Compare commits
28 Commits
adapters/o
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| baa81c4923 | |||
| c0b01debba | |||
| f8c99f3e21 | |||
| d47d0d361d | |||
| 7d0de4f57a | |||
| f56f233704 | |||
| 9a15406f76 | |||
| 23db3d8fe0 | |||
| 755cd2d3c3 | |||
| df76dcb9c0 | |||
| 96d5a3032a | |||
| 92a40f4a37 | |||
| f7409afd24 | |||
| 6138c103d5 | |||
| 7b6f3ac60c | |||
| c9e877c3f7 | |||
| d54bdd9847 | |||
| 5555a7b342 | |||
| 0782b0488b | |||
| e3d8b16844 | |||
| c2ca5e987f | |||
| 1f2e7230dc | |||
| ed17713ea1 | |||
| 4eeafdef41 | |||
| 385a618154 | |||
| 87aa710013 | |||
| 98ff028228 | |||
| 2ec13bf662 |
@@ -7,3 +7,8 @@ sdk
|
|||||||
examples
|
examples
|
||||||
deploy
|
deploy
|
||||||
*.log
|
*.log
|
||||||
|
.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/*.credentials.json
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
- P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。启用指标后的5秒后台采样使用000026–000028固定索引读取任务、交付、流程状态、Weave Inbox状态、固定事件类别、终态耗时和最近窗口内固定事件阶段耗时,每个状态或事件最多10000条、事件窗口5分钟、900ms数据库截止;采样失败保留最后成功状态并独立记录错误/超时,不能影响业务事务或把有限样本当全局精确数。
|
- P6进程观测使用internal/observability固定指标目录,拒绝未知名称、类型和动态标签,不能引入任务/租户/业务键/凭据维度。ORBIT_METRICS_ADDR默认空(关闭),显式启用独立采集监听;业务端口不得暴露全局指标。渲染和网络I/O不持有全局记录锁,每个直方图局部快照保持桶/count/sum一致,最多2个并发抓取。启用指标后的5秒后台采样使用000026–000028固定索引读取任务、交付、流程状态、Weave Inbox状态、固定事件类别、终态耗时和最近窗口内固定事件阶段耗时,每个状态或事件最多10000条、事件窗口5分钟、900ms数据库截止;采样失败保留最后成功状态并独立记录错误/超时,不能影响业务事务或把有限样本当全局精确数。
|
||||||
- HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。
|
- HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。
|
||||||
- 进程指标、固定状态采样、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md及docs/reports/phase-6-business-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压的全局聚合、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。
|
- 进程指标、固定状态采样、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md及docs/reports/phase-6-business-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压的全局聚合、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。
|
||||||
- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。不要把轮换同一凭据ID解释成历史令牌仍有效,也不得级联删除订阅、任务或事件。
|
- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。000032 后仅在 previous_valid_until 之前接受上一代摘要(最长 30 天,不超过原到期时间);默认控制台/CLI过渡7天,旧固定v1操作仍立即轮换。默认新凭据长期有效;SET_EXPIRY 保持秘密不变,FINALIZE 停用旧代,REVOKE 同时停用两代;有效过渡期禁止再次轮换挤掉旧代。不要把稳定凭据ID解释成所有历史令牌仍有效,也不得级联删除订阅、任务或事件。
|
||||||
- `sdk/go/cmd/orbit-load` 只用于隔离环境受理测量:每项使用稳定独立幂等键,固定请求形状和并发上限,按请求超时,不自动重试,不启动Worker或执行外部副作用;输出延迟百分位与有限错误分类,失败必须以非零状态返回。其结果不能替代真实Worker、流程推进、数据库拓扑或生产容量验收。
|
- `sdk/go/cmd/orbit-load` 只用于隔离环境受理测量:每项使用稳定独立幂等键,固定请求形状和并发上限,按请求超时,不自动重试,不启动Worker或执行外部副作用;输出延迟百分位与有限错误分类,失败必须以非零状态返回。其结果不能替代真实Worker、流程推进、数据库拓扑或生产容量验收。
|
||||||
- `sdk/go/cmd/orbit-exec-load` 独立测量真实Worker执行:每次运行固定专属处理器/业务键/序号/模拟时长,最多一次尝试且无外部副作用;任务成功须核对持久化结果、唯一尝试、时间顺序及单次本地调用。队列额度只读,提交不重试;整体截止和有界排空后输出实际分母,未完成/未知结果必须非零退出。数据库阶段耗时不得混用客户端轮询观察时间,不将隔离样本解释为生产容量。
|
- `sdk/go/cmd/orbit-exec-load` 独立测量真实Worker执行:每次运行固定专属处理器/业务键/序号/模拟时长,最多一次尝试且无外部副作用;任务成功须核对持久化结果、唯一尝试、时间顺序及单次本地调用。队列额度只读,提交不重试;整体截止和有界排空后输出实际分母,未完成/未知结果必须非零退出。数据库阶段耗时不得混用客户端轮询观察时间,不将隔离样本解释为生产容量。
|
||||||
- `sdk/go/cmd/orbit-workflow-load` 独立测量真实Weave流程:每次运行创建固定版本的双分支并行定义,启动实例不自动重试,必须核对流程/步骤/任务/尝试/来源/输出及两倍分支调用;队列只读,处理器无外部副作用,截止后保留实际分母并以非零状态退出。隔离样本不代表生产容量、高可用或跨故障域验收。
|
- `sdk/go/cmd/orbit-workflow-load` 独立测量真实Weave流程:每次运行创建固定版本的双分支并行定义,启动实例不自动重试,必须核对流程/步骤/任务/尝试/来源/输出及两倍分支调用;队列只读,处理器无外部副作用,截止后保留实际分母并以非零状态退出。隔离样本不代表生产容量、高可用或跨故障域验收。
|
||||||
@@ -93,3 +93,7 @@
|
|||||||
- `sdk/go/cmd/orbit-schedule-load` 使用同时起止的有限FIXED_RATE计划测量集中触发;固定版本/时区快照、触发时间槽、任务/尝试/来源、本地调用逐项核对。配置总数、已观察、已触发和已核验分开计数;截止保留原操作身份并有界暂停,不取消已生成任务;暂停只在明确版本冲突后换键,传输不明不得新键重试。样本不代表长期积压容量或生产HA。
|
- `sdk/go/cmd/orbit-schedule-load` 使用同时起止的有限FIXED_RATE计划测量集中触发;固定版本/时区快照、触发时间槽、任务/尝试/来源、本地调用逐项核对。配置总数、已观察、已触发和已核验分开计数;截止保留原操作身份并有界暂停,不取消已生成任务;暂停只在明确版本冲突后换键,传输不明不得新键重试。样本不代表长期积压容量或生产HA。
|
||||||
|
|
||||||
- 流程测量核验必须绑定本次发布的不可变版本ID及完整规范,步骤精确为pair/left/right且任务/激活唯一;按任务和尝试关联本地调用,不能用每实例总次数抵充缺失分支。零序号也要求精确必填字段,拒绝重复键/别名/null;报告保留逐请求原键与ID和未确认调用分母。源码更新后重新构建并运行真实样本,原二进制结果不得配上更新后的源码摘要。
|
- 流程测量核验必须绑定本次发布的不可变版本ID及完整规范,步骤精确为pair/left/right且任务/激活唯一;按任务和尝试关联本地调用,不能用每实例总次数抵充缺失分支。零序号也要求精确必填字段,拒绝重复键/别名/null;报告保留逐请求原键与ID和未确认调用分母。源码更新后重新构建并运行真实样本,原二进制结果不得配上更新后的源码摘要。
|
||||||
|
|
||||||
|
- 管理员接入信息按用户要求支持随时查看/复制当前 Token。000033 的 secret_key_id / secret_ciphertext 仅存 AES-256-GCM 密文,主密钥来自 ORBIT_CREDENTIAL_KEY_FILE,所有节点和管理 CLI 共用;不得记录秘密到日志/审计/幂等回执。查看须管理员会话、同源 POST、核对当前版本和摘要,并确认审计提交后才交付。历史导入只接受当前摘要,不接受过渡期旧代;不轮换身份、不改认证 revision/有效期/过渡截止。轮换原子替换密文,撤销清除密文;没有主密钥的旧 CLI 轮换必须清除过期密文。普通列表仅返回 token_stored,不自动读取秘密。
|
||||||
|
|
||||||
|
- 000034 通用退役不删除历史或回收名称,不含调用方业务判断。订阅退役按操作身份→结果流→订阅持锁,以提交水位核对未物化匹配结果、pending 与实际未确认交付;只有结清后才释放活动名额,暂停仍计入 64。队列退役按应用额度→身份→队列锁,保护未完成/未知任务、许可、活动计划、固定版本引用的活动流程及未退役订阅。所有新工作入口检查 retired_at,旧历史与原幂等回执仍可读取;旧写节点停止后迁移,禁止混跑忽略退役的服务端。
|
||||||
|
|||||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
代码交付主分支为 `main`。采用 `v主版本.次版本.修订号`,已发布标签不可变;补丁按 `v0.1.1`、`v0.1.2` 递增,业务项目固定具体版本。
|
代码交付主分支为 `main`。采用 `v主版本.次版本.修订号`,已发布标签不可变;补丁按 `v0.1.1`、`v0.1.2` 递增,业务项目固定具体版本。
|
||||||
|
|
||||||
|
## main — 2026-09-29
|
||||||
|
|
||||||
|
- 登录增加 MySQL 持久失败计数、临时锁定、账号/IP 限流、可信代理解析与审计查询,阈值及时间可配置;迁移 000035,无外键。
|
||||||
|
- 补齐根页面和实际滚动区的边界回弹限制,修复侧栏与内容随触控板弹动。
|
||||||
|
|
||||||
|
- 修复长页面滚动体验:固定导航与页头、内容独立滚动,应用详情后台刷新避免重建相同快照及打断交互。
|
||||||
|
|
||||||
|
- 筛选范围改为可搜索的浅色菜单;四个资源列表默认每页 10 条,登录页大屏内容居中,统一筛选区与表格间距。
|
||||||
|
|
||||||
|
- 管理端统一浅色工作台、分组导航、紧凑总览、中文状态和移动端布局。
|
||||||
|
- 任务/流程增加服务端状态、精确业务键和时间筛选;任务、计划、流程、订阅增加游标分页、页大小与 ID 定位。刷新保留查询,迟到响应不覆盖新结果。
|
||||||
|
- 应用搜索、接入配置集中展示、低频操作折叠;详情支持复制 ID、键盘焦点与返回入口,Token 查看/复制及原有审计规则保持。
|
||||||
|
- 此前浅色工作台改版只更新控制台;本次登录保护增加管理接口与迁移 000035,业务 SDK 协议不变。SDK 继续使用 v0.1.2,Outbox 继续使用 v0.1.1。[验收记录](docs/reports/console-refresh.md)。
|
||||||
|
|
||||||
|
## v0.1.2 — 2026-09-27
|
||||||
|
|
||||||
|
- 通用队列/订阅永久退役、活动订阅名额释放、依赖与积压保护;历史、名称、审计和原幂等回执保留。
|
||||||
|
- SDK 新增 `RetireQueue` / `RetireSubscription`,新增完整接口文档和可运行资源生命周期示例。
|
||||||
|
- 应用接入页明确选择并记忆队列、展示暂停/退役和容量;未补存 Token 文案保持一致,禁止静默替换复制目标。
|
||||||
|
- 汇总 main 已交付的应用接入、默认长期凭据、有界新旧 Token 过渡和管理员加密保管/查看/复制能力。
|
||||||
|
|
||||||
|
服务端标签 `v0.1.2`、SDK 标签 `sdk/go/v0.1.2`;Outbox 未修改,继续使用 `adapters/outbox/mysql/v0.1.1`。服务端升级需停旧写节点、备份后前向迁移到 000034;旧 v0.1.1 SDK 的既有接口保持兼容,退役方法需要新 SDK。退役不删除数据,不代替生产环境容量与高可用验收。
|
||||||
|
|
||||||
|
验证范围见[资源退役报告](docs/reports/resource-retirement.md)。
|
||||||
|
|
||||||
## v0.1.1 — 2026-09-24
|
## v0.1.1 — 2026-09-24
|
||||||
|
|
||||||
本版汇总 v0.1.0 之后已完成的控制台、运维和业务接入能力,并统一示例和接入文档。
|
本版汇总 v0.1.0 之后已完成的控制台、运维和业务接入能力,并统一示例和接入文档。
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,17 +1,19 @@
|
|||||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
ARG TARGETOS=linux
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
|
||||||
ARG TARGETOS
|
FROM registry.cn-shenzhen.aliyuncs.com/yuntuo_tools/golang:1.27.1-alpine AS build
|
||||||
ARG TARGETARCH
|
|
||||||
|
|
||||||
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS build
|
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG REVISION=unknown
|
ARG REVISION=unknown
|
||||||
ARG BUILD_TIME=unknown
|
ARG BUILD_TIME=unknown
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
ENV GOWORK=off
|
ENV GO111MODULE=on \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
GOPROXY=https://goproxy.cn,direct \
|
||||||
|
GOWORK=off
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -1,6 +1,6 @@
|
|||||||
GO ?= go
|
GO ?= go
|
||||||
|
|
||||||
.PHONY: fmt test vet build adapters-check image integration console-e2e compose-check mysql-replication mysql-compatibility sdk-check protocol-check docs-check stability-check schedule-backlog release-manifest release-local-check
|
.PHONY: fmt test vet build adapters-check image integration console-e2e console-check compose-check mysql-replication mysql-compatibility sdk-check protocol-check docs-check stability-check schedule-backlog release-manifest release-local-check
|
||||||
fmt:
|
fmt:
|
||||||
$(GO) fmt ./...
|
$(GO) fmt ./...
|
||||||
cd sdk/go && $(GO) fmt ./...
|
cd sdk/go && $(GO) fmt ./...
|
||||||
@@ -44,6 +44,12 @@ image:
|
|||||||
integration:
|
integration:
|
||||||
./scripts/test-mysql.sh
|
./scripts/test-mysql.sh
|
||||||
|
|
||||||
|
console-check:
|
||||||
|
node --check internal/transport/httpserver/console/console-select.js
|
||||||
|
node --check internal/transport/httpserver/console/app.js
|
||||||
|
node --check internal/transport/httpserver/console/applications.js
|
||||||
|
node --test scripts/test-console-state.cjs
|
||||||
|
|
||||||
console-e2e:
|
console-e2e:
|
||||||
./scripts/test-console-e2e.sh
|
./scripts/test-console-e2e.sh
|
||||||
|
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
|
- **Pulse · 脉动**:异步任务、延迟执行、定时计划、执行授权、重试与结果订阅。
|
||||||
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
- **Weave · 织流**:研发定义并发布流程,支持顺序、并行、条件分支、等待信号、有界查询和人工恢复。
|
||||||
- **Console**:账号密码登录的管理页面,查看任务、计划、流程、结果交付及归档作业。
|
- **Console**:账号密码登录的管理工作台,按应用与环境查看运行快照;支持任务/流程筛选、资源分页、ID 定位、审计与接入配置复制;具备连续错误临时锁定、账号/IP 限流及登录审计。[控制台使用说明](docs/console.md) · [登录保护配置](docs/console-login-security.md)。
|
||||||
|
|
||||||
主分支:`main`。当前版本:**`v0.1.1`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
主分支:`main`。当前版本:**`v0.1.2`**。源码、SDK 和 Outbox 适配包采用固定版本发布,已发布标签不覆盖。变更见 [CHANGELOG](CHANGELOG.md)。
|
||||||
|
|
||||||
## 从这里开始
|
## 从这里开始
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
| 使用 Docker 启动,后续接入域名 | [Docker 与域名部署](#docker-与域名部署) |
|
| 使用 Docker 启动,后续接入域名 | [Docker 与域名部署](#docker-与域名部署) |
|
||||||
| 了解全部能力和用法 | [功能一览](#功能一览) |
|
| 了解全部能力和用法 | [功能一览](#功能一览) |
|
||||||
| 业务 Go 项目接入 | [SDK 怎么调用](#sdk-怎么调用) |
|
| 业务 Go 项目接入 | [SDK 怎么调用](#sdk-怎么调用) |
|
||||||
| 直接运行完整场景 | [examples:27 个可运行示例](examples/README.md) |
|
| 直接运行完整场景 | [examples:28 个可运行示例](examples/README.md) |
|
||||||
| 嵌入现有 HTTP 服务 | [HTTP + Outbox + Worker + Inbox](examples/http-service/README.md) |
|
| 嵌入现有 HTTP 服务 | [HTTP + Outbox + Worker + Inbox](examples/http-service/README.md) |
|
||||||
| 查字段、错误码、权限及协议 | [HTTP / WebSocket API](docs/api/README.md)、[Go SDK 手册](docs/sdk/README.md) |
|
| 查字段、错误码、权限及协议 | [HTTP / WebSocket API](docs/api/README.md)、[Go SDK 手册](docs/sdk/README.md) |
|
||||||
| 配置、排障、备份、发布 | [配置与运维](#配置与运维)、[运行手册](docs/20-operations-runbook.md) |
|
| 配置、排障、备份、发布 | [配置与运维](#配置与运维)、[运行手册](docs/20-operations-runbook.md) |
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
| 安全重试 | 冻结次数、退避与总时限;明确可重试失败自动再执行,未知效果按安全策略核查 | [retry](examples/retry/README.md) · [重试策略](docs/api/retries.md) |
|
| 安全重试 | 冻结次数、退避与总时限;明确可重试失败自动再执行,未知效果按安全策略核查 | [retry](examples/retry/README.md) · [重试策略](docs/api/retries.md) |
|
||||||
| 取消 | `CancelTask` 保存取消意图;运行中的处理器配合停止并结清 | [cancellation](examples/cancellation/README.md) |
|
| 取消 | `CancelTask` 保存取消意图;运行中的处理器配合停止并结清 | [cancellation](examples/cancellation/README.md) |
|
||||||
| 定时计划 | Cron / 固定间隔、时区、不可变版本、漏跑策略、暂停/恢复和触发历史 | [schedules](examples/schedules/README.md) · [计划接口](docs/api/schedules.md) |
|
| 定时计划 | Cron / 固定间隔、时区、不可变版本、漏跑策略、暂停/恢复和触发历史 | [schedules](examples/schedules/README.md) · [计划接口](docs/api/schedules.md) |
|
||||||
|
| 资源退役 | 结清后退役订阅与队列,释放订阅名额,保留历史和审计 | [运行示例](sdk/go/examples/resource-lifecycle/README.md) · [接口与规则](docs/api/resource-retirement.md) |
|
||||||
| 队列资源控制 | 创建/配置队列并发、开始速率、积压上限和暂停;满额返回背压 | [resources](examples/resources/README.md) · [队列接口](docs/api/queues.md) |
|
| 队列资源控制 | 创建/配置队列并发、开始速率、积压上限和暂停;满额返回背压 | [resources](examples/resources/README.md) · [队列接口](docs/api/queues.md) |
|
||||||
| 应用共享配额 | 同一应用的多个命名空间/队列共享总额度 | [application-resources](examples/application-resources/README.md) |
|
| 应用共享配额 | 同一应用的多个命名空间/队列共享总额度 | [application-resources](examples/application-resources/README.md) |
|
||||||
| 资源键互斥 | `ConcurrencyKey` 协调跨队列同资源任务,资源端核验递增许可序号 | [keyed-concurrency](examples/keyed-concurrency/README.md) |
|
| 资源键互斥 | `ConcurrencyKey` 协调跨队列同资源任务,资源端核验递增许可序号 | [keyed-concurrency](examples/keyed-concurrency/README.md) |
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
| 业务结果 Inbox | 事件去重与业务更新同事务,提交成功后确认交付 | [result-inbox](examples/result-inbox/README.md) |
|
| 业务结果 Inbox | 事件去重与业务更新同事务,提交成功后确认交付 | [result-inbox](examples/result-inbox/README.md) |
|
||||||
| HTTP 服务完整生命周期 | 业务接口、Outbox、Worker、结果消费、就绪检查和有界关闭放进同一业务服务 | [http-service](examples/http-service/README.md) |
|
| HTTP 服务完整生命周期 | 业务接口、Outbox、Worker、结果消费、就绪检查和有界关闭放进同一业务服务 | [http-service](examples/http-service/README.md) |
|
||||||
| 平台身份与凭据 | 应用/命名空间/队列授权范围、凭据轮换/撤销;SDK 不直连平台数据库 | [鉴权](docs/api/authentication.md) · [凭据管理](docs/api/authentication.md) |
|
| 平台身份与凭据 | 应用/命名空间/队列授权范围、凭据轮换/撤销;SDK 不直连平台数据库 | [鉴权](docs/api/authentication.md) · [凭据管理](docs/api/authentication.md) |
|
||||||
| 账号密码控制台 | 初始化管理员,登录查看统计、任务/计划/流程/交付详情和管理操作,未登录跳转登录页 | [Console 手册](docs/console.md) |
|
| 账号密码控制台 | 初始化管理员,按应用/环境/队列查看任务、计划、流程、交付与归档,列表和详情显示应用归属 | [Console 手册](docs/console.md) · [应用范围](docs/api/console-scope.md) |
|
||||||
| 保留策略与周期归档 | 配置各类数据保留天数,持久化归档计划/作业,分页、续跑、重试与文件核验 | [保留策略](docs/api/retention.md) · [周期归档](docs/api/archive-scheduler.md) |
|
| 保留策略与周期归档 | 配置各类数据保留天数,持久化归档计划/作业,分页、续跑、重试与文件核验 | [保留策略](docs/api/retention.md) · [周期归档](docs/api/archive-scheduler.md) |
|
||||||
| 运维与观测 | 健康/readiness、独立指标端口、迁移核验、负载工具、备份恢复与故障演练 | [观测](docs/api/observability.md) · [运维](docs/20-operations-runbook.md) |
|
| 运维与观测 | 健康/readiness、独立指标端口、迁移核验、负载工具、备份恢复与故障演练 | [观测](docs/api/observability.md) · [运维](docs/20-operations-runbook.md) |
|
||||||
|
|
||||||
@@ -76,14 +77,14 @@
|
|||||||
### 1. 获取代码并迁移数据库
|
### 1. 获取代码并迁移数据库
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone --branch v0.1.1 https://git.yuntuo.hk/k2safe/orbit.git
|
git clone --branch v0.1.2 https://git.yuntuo.hk/k2safe/orbit.git
|
||||||
cd orbit
|
cd orbit
|
||||||
export ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit'
|
export ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit'
|
||||||
export ORBIT_HTTP_ADDR='127.0.0.1:8080'
|
export ORBIT_HTTP_ADDR='127.0.0.1:8080'
|
||||||
go run ./cmd/orbit-migrate up
|
go run ./cmd/orbit-migrate up
|
||||||
```
|
```
|
||||||
|
|
||||||
DSN 替换为实际账号密码。`--branch v0.1.1` 检出固定发布版本;参与开发使用 `git switch main`。已有库先备份,再按[升级与发布手册](docs/20-operations-runbook.md)停旧写节点、迁移和核验;不修改已应用的迁移文件。
|
DSN 替换为实际账号密码。`--branch v0.1.2` 检出固定发布版本;参与开发使用 `git switch main`。已有库先备份,再按[升级与发布手册](docs/20-operations-runbook.md)停旧写节点、迁移和核验;不修改已应用的迁移文件。
|
||||||
|
|
||||||
### 2. 一次性创建示例资源与管理员
|
### 2. 一次性创建示例资源与管理员
|
||||||
|
|
||||||
@@ -103,6 +104,8 @@ python3 -c 'import getpass; print(getpass.getpass("设置管理员密码(至
|
|||||||
--console-password-stdin --output .local/development.credentials.json
|
--console-password-stdin --output .local/development.credentials.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
管理员可在「应用接入」页面新建应用和初始队列、查看和调整有效期、平滑换证(默认长期 Token,换证时新旧共存 7 天),随时查看、复制包含当前 Token 的完整接入配置,或下载 orbit.env。设置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一接入管理员,并通过 `ORBIT_CREDENTIAL_KEY_FILE` 配置 Token 加密保管密钥;本地 Compose 自动生成并持久保存。[应用接入使用与接口文档](docs/api/console-applications.md)。
|
||||||
|
|
||||||
初始化只运行一次,后续重启复用数据库、账号和 SDK 凭据。凭据 JSON 是服务端程序访问 API 的秘密文件,**控制台不需要选择或上传它**。已有资源增加管理员的方法见 [Console 手册](docs/console.md);真实接入按实际角色缩小权限。
|
初始化只运行一次,后续重启复用数据库、账号和 SDK 凭据。凭据 JSON 是服务端程序访问 API 的秘密文件,**控制台不需要选择或上传它**。已有资源增加管理员的方法见 [Console 手册](docs/console.md);真实接入按实际角色缩小权限。
|
||||||
|
|
||||||
### 3. 启动平台,进入控制台
|
### 3. 启动平台,进入控制台
|
||||||
@@ -144,12 +147,12 @@ worker_drained=true started=3 confirmed=3 running=0
|
|||||||
在自己的业务项目中执行:
|
在自己的业务项目中执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1
|
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2
|
||||||
# 业务事务需要 MySQL Outbox 时再安装:
|
# 业务事务需要 MySQL Outbox 时再安装:
|
||||||
go get git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql@v0.1.1
|
go get git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql@v0.1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
无需本地源码或 `replace`。SDK 是独立 Go module,只依赖标准库和 WebSocket 客户端;MySQL 驱动属于可选适配包。子模块的 Git 标签分别为 `sdk/go/v0.1.1` 和 `adapters/outbox/mysql/v0.1.1`。
|
无需本地源码或 `replace`。SDK 是独立 Go module,只依赖标准库和 WebSocket 客户端;MySQL 驱动属于可选适配包。子模块的 Git 标签分别为 `sdk/go/v0.1.2` 和 `adapters/outbox/mysql/v0.1.1`。
|
||||||
|
|
||||||
### 提交、执行、接收结果
|
### 提交、执行、接收结果
|
||||||
|
|
||||||
@@ -203,6 +206,8 @@ fmt.Println(current.ID, current.State)
|
|||||||
|
|
||||||
## Docker 与域名部署
|
## Docker 与域名部署
|
||||||
|
|
||||||
|
本机给其他服务联调,可直接运行 `./scripts/orbit-local.py up`,自动部署到 `18080`,生成管理员和 SDK 配置;完整地址、Docker 网络与启停方法见[本地 Compose 测试环境](deploy/local/README.md)。
|
||||||
|
|
||||||
仓库提供 [Dockerfile](Dockerfile)、[Compose 启动链](deploy/docker-compose.yml)和 [Nginx 配置](deploy/nginx/orbit.conf.example)。按[完整 Compose 手册](docs/22-compose-deployment.md)注入数据库密码、构建镜像、启动 MySQL/迁移/核验、初始化管理员,最后启动 Orbit。手册也包括停止、更新与两节点覆盖配置。
|
仓库提供 [Dockerfile](Dockerfile)、[Compose 启动链](deploy/docker-compose.yml)和 [Nginx 配置](deploy/nginx/orbit.conf.example)。按[完整 Compose 手册](docs/22-compose-deployment.md)注入数据库密码、构建镜像、启动 MySQL/迁移/核验、初始化管理员,最后启动 Orbit。手册也包括停止、更新与两节点覆盖配置。
|
||||||
|
|
||||||
- Console 同源访问:域名反向代理到 Orbit 后,页面自动使用这个域名,无需在界面填写服务地址。
|
- Console 同源访问:域名反向代理到 Orbit 后,页面自动使用这个域名,无需在界面填写服务地址。
|
||||||
@@ -219,6 +224,7 @@ fmt.Println(current.ID, current.State)
|
|||||||
| `ORBIT_MYSQL_DSN` | 平台数据库连接,迁移账号与运行账号按部署手册分开 |
|
| `ORBIT_MYSQL_DSN` | 平台数据库连接,迁移账号与运行账号按部署手册分开 |
|
||||||
| `ORBIT_HTTP_ADDR` | 服务进程监听地址,例 `127.0.0.1:8080` |
|
| `ORBIT_HTTP_ADDR` | 服务进程监听地址,例 `127.0.0.1:8080` |
|
||||||
| `ORBIT_CONSOLE_QUEUE_ID` | 控制台默认队列;不授予账号额外权限 |
|
| `ORBIT_CONSOLE_QUEUE_ID` | 控制台默认队列;不授予账号额外权限 |
|
||||||
|
| `ORBIT_CONSOLE_APPLICATION_ADMIN` | 指定唯一可接入和管理各应用的控制台登录名;空值不开放跨应用权限,多节点保持一致 |
|
||||||
| `ORBIT_DB_MAX_OPEN` / `ORBIT_DB_MAX_IDLE` | 数据库连接池上限 |
|
| `ORBIT_DB_MAX_OPEN` / `ORBIT_DB_MAX_IDLE` | 数据库连接池上限 |
|
||||||
| `ORBIT_SHUTDOWN_TIMEOUT` | SIGTERM 关闭窗口,默认 10s |
|
| `ORBIT_SHUTDOWN_TIMEOUT` | SIGTERM 关闭窗口,默认 10s |
|
||||||
| `ORBIT_WORKFLOW_TICK_INTERVAL` / `ORBIT_SCHEDULE_TICK_INTERVAL` | 流程/计划后台扫描间隔,默认 1s |
|
| `ORBIT_WORKFLOW_TICK_INTERVAL` / `ORBIT_SCHEDULE_TICK_INTERVAL` | 流程/计划后台扫描间隔,默认 1s |
|
||||||
@@ -267,11 +273,12 @@ docs/ API、SDK、场景、设计及验收报告
|
|||||||
make test vet build # 7 个模块的竞态测试、静态检查与构建
|
make test vet build # 7 个模块的竞态测试、静态检查与构建
|
||||||
make sdk-check protocol-check docs-check
|
make sdk-check protocol-check docs-check
|
||||||
make integration # 临时 MySQL、真实平台、示例及故障回归
|
make integration # 临时 MySQL、真实平台、示例及故障回归
|
||||||
|
make console-check # 前端查询状态回归(开发机需 Node.js)
|
||||||
make console-e2e # 控制台端到端回归
|
make console-e2e # 控制台端到端回归
|
||||||
```
|
```
|
||||||
|
|
||||||
集成测试需要 Docker、Python 3 和 OpenSSL 等工具,由脚本创建独立临时数据库并清理自建容器。生产容量与故障域验收另见[交付清单](docs/release-checklist.md);有限样本不能代表百万企业规模的生产容量。
|
集成测试需要 Docker、Python 3 和 OpenSSL 等工具,由脚本创建独立临时数据库并清理自建容器。生产容量与故障域验收另见[交付清单](docs/release-checklist.md);有限样本不能代表百万企业规模的生产容量。
|
||||||
|
|
||||||
`v0.1.1` 包含账号密码控制台、归档策略/周期作业、Outbox 公共包、HTTP 生命周期示例及统一示例目录;与上一版本的升级说明和验证结果见 [CHANGELOG](CHANGELOG.md) 与 [发布记录](docs/reports/v0.1.1-release.md)。当前未提供源数据删除、其他数据库 Outbox 适配,也不把单机或隔离环境多节点测试等同于生产跨故障域高可用验收。
|
`v0.1.2` 新增通用资源退役;此前已包含账号密码控制台、归档策略/周期作业、Outbox 公共包、HTTP 生命周期示例及统一示例目录;与上一版本的升级说明和验证结果见 [CHANGELOG](CHANGELOG.md) 与 [本次验证](docs/reports/resource-retirement.md)。当前未提供源数据删除、其他数据库 Outbox 适配,也不把单机或隔离环境多节点测试等同于生产跨故障域高可用验收。
|
||||||
|
|
||||||
更多资料:[设计文档](docs/README.md) · [持续开发计划](docs/08-development-plan.md) · [接口目录](docs/api/README.md) · [示例目录](examples/README.md) · [发布规则](docs/sdk/release.md)。
|
更多资料:[设计文档](docs/README.md) · [持续开发计划](docs/08-development-plan.md) · [接口目录](docs/api/README.md) · [示例目录](examples/README.md) · [发布规则](docs/sdk/release.md)。
|
||||||
|
|||||||
@@ -13,25 +13,30 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/config"
|
"git.yuntuo.hk/k2safe/orbit/internal/config"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
||||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runCredential(args []string) error {
|
func runCredential(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: orbit-admin credential status|rotate|revoke|apply")
|
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "status":
|
case "status":
|
||||||
return runCredentialStatus(args[1:])
|
return runCredentialStatus(args[1:])
|
||||||
case "rotate":
|
case "rotate":
|
||||||
return runCredentialChange(args[1:], "ROTATE")
|
return runCredentialChange(args[1:], "ROTATE")
|
||||||
|
case "expiry":
|
||||||
|
return runCredentialChange(args[1:], "SET_EXPIRY")
|
||||||
|
case "finalize":
|
||||||
|
return runCredentialChange(args[1:], "FINALIZE")
|
||||||
case "revoke":
|
case "revoke":
|
||||||
return runCredentialChange(args[1:], "REVOKE")
|
return runCredentialChange(args[1:], "REVOKE")
|
||||||
case "apply":
|
case "apply":
|
||||||
return runCredentialApply(args[1:])
|
return runCredentialApply(args[1:])
|
||||||
default:
|
default:
|
||||||
return errors.New("usage: orbit-admin credential status|rotate|revoke|apply")
|
return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +69,19 @@ func runCredentialStatus(args []string) error {
|
|||||||
|
|
||||||
func runCredentialChange(args []string, action string) error {
|
func runCredentialChange(args []string, action string) error {
|
||||||
flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError)
|
flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError)
|
||||||
namespace, credential, output, reason, validFor := "", "", "", "", "720h"
|
namespace, credential, output, reason, validFor := "", "", "", "", "0"
|
||||||
|
grace := time.Duration(0)
|
||||||
|
if action == "ROTATE" {
|
||||||
|
grace = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
var expected uint64
|
var expected uint64
|
||||||
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
|
flags.StringVar(&namespace, "namespace-id", "", "credential namespace id")
|
||||||
flags.StringVar(&credential, "credential-id", "", "credential id")
|
flags.StringVar(&credential, "credential-id", "", "credential id")
|
||||||
flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply")
|
flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply")
|
||||||
flags.StringVar(&reason, "reason", "", "required audit reason")
|
flags.StringVar(&reason, "reason", "", "required audit reason")
|
||||||
flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten")
|
flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten")
|
||||||
flags.StringVar(&validFor, "valid-for", "720h", "new credential validity, from 1h to 8760h")
|
flags.StringVar(&validFor, "valid-for", "0", "credential validity: 0 permanent, or 1h to 8760h")
|
||||||
|
flags.DurationVar(&grace, "grace", grace, "rotation overlap: 0 immediate, up to 720h; default 168h")
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ func runCredentialChange(args []string, action string) error {
|
|||||||
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
|
return errors.New("credential change requires valid ids and a nonzero --expected-revision")
|
||||||
}
|
}
|
||||||
duration := time.Duration(0)
|
duration := time.Duration(0)
|
||||||
if action == "ROTATE" {
|
if action == "ROTATE" || action == "SET_EXPIRY" {
|
||||||
duration, err = time.ParseDuration(validFor)
|
duration, err = time.ParseDuration(validFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("invalid --valid-for duration")
|
return errors.New("invalid --valid-for duration")
|
||||||
@@ -95,7 +105,7 @@ func runCredentialChange(args []string, action string) error {
|
|||||||
}
|
}
|
||||||
defer closeDB()
|
defer closeDB()
|
||||||
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
|
op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{
|
||||||
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, Reason: reason,
|
NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("credential operation could not be prepared")
|
return errors.New("credential operation could not be prepared")
|
||||||
@@ -103,7 +113,11 @@ func runCredentialChange(args []string, action string) error {
|
|||||||
if err := writePrivateOperation(output, op); err != nil {
|
if err := writePrivateOperation(output, op); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op)
|
vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("credential operation was not confirmed; the private operation file was retained for reconciliation")
|
return errors.New("credential operation was not confirmed; the private operation file was retained for reconciliation")
|
||||||
}
|
}
|
||||||
@@ -129,7 +143,11 @@ func runCredentialApply(args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer closeDB()
|
defer closeDB()
|
||||||
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op)
|
vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("credential operation was not confirmed; retry with the same private operation file")
|
return errors.New("credential operation was not confirmed; retry with the same private operation file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
|
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/config"
|
"git.yuntuo.hk/k2safe/orbit/internal/config"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
||||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
)
|
)
|
||||||
@@ -257,6 +258,7 @@ func runBootstrap(args []string) error {
|
|||||||
var in mysql.BootstrapInput
|
var in mysql.BootstrapInput
|
||||||
var output string
|
var output string
|
||||||
flags.StringVar(&in.Application, "application", "", "new application name")
|
flags.StringVar(&in.Application, "application", "", "new application name")
|
||||||
|
flags.DurationVar(&in.ValidFor, "valid-for", 0, "initial credential validity: 0 permanent, or 1h to 8760h")
|
||||||
flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error {
|
flags.Func("application-id", "existing application id; mutually exclusive with --application", func(v string) error {
|
||||||
in.ExistingApplicationID = core.ID(v)
|
in.ExistingApplicationID = core.ID(v)
|
||||||
return in.ExistingApplicationID.Validate()
|
return in.ExistingApplicationID.Validate()
|
||||||
@@ -305,6 +307,10 @@ func runBootstrap(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
vault, err := credentialvault.LoadFile(cfg.CredentialKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("cannot create new private output file (existing files are never overwritten)")
|
return errors.New("cannot create new private output file (existing files are never overwritten)")
|
||||||
@@ -344,14 +350,14 @@ func runBootstrap(args []string) error {
|
|||||||
}
|
}
|
||||||
delivered = true
|
delivered = true
|
||||||
return nil
|
return nil
|
||||||
})
|
}, vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if delivered {
|
if delivered {
|
||||||
return errors.New("bootstrap commit could not be confirmed; private output was retained; verify its credential before retrying")
|
return errors.New("bootstrap commit could not be confirmed; private output was retained; verify its credential before retrying")
|
||||||
}
|
}
|
||||||
return errors.New("bootstrap failed before credential delivery; check schema, names and database access")
|
return errors.New("bootstrap failed before credential delivery; check schema, names and database access")
|
||||||
}
|
}
|
||||||
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.")
|
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; expiry follows --valid-for (default: permanent).")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/config"
|
"git.yuntuo.hk/k2safe/orbit/internal/config"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/observability"
|
"git.yuntuo.hk/k2safe/orbit/internal/observability"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
"git.yuntuo.hk/k2safe/orbit/internal/storage/mysql"
|
||||||
@@ -41,6 +42,13 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
vault, err := credentialvault.LoadFile(cfg.CredentialKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.ConsoleApplicationAdmin != "" && vault == nil {
|
||||||
|
return errors.New("ORBIT_CREDENTIAL_KEY_FILE is required for console application administration")
|
||||||
|
}
|
||||||
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
startup, cancel := context.WithTimeout(root, 10*time.Second)
|
startup, cancel := context.WithTimeout(root, 10*time.Second)
|
||||||
@@ -85,7 +93,29 @@ func run() error {
|
|||||||
txStats := mysql.ReadTransactionStats()
|
txStats := mysql.ReadTransactionStats()
|
||||||
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
|
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
|
||||||
}
|
}
|
||||||
consoleAuth := mysql.NewConsoleAuthStore(db)
|
consoleAuth := mysql.NewConsoleAuthStore(db, cfg.ConsoleLogin)
|
||||||
|
// 登录安全记录独立保留,每分钟有界清理,关闭时等待清理器退出。
|
||||||
|
loginCleanupCtx, stopLoginCleanup := context.WithCancel(root)
|
||||||
|
loginCleanupDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(loginCleanupDone)
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-loginCleanupCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
call, cancel := context.WithTimeout(loginCleanupCtx, 5*time.Second)
|
||||||
|
err := consoleAuth.CleanupLoginSecurity(call)
|
||||||
|
cancel()
|
||||||
|
if err != nil && loginCleanupCtx.Err() == nil {
|
||||||
|
slog.Error("console login security cleanup failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer func() { stopLoginCleanup(); <-loginCleanupDone }()
|
||||||
retentionStore := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults)
|
retentionStore := mysql.NewRetentionPolicyStore(db, cfg.RetentionDefaults)
|
||||||
server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db)))
|
server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db)))
|
||||||
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
|
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
|
||||||
@@ -125,7 +155,10 @@ func run() error {
|
|||||||
httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db)))
|
httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db)))
|
||||||
httpserver.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore)
|
httpserver.RegisterConsoleRetention(server, consoleAuth, retentionStore, retentionStore)
|
||||||
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
|
httpserver.RegisterConsoleArchiveJobs(server, consoleAuth, retentionStore, cfg.ArchiveStorageID)
|
||||||
httpserver.WrapConsoleAuth(server, consoleAuth)
|
consoleApplications := mysql.NewConsoleApplicationStore(db, cfg.ConsoleApplicationAdmin, vault)
|
||||||
|
httpserver.RegisterConsoleApplications(server, consoleAuth, consoleApplications)
|
||||||
|
httpserver.RegisterConsoleScopes(server, consoleApplications)
|
||||||
|
httpserver.WrapConsoleAuth(server, consoleAuth, consoleApplications)
|
||||||
httpserver.WrapMetrics(server, metrics)
|
httpserver.WrapMetrics(server, metrics)
|
||||||
|
|
||||||
if cfg.ArchiveRoot != "" {
|
if cfg.ArchiveRoot != "" {
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ x-orbit-environment: &orbit-environment
|
|||||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||||
|
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||||
|
ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5}
|
||||||
|
ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m}
|
||||||
|
ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m}
|
||||||
|
ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10}
|
||||||
|
ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30}
|
||||||
|
ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m}
|
||||||
|
ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90}
|
||||||
|
ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-}
|
||||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ services:
|
|||||||
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
|
ORBIT_RETENTION_DELIVERY_DAYS: ${ORBIT_RETENTION_DELIVERY_DAYS:-90}
|
||||||
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
ORBIT_RETENTION_INBOX_DAYS: ${ORBIT_RETENTION_INBOX_DAYS:-90}
|
||||||
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
ORBIT_RETENTION_AUDIT_DAYS: ${ORBIT_RETENTION_AUDIT_DAYS:-2555}
|
||||||
|
ORBIT_CONSOLE_APPLICATION_ADMIN: ${ORBIT_CONSOLE_APPLICATION_ADMIN:-}
|
||||||
|
ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT: ${ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT:-5}
|
||||||
|
ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW: ${ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW:-15m}
|
||||||
|
ORBIT_CONSOLE_LOGIN_LOCK_DURATION: ${ORBIT_CONSOLE_LOGIN_LOCK_DURATION:-15m}
|
||||||
|
ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT: ${ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT:-10}
|
||||||
|
ORBIT_CONSOLE_LOGIN_IP_LIMIT: ${ORBIT_CONSOLE_LOGIN_IP_LIMIT:-30}
|
||||||
|
ORBIT_CONSOLE_LOGIN_RATE_WINDOW: ${ORBIT_CONSOLE_LOGIN_RATE_WINDOW:-1m}
|
||||||
|
ORBIT_CONSOLE_LOGIN_AUDIT_DAYS: ${ORBIT_CONSOLE_LOGIN_AUDIT_DAYS:-90}
|
||||||
|
ORBIT_CONSOLE_TRUSTED_PROXIES: ${ORBIT_CONSOLE_TRUSTED_PROXIES:-}
|
||||||
|
ORBIT_CREDENTIAL_KEY_FILE: ${ORBIT_CREDENTIAL_KEY_FILE:-}
|
||||||
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
ORBIT_CONSOLE_QUEUE_ID: ${ORBIT_CONSOLE_QUEUE_ID:-}
|
||||||
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
ORBIT_METRICS_ADDR: ${ORBIT_METRICS_ADDR:-}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
148
deploy/local/README.md
Normal file
148
deploy/local/README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 本地 Docker Compose 联调环境
|
||||||
|
|
||||||
|
一套长期保留数据的 Orbit + MySQL 测试环境。项目名固定为 `orbit-test`,本机端口 `18080`;复用[基础 Compose](../docker-compose.yml),本目录补充镜像构建、跨服务网络和持久归档卷。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
此部署工具随 `main` 分支交付;使用当前 main 代码,新接入建议固定 SDK v0.1.2;既有 SDK v0.1.1 可继续使用原接口。需要 Docker Desktop / Docker Compose、Python 3;首次构建镜像需要访问构建依赖。从 Orbit 仓库根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/orbit-local.py up
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本创建随机数据库密码和管理员密码,构建镜像,依次完成 MySQL 健康检查、前向迁移、运行账号权限收紧、结构核验、管理员/测试身份初始化及服务启动。成功后还检查 readiness 和实际 SDK 队列权限。
|
||||||
|
|
||||||
|
启动状态:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/orbit-local.py status
|
||||||
|
```
|
||||||
|
|
||||||
|
`mysql` 和 `orbit` 长期运行;`migrate`、`grant`、`verify`、`archive-init` 显示 `Exited (0)` 是一次性初始化成功。
|
||||||
|
|
||||||
|
## 控制台与接入地址
|
||||||
|
|
||||||
|
| 用途 | 地址/配置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 本机控制台 | <http://127.0.0.1:18080/console/> |
|
||||||
|
| 本机 API / SDK baseURL | `http://127.0.0.1:18080` |
|
||||||
|
| Docker 网络 | `orbit-test-services` |
|
||||||
|
| 同网络容器 API / SDK baseURL | `http://orbit-test:8080` |
|
||||||
|
| Worker WebSocket | 上述主机的 `/v1/worker/connect`;SDK 自动处理升级与鉴权 |
|
||||||
|
| 结果通知 | 使用 SDK 持久订阅 + EventConsumer,参见[消费手册](../../docs/sdk/event-consumer.md) |
|
||||||
|
| 就绪检查 | `/health/ready` |
|
||||||
|
| 管理员 | `admin` |
|
||||||
|
| 管理员密码 | 本机 `.local/compose-test/admin.password` |
|
||||||
|
|
||||||
|
浏览器直接账号密码登录,自动使用同源地址,不需要上传凭据 JSON。MySQL 仅在环境内部网络开放,业务服务通过 Orbit API 接入。
|
||||||
|
|
||||||
|
## 为其他应用分配独立配置
|
||||||
|
|
||||||
|
登录后打开侧栏「应用接入」,新建应用并选择能力,成功后复制/下载该应用的 `.env`。详情可以随时查看队列、权限和有效期。页面平滑换证默认保留旧 Token 7 天,过渡期内新旧 Token 均按各自有效期核验,原任务和订阅保持不变;全部服务切换并收尾后才可提前结束过渡。管理员可以随时「查看接入信息」「复制接入配置」或下载 orbit.env,过渡期也可以获取当前新 Token。旧凭据仅有摘要时先导入已保存的当前 Token,保留现有有效期和业务身份。
|
||||||
|
|
||||||
|
`host.env` / `docker.env` 是初始 local-testing 应用的联调身份;独立应用应使用页面分配的自己的配置。应用接入管理只授权 `admin`,无需创建多角色。[详细操作和恢复手册](../../docs/api/console-applications.md)。
|
||||||
|
|
||||||
|
## 本机业务服务
|
||||||
|
|
||||||
|
初始化生成 `.local/compose-test/host.env`,包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ORBIT_URL=http://127.0.0.1:18080
|
||||||
|
ORBIT_TOKEN=自动生成的真实令牌
|
||||||
|
ORBIT_NAMESPACE_ID=自动生成的命名空间ID
|
||||||
|
ORBIT_QUEUE_ID=自动生成的队列ID
|
||||||
|
```
|
||||||
|
|
||||||
|
在仓库根目录加载,再启动自己的服务:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -a
|
||||||
|
. ./.local/compose-test/host.env
|
||||||
|
set +a
|
||||||
|
# 在当前环境中启动你的业务进程
|
||||||
|
```
|
||||||
|
|
||||||
|
这些变量由业务配置代码读取,再传给 SDK;SDK 本身不自动加载 env 文件:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{
|
||||||
|
Token: os.Getenv("ORBIT_TOKEN"),
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
queueID := os.Getenv("ORBIT_QUEUE_ID")
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK 安装、任务处理器和完整服务见[根 README](../../README.md#sdk-怎么调用)、[HTTP 接入例子](../../examples/http-service/README.md)。业务处理器由接入服务注册运行;平台不会自动执行业务代码。
|
||||||
|
|
||||||
|
## 其他 Docker Compose 服务
|
||||||
|
|
||||||
|
在业务 Compose 中接入共享网络,并引用本机生成的 `docker.env`。将下方路径换为本机 Orbit 仓库的绝对路径;这份文件含真实令牌,不提交到业务 Git。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# 这里保留业务服务自己的 image/build、ports 等配置
|
||||||
|
env_file:
|
||||||
|
- /absolute/path/to/orbit/.local/compose-test/docker.env
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- orbit-test
|
||||||
|
|
||||||
|
networks:
|
||||||
|
orbit-test:
|
||||||
|
external: true
|
||||||
|
name: orbit-test-services
|
||||||
|
```
|
||||||
|
|
||||||
|
业务服务读取到的 `ORBIT_URL` 为 `http://orbit-test:8080`,HTTP 和 WebSocket 都通过共享网络访问;Docker 内不要把 `127.0.0.1:18080` 当成宿主机地址。先启动 Orbit 环境,再启动业务 Compose。
|
||||||
|
|
||||||
|
如果仅临时联调一个已运行容器,可以执行 `docker network connect orbit-test-services <业务容器名>`,并在该业务服务配置中设置上述 URL 和凭据;容器重建后应采用 Compose 声明网络。
|
||||||
|
|
||||||
|
## 运行示例与看数据
|
||||||
|
|
||||||
|
宿主机有 Go 1.27.1 时,从仓库根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ORBIT_URL=http://127.0.0.1:18080
|
||||||
|
export ORBIT_CREDENTIALS_FILE="$PWD/.local/compose-test/sdk.credentials.json"
|
||||||
|
cd examples
|
||||||
|
go run ./worker
|
||||||
|
go run ./schedules
|
||||||
|
go run ./workflow
|
||||||
|
go run ./event-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker 返回三条 `SUCCEEDED`;计划示例实际执行后暂停;流程示例完成并行、信号和审计核对;事件示例验证通知、ACK 与断线重报。刷新控制台即可查看真实数据。短示例每次产生新测试记录,不是长期业务消费者。
|
||||||
|
|
||||||
|
## 日常命令与持久化
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/orbit-local.py logs # 默认显示 Orbit 最近 100 行
|
||||||
|
./scripts/orbit-local.py logs mysql # 数据库日志
|
||||||
|
./scripts/orbit-local.py restart # 只重启 Orbit,等到 ready
|
||||||
|
./scripts/orbit-local.py stop # 停止本环境的 Orbit 和 MySQL
|
||||||
|
./scripts/orbit-local.py start # 启动已有容器,等到 ready
|
||||||
|
./scripts/orbit-local.py up # 初次部署/重建镜像与容器;复用原密码和身份
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库卷为 `orbit-test_orbit-mysql`,归档卷为 `orbit-test_orbit-archives`,容器配置 `restart: unless-stopped`。停止、重启、重新构建都保留数据。私密配置目录 `.local/compose-test` 也要保留;仅留下卷而丢失密码文件不能当成全新安装。
|
||||||
|
|
||||||
|
归档卷预算 1 GiB,归档计划和保留策略通过控制台配置,默认不自动开启计划;目前导出保留源数据。不要为腾空间删除数据库卷。
|
||||||
|
|
||||||
|
默认使用 `10.253.240.0/24` 内部网络和 `10.253.241.0/24` 服务网络,避免占用已耗尽的 Docker 默认子网池。如与本机已有网络冲突,在私密 `compose.env` 中设置 `ORBIT_TEST_BACKEND_SUBNET`、`ORBIT_TEST_SERVICES_SUBNET` 后首次启动;已有网络需要先协调连接服务后再调整。
|
||||||
|
|
||||||
|
配置统一从 `.local/compose-test/compose.env` 读取,脚本隔离其他项目的同名环境变量。可查看原始配置:[基础文件](../docker-compose.yml) + [本地覆盖](compose.yml)。脚本不提供删卷命令;需手工管理时务必固定 Compose 项目 `orbit-test` 和同一份配置。
|
||||||
|
|
||||||
|
## 凭据和权限
|
||||||
|
|
||||||
|
新建 SDK 凭据默认长期有效(省略 `expires_at`);旧环境保留原策略,可在应用接入页「调整有效期」且不改变 Token。启动输出读取数据库当前有效期。凭据拥有此测试命名空间的任务、Worker、调度、订阅、流程及恢复权限;这是联调身份,正式环境按角色收窄。凭据到期/撤销后不会自动重新初始化或重置历史,按[轮换手册](../../docs/api/authentication.md)处理。
|
||||||
|
|
||||||
|
管理员密码、数据库密码、SDK Token 均保存在 `.local/compose-test` 下的私密文件;目录权限 0700、文件权限 0600,Git 和 Docker 构建上下文都排除这些文件。需要其他服务读取时分发 `host.env` / `docker.env`,不要给业务服务数据库管理员配置。
|
||||||
|
|
||||||
|
### Token 保管密钥
|
||||||
|
|
||||||
|
`up` 自动生成 `.local/compose-test/credential-keys.json`(0600),重复部署复用它。初始化容器将文件放入 `orbit-keys` 卷,供 Orbit 的 65532 用户只读访问;bootstrap/凭据管理 CLI 使用同一源文件。已经启用后发现文件丢失会停止部署,要求恢复原文件,不能生成替代密钥。
|
||||||
|
|
||||||
|
备份数据库时单独备份该文件,不提交 Git;不要删除 `orbit-keys` 卷。密钥环、生产多节点部署、旧凭据导入和主密钥变更见[应用接入接口文档](../../docs/api/console-applications.md#8-部署-token-保管)。本地升级会保留原管理员和所有应用身份,历史 Token 需要经管理员导入补齐。
|
||||||
|
|
||||||
|
资源退役入口及结清规则见[通用资源退役](../../docs/api/resource-retirement.md)。多队列时先明确选择目标,浏览器按命名空间记忆选择;退役队列不再可导出为新接入配置,历史查询仍开放。
|
||||||
74
deploy/local/compose.yml
Normal file
74
deploy/local/compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 叠加 deploy/docker-compose.yml;使用 scripts/orbit-local.py 固定项目和私密配置。
|
||||||
|
services:
|
||||||
|
credential-key-init:
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: "no"
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
|
command: ["cp /source/credential-keys.json /keys/credential-keys.json.tmp && chown 65532:65532 /keys /keys/credential-keys.json.tmp && chmod 700 /keys && chmod 600 /keys/credential-keys.json.tmp && mv /keys/credential-keys.json.tmp /keys/credential-keys.json"]
|
||||||
|
volumes:
|
||||||
|
- ${ORBIT_LOCAL_DIRECTORY}/credential-keys.json:/source/credential-keys.json:ro
|
||||||
|
- orbit-keys:/keys
|
||||||
|
archive-init:
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: "no"
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
|
command: ["chown 65532:65532 /archives && chmod 700 /archives"]
|
||||||
|
volumes:
|
||||||
|
- orbit-archives:/archives
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
profiles: ["tools"]
|
||||||
|
image: ${ORBIT_IMAGE}
|
||||||
|
user: ${ORBIT_LOCAL_USER}
|
||||||
|
entrypoint: ["/usr/local/bin/orbit-admin"]
|
||||||
|
command: ["verify"]
|
||||||
|
environment:
|
||||||
|
ORBIT_MYSQL_DSN: "root:${ORBIT_DB_ROOT_PASSWORD}@tcp(mysql:3306)/${ORBIT_DB_NAME}?parseTime=true&multiStatements=true"
|
||||||
|
ORBIT_CREDENTIAL_KEY_FILE: /run/local/credential-keys.json
|
||||||
|
volumes:
|
||||||
|
- ${ORBIT_LOCAL_DIRECTORY}:/run/local
|
||||||
|
|
||||||
|
orbit:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${ORBIT_BUILD_VERSION}
|
||||||
|
REVISION: ${ORBIT_BUILD_REVISION}
|
||||||
|
BUILD_TIME: ${ORBIT_BUILD_TIME}
|
||||||
|
depends_on:
|
||||||
|
credential-key-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
archive-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
ORBIT_CONSOLE_APPLICATION_ADMIN: admin
|
||||||
|
ORBIT_CREDENTIAL_KEY_FILE: /run/orbit-keys/credential-keys.json
|
||||||
|
ORBIT_ARCHIVE_ROOT: /var/lib/orbit/archives
|
||||||
|
ORBIT_ARCHIVE_STORAGE_ID: ${ORBIT_ARCHIVE_STORAGE_ID}
|
||||||
|
ORBIT_ARCHIVE_MAX_BYTES: 1073741824
|
||||||
|
ORBIT_ARCHIVE_TICK_INTERVAL: 10s
|
||||||
|
volumes:
|
||||||
|
- orbit-archives:/var/lib/orbit/archives
|
||||||
|
- orbit-keys:/run/orbit-keys:ro
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
services:
|
||||||
|
aliases: [orbit-test]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: ${ORBIT_TEST_BACKEND_SUBNET:-10.253.240.0/24}
|
||||||
|
services:
|
||||||
|
name: orbit-test-services
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: ${ORBIT_TEST_SERVICES_SUBNET:-10.253.241.0/24}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
orbit-keys:
|
||||||
|
orbit-archives:
|
||||||
@@ -81,6 +81,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 控制台登录额外限制来源 IP 的尝试速率;业务任务速率仍由 Orbit 的持久化配额和授权决定。
|
# 控制台登录额外限制来源 IP 的尝试速率;业务任务速率仍由 Orbit 的持久化配额和授权决定。
|
||||||
|
# Orbit 另有数据库持久化账号/IP 保护;配置 ORBIT_CONSOLE_TRUSTED_PROXIES 为此代理的实际地址。
|
||||||
location = /console/auth/login {
|
location = /console/auth/login {
|
||||||
limit_req zone=orbit_console_login burst=5 nodelay;
|
limit_req zone=orbit_console_login burst=5 nodelay;
|
||||||
proxy_pass http://orbit_backend;
|
proxy_pass http://orbit_backend;
|
||||||
|
|||||||
@@ -274,3 +274,14 @@ Event.WorkflowID为只读STORED生成列,从原不可变事件派生流程归
|
|||||||
`orbit_archive_jobs` 固定范围和卷,保存 PENDING/RUNNING/SUCCEEDED/FAILED/ABANDONED 状态、版本、领取次数、已确认的批数/根数/字节/链摘要,以及随机租约代次和截止。租约确认必须匹配代次与版本且未过期;人工操作需要原版本、幂等键与审计原因。两个表均无外键,关联在同一事务按命名空间核验。
|
`orbit_archive_jobs` 固定范围和卷,保存 PENDING/RUNNING/SUCCEEDED/FAILED/ABANDONED 状态、版本、领取次数、已确认的批数/根数/字节/链摘要,以及随机租约代次和截止。租约确认必须匹配代次与版本且未过期;人工操作需要原版本、幂等键与审计原因。两个表均无外键,关联在同一事务按命名空间核验。
|
||||||
|
|
||||||
数据库与文件不做跨资源事务。持久文件是恢复依据,数据库链前缀是可信核验锚点;文件发布后确认丢失,由原作业重新读文件校验后收敛,缺失文件不自动重建。见[接口与部署说明](api/archive-scheduler.md)。
|
数据库与文件不做跨资源事务。持久文件是恢复依据,数据库链前缀是可信核验锚点;文件发布后确认丢失,由原作业重新读文件校验后收敛,缺失文件不自动重建。见[接口与部署说明](api/archive-scheduler.md)。
|
||||||
|
|
||||||
|
### 当前 Token 保管(000033)
|
||||||
|
|
||||||
|
`orbit_credentials.secret_key_id` 指向独立文件中的主密钥;`secret_ciphertext` 保存 AES-256-GCM 的 nonce 与认证密文,两字段同时为空或非空。原文不进入持久化模型、日志和审计。既有摘要保留用于认证,旧记录通过管理员导入当前原文补存,不改变身份、版本、有效期或过渡截止。撤销清空密文,轮换原子替换当前摘要与密文;无外键。
|
||||||
|
|
||||||
|
## 000034:通用资源退役
|
||||||
|
|
||||||
|
- `pulse_queues.retired_at`:UTC 永久退役时间,NULL 表示未退役;退役必须 paused=true。
|
||||||
|
- `orbit_subscriptions.retired_at`:UTC 永久退役时间,NULL 表示仍占活动名额。
|
||||||
|
- `orbit_subscriptions.retired_sequence`:退役事务锁定的命名空间结果流提交水位,NULL 表示未退役;退役后 enabled=false、pending_count=0。
|
||||||
|
- `ix_subscription_active(namespace_id,retired_at)` 用于活动名额统计。无外键、不删除历史、不回收名称;审计与幂等回执和退役状态同事务。
|
||||||
|
|||||||
@@ -260,9 +260,101 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专
|
|||||||
- 已通过:标准竞态测试、vet/build、SDK隔离和文档链接;定向真实MySQL/平台回归与平台全量 integration 均退出0。包括事务回滚/重复/旧快照、确认回滚、提交回执丢失、强杀恢复和SIGTERM收尾。
|
- 已通过:标准竞态测试、vet/build、SDK隔离和文档链接;定向真实MySQL/平台回归与平台全量 integration 均退出0。包括事务回滚/重复/旧快照、确认回滚、提交回执丢失、强杀恢复和SIGTERM收尾。
|
||||||
- [公共包接口](sdk/outbox-mysql.md)、[完整调用和故障示例](examples/http-service.md)、[验收报告](reports/outbox-http-integration.md)。本批不扩展到其他数据库/ORM、流程命令Outbox或源数据删除。
|
- [公共包接口](sdk/outbox-mysql.md)、[完整调用和故障示例](examples/http-service.md)、[验收报告](reports/outbox-http-integration.md)。本批不扩展到其他数据库/ORM、流程命令Outbox或源数据删除。
|
||||||
|
|
||||||
### main、正式版本与统一示例(本批)
|
### main、正式版本与统一示例(本批已完成)
|
||||||
|
|
||||||
- 根 examples 统一 27 个可运行场景,各目录 README 说明用途、权限、运行和结果;首页改为完整功能与接入入口。
|
- 根 examples 统一 27 个可运行场景,各目录 README 说明用途、权限、运行和结果;首页改为完整功能与接入入口。
|
||||||
- 开发工作区、构建、MySQL/控制台/历史 SDK 回归同步迁移路径;SDK 保持独立模块。
|
- 开发工作区、构建、MySQL/控制台/历史 SDK 回归同步迁移路径;SDK 保持独立模块。
|
||||||
- 使用 main 与 v0.1.1 / sdk/go/v0.1.1 / adapters/outbox/mysql/v0.1.1 交付,旧标签不变;业务固定版本,无需开发分支或提交号。
|
- 使用 main 与 v0.1.1 / sdk/go/v0.1.1 / adapters/outbox/mysql/v0.1.1 交付,旧标签不变;业务固定版本,无需开发分支或提交号。
|
||||||
- 标准 7 模块回归、SDK/协议/文档门禁、真实 MySQL 全量、控制台端到端及历史任务 API 双向 smoke 均通过;远端安装结果见[发布记录](reports/v0.1.1-release.md)。
|
- 标准 7 模块回归、SDK/协议/文档门禁、真实 MySQL 全量、控制台端到端及历史任务 API 双向 smoke 均通过;main 与三组 v0.1.1 标签已推送,仓库外空缓存安装两个模块、构建、HTTP smoke 及校验和验证通过,无 replace;证据见[发布记录](reports/v0.1.1-release.md)。
|
||||||
|
|
||||||
|
### 本地 Compose 联调环境(2026-09-27)
|
||||||
|
|
||||||
|
- 已提供 `scripts/orbit-local.py` 和本地 Compose 覆盖文件,部署在独立项目 orbit-test,主机端口 18080;MySQL/归档卷持久保存,迁移账号与运行账号分离。
|
||||||
|
- 初始化单管理员、完整测试身份和 host.env/docker.env,私密内容位于被 Git 与 Docker 构建排除的 .local/compose-test;重复启动复用原身份,不重置密码或数据。
|
||||||
|
- 提供 orbit-test-services 共享网络与 http://orbit-test:8080 地址,固定可配置子网,解决本机默认 Docker 地址池耗尽的问题。
|
||||||
|
- 已验证:真实容器构建/迁移/零外键/权限收紧;浏览器登录协议和未登录跳转;另一容器运行 Worker 完成三项任务及事件 ACK 重报;定时/流程示例;重复部署与 Orbit/MySQL 停止再启动后原任务、身份保持。
|
||||||
|
- 接入、账号位置、启动/停止及网络声明见[本地部署手册](../deploy/local/README.md)。这是本地联调环境,不改变平台或 SDK 协议。
|
||||||
|
|
||||||
|
### 单管理员应用接入(2026-09-27)
|
||||||
|
|
||||||
|
- 新增独立应用接入页:应用/环境分页、队列及凭据详情、选择业务能力新建应用、重新生成 Token、复制/下载 SDK 配置;沿用单管理员,不引入角色系统。
|
||||||
|
- 新增 Cookie 专用管理接口,部署显式指定唯一登录名;同事务校验管理员与有效会话,写入要求同源与专用请求头。最初采用摘要模式;后续管理员接入信息功能已改为当前 Token 加密保管,普通列表仍无秘密。
|
||||||
|
- 新建资源/审计/幂等回执同事务,轮换复用现有凭据 ID 与轮换核心。请求不明保留原操作,原样重报;原有任务、订阅和交付身份不变。无新表、无外键、无 SDK 协议修改。
|
||||||
|
- 已通过定向 MySQL 并发创建、幂等冲突、重名回滚、轮换/旧 Token 拒绝、退出失效与审计脱敏;真实 HTTP 已验证创建、轮换后原任务和订阅可读。标准/全量集成和页面验收记录见[本批报告](reports/console-applications.md)。
|
||||||
|
- [页面使用、HTTP 接口及故障恢复](api/console-applications.md)。
|
||||||
|
|
||||||
|
### 长期凭据与平滑换证(2026-09-27)
|
||||||
|
|
||||||
|
- 新建应用和 bootstrap 默认长期 Token,可选定期;既有 Token 可通过管理端/CLI 原地调整有效期,无需改业务配置。
|
||||||
|
- 000032 与基线增加旧摘要/固定截止,无外键。控制台/CLI 默认 7 天过渡;同一身份的 API、WS 控制命令和跨节点重连均核验两代有效性,权限与任务归属保持不变。
|
||||||
|
- 结束过渡、两代同时撤销、过渡期防重复换证、旧 v1 固定操作兼容、幂等审计及中文模型注释随功能交付。
|
||||||
|
- 验证结果见[长期凭据与平滑换证](reports/credential-overlap.md)。SDK 无接口修改;调用方须在截止前滚动更新配置并收尾在途执行。
|
||||||
|
|
||||||
|
### 控制台应用范围与列表归属(2026-09-27)
|
||||||
|
|
||||||
|
- 已实现:页顶应用/环境与队列筛选、列表和详情应用标签、URL 保持筛选;切换隔离旧请求和表单,错误范围不降级读取默认数据。
|
||||||
|
- 单管理员通过 Cookie 显式选择范围;业务事务重新核验会话与目标应用,SDK 不支持范围覆盖,订阅原凭据绑定不变。
|
||||||
|
- 同步提供[范围接口](api/console-scope.md)、真实 MySQL 授权/审计/退出后拒绝测试和 HTTP 列表及订阅管理回归。最终结果见[本批报告](reports/console-scope.md)。
|
||||||
|
|
||||||
|
### 换证过渡状态提示(2026-09-27)
|
||||||
|
|
||||||
|
- 凭据卡片明确区分过渡中与已撤销状态,展示旧 Token 实际截止及服务切换步骤;入口可点击「查看原因」打开只读说明,到期后页面刷新恢复换证入口。
|
||||||
|
- 单独说明当前 Token 的获取位置、凭据 ID 与 Token 的区别、过渡期配置可能仍是旧代,以及未保存时的处理方式;新建/换证前、成功交付与关闭确认均提示原文只显示一次。
|
||||||
|
- 保留过渡期禁止重复换证的保护;提前结束仍需管理员确认服务全部切换、在途任务收尾,不自动结束过渡或替换现有凭据。
|
||||||
|
- 回归范围:标准竞态测试、vet/build、JavaScript 语法和文档链接;本地页面核对过渡原因可点击、凭据获取说明、正常换证入口与结束过渡确认弹窗。说明同步到[应用接入手册](api/console-applications.md)。
|
||||||
|
- 已完成验收:基础回归通过;浏览器验证过渡原因点击、说明展开/关闭/再次打开、普通换证与新建保存提醒、结束过渡确认。验收没有轮换或停用现有应用凭据。
|
||||||
|
- 本地接入命令已实跑:仓库外独立模块安装 SDK v0.1.1、读取队列及校验和通过;真实 Worker 三项成功且排空、结果订阅跨连接 ACK 去重、共享 Docker 网络就绪检查均通过。本地部署手册同步修正旧 Token 过渡语义。
|
||||||
|
|
||||||
|
### 管理员接入信息与 Token 保管(本批)
|
||||||
|
|
||||||
|
- [x] 当前 Token 加密保存;查看/导入同事务审计,单管理员 Cookie 与同源校验,列表脱敏。
|
||||||
|
- [x] 「查看接入信息」「复制接入配置」「下载 orbit.env」,包含地址、应用/命名空间/队列与当前 Token。
|
||||||
|
- [x] 新建/换证/撤销密文与摘要同步,原有效期和过渡策略保持;历史凭据导入校验当前摘要。
|
||||||
|
- [x] 前向迁移 000033、中文模型注释、无外键;本地 Compose 持久主密钥与丢失保护。
|
||||||
|
- [x] 标准回归、完整 MySQL/SDK/进程恢复、控制台端到端和浏览器验证通过;本地数据库升级为 33,原身份保持。见[验收报告](reports/credential-token-vault.md)。历史凭据仅有摘要时仍需导入当前原文,不会自动重置。
|
||||||
|
|
||||||
|
## 2026-09-27:通用资源退役与接入选择
|
||||||
|
|
||||||
|
范围:不含接入方名称或业务判断。迁移 000034、队列/订阅退役 API 与 SDK、活动订阅名额提示、管理端退役入口、按命名空间记忆队列、未补存 Token 文案、可运行示例。
|
||||||
|
|
||||||
|
保护:未完成/未知任务、执行许可、活动计划和流程、未退役订阅阻止队列退役;积压、未确认和耗尽交付阻止订阅退役。退役不删历史或审计,不修改凭据。
|
||||||
|
|
||||||
|
进度:实现完成;标准七模块竞态回归、vet/build、SDK/协议/文档门禁、真实 MySQL 完整 integration、控制台 HTTP 端到端均通过。浏览器与本地部署、远端版本交付记录见[本批报告](reports/resource-retirement.md)。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-29:管理端简化与操作体验
|
||||||
|
|
||||||
|
- [x] 全站统一浅色工作台、分组导航、紧凑统计、中文状态、响应式布局和登录页。
|
||||||
|
- [x] 任务/流程按状态、业务键、时间服务端筛选;四个资源列表真实游标翻页与 ID 定位,刷新保持范围与筛选。
|
||||||
|
- [x] 首页明确最近窗口口径,提供待处理入口;应用页集中接入配置,收起技术细节和低频管理操作。
|
||||||
|
- [x] 浏览器验证、查询/翻页回归、基础构建与控制台端到端,本地 Docker 更新和对接说明同步。验收见[管理端改版记录](reports/console-refresh.md)。
|
||||||
|
|
||||||
|
不修改业务执行内核、权限、Token、数据库模型或既有业务资源。
|
||||||
|
|
||||||
|
## 2026-09-29:筛选、分页与大屏布局细化
|
||||||
|
|
||||||
|
- [x] 应用/队列搜索选择、统一状态和每页条数菜单、筛选控件对齐。
|
||||||
|
- [x] 四个资源列表默认每页 10 条,保留服务端游标与原条件。
|
||||||
|
- [x] 登录页左侧内容居中、主工作区最大宽度与移动布局调整。
|
||||||
|
- [x] 浏览器交互及分页回归、基础构建、本地 Docker 部署与文档同步;见[验收记录](reports/console-refresh.md)。
|
||||||
|
|
||||||
|
## 2026-09-29:滚动稳定性与登录保护核对
|
||||||
|
|
||||||
|
- [x] 控制台独立内容滚动,固定导航与页头,保留移动端导航和表格横向滚动。
|
||||||
|
- [x] 应用详情相同快照不重建,后台刷新在滚动/编辑/展开详情时让位,检查请求返回时的交互状态。
|
||||||
|
- [x] 核对当时登录实现的失败次数封控和登录限流缺口;后续已在下方专项补齐。
|
||||||
|
- [x] 浏览器滚动、刷新和切页回归,基础检查及持久 Docker 部署;验收见[管理端改版记录](reports/console-refresh.md)。
|
||||||
|
|
||||||
|
## 2026-09-30:滚动边界补修与登录保护
|
||||||
|
|
||||||
|
- [x] 关闭根页面、侧栏及各内容滚动区的边界回弹;验证连续滚动与短屏导航。
|
||||||
|
- [x] MySQL 持久化失败窗口、临时锁定及账号/IP 限流,配置校验、可信代理识别。
|
||||||
|
- [x] 登录成功/失败/拒绝审计,单管理员查询、保留期及有界清理,登录页等待提示。
|
||||||
|
- [x] 真实 MySQL 并发/跨实例/恢复/审计失败回归,基础检查、浏览器验证、Docker 部署与文档同步。
|
||||||
|
|
||||||
|
本地部署修订 `f56f233704700d43c6733b7f0fa14d67e1feddf7`,Schema 35,无外键。基础检查、真实 MySQL、双节点登录与控制台端到端通过;广泛集成在 Docker 暂时无响应处中断,原参数补跑剩余链路全部通过。证据与验证边界见[验收记录](reports/console-refresh.md),默认策略和部署配置见[登录保护](console-login-security.md)。
|
||||||
|
|
||||||
|
## 2026-09-30:浏览器页签图标
|
||||||
|
|
||||||
|
- [x] 复用现有 Orbit 品牌图形,增加 SVG 和 16/32/48 像素 ICO 图标,登录页与管理端共用。
|
||||||
|
- [x] `make test vet build docs-check` 通过;本地部署修订 `d47d0d361d7edbcadf5728013cebcf1a2b908cc7`。未登录可请求两种图标,HTTP 200、媒体类型与文件内容正确;登录页与实际控制台均引用图标,readiness 为 ready。
|
||||||
|
|||||||
@@ -101,3 +101,7 @@ GET /version 版本、提交和协议身份
|
|||||||
## 4. 发布验收
|
## 4. 发布验收
|
||||||
|
|
||||||
镜像构建通过只证明发布物可复现,不等于生产高可用。发布候选仍需按[交付核对清单](release-checklist.md)完成真实 MySQL 回归、滚动替换、复制切换、容量、备份恢复和 RPO/RTO 验收。业务 Worker 继续独立部署,通过 HTTP/WebSocket SDK 接入。
|
镜像构建通过只证明发布物可复现,不等于生产高可用。发布候选仍需按[交付核对清单](release-checklist.md)完成真实 MySQL 回归、滚动替换、复制切换、容量、备份恢复和 RPO/RTO 验收。业务 Worker 继续独立部署,通过 HTTP/WebSocket SDK 接入。
|
||||||
|
|
||||||
|
### 管理员 Token 保管文件
|
||||||
|
|
||||||
|
启用 `ORBIT_CONSOLE_APPLICATION_ADMIN` 的部署必须通过 `ORBIT_CREDENTIAL_KEY_FILE` 指定 JSON 密钥环。将文件以只读卷或编排器 secret 挂载,并赋予容器 UID 65532 读取权限;不要写入镜像。所有节点与管理 CLI 共用密钥环。字段、升级顺序、备份和主密钥轮替见[接入信息部署](api/console-applications.md#8-部署-token-保管)。本地 Compose 自动创建和复用该文件。
|
||||||
|
|||||||
@@ -152,3 +152,5 @@ docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.ha.yml up -
|
|||||||
## 6. 当前边界
|
## 6. 当前边界
|
||||||
|
|
||||||
该文件是可重复的单节点启动基线,不代表自动数据库故障切换、跨故障域多副本、备份恢复或容量目标已经验收。生产上线还需要真实 MySQL 拓扑、滚动替换、凭据轮换、备份恢复、压测和 RPO/RTO 证据;对应门槛以[交付核对清单](release-checklist.md)为准。
|
该文件是可重复的单节点启动基线,不代表自动数据库故障切换、跨故障域多副本、备份恢复或容量目标已经验收。生产上线还需要真实 MySQL 拓扑、滚动替换、凭据轮换、备份恢复、压测和 RPO/RTO 证据;对应门槛以[交付核对清单](release-checklist.md)为准。
|
||||||
|
|
||||||
|
启用应用接入管理员时,还须配置 `ORBIT_CREDENTIAL_KEY_FILE` 并在 Compose 覆盖文件中只读挂载对应主密钥文件(容器 UID 65532 可读)。基础 Compose 只传递路径,不生成生产秘密;[本地 Compose](../deploy/local/README.md)提供自动初始化。参见[Token 保管配置](api/console-applications.md#8-部署-token-保管)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Orbit HTTP / WebSocket 接口手册
|
# Orbit HTTP / WebSocket 接口手册
|
||||||
|
|
||||||
更新:2026-09-18。接口实现状态随 [开发计划](../08-development-plan.md) 更新。
|
更新:2026-09-27。接口实现状态随 [开发计划](../08-development-plan.md) 更新。
|
||||||
|
|
||||||
## 当前接口状态
|
## 当前接口状态
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
| `GET /health/live` | 已实现 | [健康检查](health.md) |
|
||||||
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
| `GET /health/ready` | 已实现,验证数据库、迁移记录及零外键要求 | [健康检查](health.md) |
|
||||||
| `GET /version` | 已实现,返回版本、提交、构建时间及协议版本,不包含数据库、凭据或租户信息 | [健康检查](health.md) |
|
| `GET /version` | 已实现,返回版本、提交、构建时间及协议版本,不包含数据库、凭据或租户信息 | [健康检查](health.md) |
|
||||||
|
| `GET /console/scope` 与控制台显式应用范围 | 已实现单管理员应用/环境切换、队列分页和列表归属;SDK 不可覆盖范围 | [应用范围](console-scope.md) |
|
||||||
| 独立监听的 `GET /metrics` | 已实现基础运行指标,默认关闭 | [运行观测](observability.md) |
|
| 独立监听的 `GET /metrics` | 已实现基础运行指标,默认关闭 | [运行观测](observability.md) |
|
||||||
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
| `/v1/tasks` 提交、查询、分页与待执行取消 | 已实现,P3扩展业务键与创建时间筛选 | [任务接口](tasks.md) / [查询契约](task-queries.md) |
|
||||||
| `GET /v1/tasks/{task_id}/source` | 已实现普通、人工重试、计划固定版本及流程历史激活/轮次来源 | [来源查询](task-source.md) |
|
| `GET /v1/tasks/{task_id}/source` | 已实现普通、人工重试、计划固定版本及流程历史激活/轮次来源 | [来源查询](task-source.md) |
|
||||||
@@ -66,3 +67,7 @@ P1 业务接口已遵守下述约定,后续接口继续沿用:
|
|||||||
[流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
|
[流程实例筛选分页与权限](workflow-queries.md)已接入,固定筛选沿游标分页,覆盖未执行分支的全部队列权限。
|
||||||
|
|
||||||
- [周期归档与作业管理](archive-scheduler.md)
|
- [周期归档与作业管理](archive-scheduler.md)
|
||||||
|
|
||||||
|
- [控制台应用接入与 Token 管理](console-applications.md)
|
||||||
|
|
||||||
|
- [通用资源退役](resource-retirement.md):订阅结清、活动名额释放、队列依赖保护、SDK 与控制台操作。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。
|
状态:P1 已实现。业务接口每次在数据库事务内校验凭据摘要、撤销/到期、应用与命名空间启停,以及动作和队列范围。授权与写入使用同一事务;管理变更与已经获得授权锁的请求按事务先后次序生效。
|
||||||
|
|
||||||
|
已提供单管理员[应用接入页面](console-applications.md):新建应用、查看队列与凭据、长期凭据、调整有效期、平滑换证、复制 SDK 配置。下方 CLI 继续用于首次安装及运维。
|
||||||
|
|
||||||
## 初始化一个新的接入应用
|
## 初始化一个新的接入应用
|
||||||
|
|
||||||
前置:专用 MySQL 数据库已经执行 orbit-migrate up,使用持有初始化权限的数据库管理连接。此命令创建新应用、命名空间、队列和凭据,不会覆盖现有同名应用。
|
前置:专用 MySQL 数据库已经执行 orbit-migrate up,使用持有初始化权限的数据库管理连接。此命令创建新应用、命名空间、队列和凭据,不会覆盖现有同名应用。
|
||||||
@@ -19,7 +21,7 @@ go run ./cmd/orbit-admin bootstrap \
|
|||||||
|
|
||||||
ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填,以字母/数字开头,后续允许字母、数字、`_ . : -`;environment 最长 32,其余最长 128。输出文件必须不存在,以 0600 创建;原始凭据不会打印到 stdout/stderr。`.local/` 和 `*.credentials.json` 已被 Git 忽略。
|
ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填,以字母/数字开头,后续允许字母、数字、`_ . : -`;environment 最长 32,其余最长 128。输出文件必须不存在,以 0600 创建;原始凭据不会打印到 stdout/stderr。`.local/` 和 `*.credentials.json` 已被 Git 忽略。
|
||||||
|
|
||||||
输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token / expires_at。初始凭据有效期为 30 天,权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。
|
输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token,可选 expires_at。初始凭据默认长期有效(expires_at 省略),可用 `--valid-for 720h` 显式选择 30 天;0 表示长期,其他值为 1 小时至 365 天。权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。
|
||||||
|
|
||||||
如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码:
|
如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码:
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ secret-manager read orbit/console-admin-password | \
|
|||||||
|
|
||||||
## 轮换与撤销
|
## 轮换与撤销
|
||||||
|
|
||||||
凭据轮换和撤销只允许在受保护的数据库管理环境执行,不提供公网管理接口。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新的 Worker 进程必须按现有进程身份规则重新连接,旧令牌不能再通过任何 API 或 WSS 认证。平台不会批量改写在途任务、交付记录或历史会话证据。
|
管理 CLI 在受保护的数据库管理环境执行;控制台也可由部署指定的单管理员轮换凭据,见[应用接入](console-applications.md)。业务 SDK 不开放凭据管理,撤销仍由 CLI 执行。轮换保留 `credential_id`,因此订阅和 Worker 实例的授权绑定仍指向同一记录;新进程仍遵循原 Worker 身份规则;过渡期内旧 Token 的 API 请求、原 WS 控制命令及跨节点重连都继续有效。新 Token 立即可用,结束过渡或超过截止后旧 Token 才被拒绝。平台不会批量改写在途任务、交付记录或历史会话证据。
|
||||||
|
|
||||||
先读取脱敏状态和当前版本:
|
先读取脱敏状态和当前版本:
|
||||||
|
|
||||||
@@ -61,12 +63,13 @@ go run ./cmd/orbit-admin credential rotate \
|
|||||||
--namespace-id <namespace_id> \
|
--namespace-id <namespace_id> \
|
||||||
--credential-id <credential_id> \
|
--credential-id <credential_id> \
|
||||||
--expected-revision 1 \
|
--expected-revision 1 \
|
||||||
--valid-for 720h \
|
--valid-for 0 \
|
||||||
|
--grace 168h \
|
||||||
--reason "scheduled credential rotation" \
|
--reason "scheduled credential rotation" \
|
||||||
--output .local/rotated.credentials.json
|
--output .local/rotated.credentials.json
|
||||||
```
|
```
|
||||||
|
|
||||||
命令先读取并核对版本,生成一个固定 `operation_id`、令牌和绝对到期时间,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 限定在 1 小时到 1 年。数据库只保存秘密摘要、请求摘要和脱敏回执。
|
命令先读取并核对版本,生成一个固定 `operation_id`、令牌、可选绝对到期时间和固定过渡截止,写入新的 `0600` 私有操作文件,然后提交数据库事务。文件不得已存在;提交结果不明、网络断开或数据库返回未知错误时必须保留文件,并使用同一个文件执行 `credential apply --file`,不能重新执行 `rotate` 生成第二个秘密。`--valid-for` 默认 0(长期),也可配置 1 小时到 365 天;`--grace` 默认 168h(7 天),范围 0–720h,0 表示立即停用旧 Token。旧 Token 的实际截止取过渡截止与原到期时间的较早者,不复活已过期秘密。数据库保存认证摘要、请求摘要和脱敏回执;配置 ORBIT_CREDENTIAL_KEY_FILE 时还加密保存当前 Token,管理员可查看和复制。[管理员接入信息与密钥配置](console-applications.md#8-部署-token-保管)。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/orbit-admin credential apply \
|
go run ./cmd/orbit-admin credential apply \
|
||||||
@@ -75,6 +78,36 @@ go run ./cmd/orbit-admin credential apply \
|
|||||||
|
|
||||||
`apply` 会严格拒绝软链接、非 0600 文件、未知 JSON 字段、重复 JSON 对象和修改过的操作内容。重复应用已提交的文件只返回原回执,回执中的 `deduplicated` 为 `true`,不会重新轮换或新增审计。
|
`apply` 会严格拒绝软链接、非 0600 文件、未知 JSON 字段、重复 JSON 对象和修改过的操作内容。重复应用已提交的文件只返回原回执,回执中的 `deduplicated` 为 `true`,不会重新轮换或新增审计。
|
||||||
|
|
||||||
|
### 原 Token 调整有效期
|
||||||
|
|
||||||
|
无需换 Token:读取当前 revision 后执行以下命令,业务侧继续使用原配置。缩短有效期同样会生效,应按预期截止配置。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/orbit-admin credential expiry \
|
||||||
|
--namespace-id <namespace_id> --credential-id <credential_id> \
|
||||||
|
--expected-revision 1 --valid-for 0 \
|
||||||
|
--reason "service credential remains valid until revoked" \
|
||||||
|
--output .local/expiry.operation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 平滑切换的完整顺序
|
||||||
|
|
||||||
|
1. 生成新 Token,保留默认 7 天过渡。凭据 ID、权限、任务、订阅和交付身份不变。
|
||||||
|
2. 把新 Token 发布到调用方配置,逐台更新 API 服务、Worker 和结果消费者。SDK v0.1.1 的 `Options.Token` 在创建客户端时读取,修改 `.env` 不会自动刷新正在运行的客户端;新建客户端或滚动重启服务,按 [HTTP 宿主示例](../examples/http-service.md) 正常排空在途工作。
|
||||||
|
3. 在截止前确认所有旧进程已完成任务回报和 ACK,并已经退出或用新 Token 重新连接。HTTP 调用无须整体停机;持续执行能力依赖业务侧保留足够副本。单实例重启本身会产生短暂停顿。
|
||||||
|
4. 全部切换后可提前结束过渡;否则旧 Token 到时自动失效,认证使用数据库时间,不依赖定时清理。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/orbit-admin credential finalize \
|
||||||
|
--namespace-id <namespace_id> --credential-id <credential_id> \
|
||||||
|
--expected-revision 2 --reason "all service instances switched and drained" \
|
||||||
|
--output .local/finalize.operation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
结束过渡立即停用旧 Token,当前 Token 不变。长期 Token 仍受主动撤销、应用/命名空间停用约束。旧代仍在有效过渡期时再次轮换返回 409,避免误伤未切换服务;先完成本次切换。修改当前有效期不延长旧代截止。若旧 Token 本身即将到期而需要更长切换窗口,应先调整当前有效期,再发起换证。
|
||||||
|
|
||||||
|
新固定操作文件使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。
|
||||||
|
|
||||||
发生泄露或 Worker 必须立即停用时执行撤销:
|
发生泄露或 Worker 必须立即停用时执行撤销:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -86,7 +119,7 @@ go run ./cmd/orbit-admin credential revoke \
|
|||||||
--reason "incident response"
|
--reason "incident response"
|
||||||
```
|
```
|
||||||
|
|
||||||
撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销只标记凭据失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。
|
撤销同样先写入固定操作文件,再提交事务;提交不明时用 `credential apply --file .local/revoke.operation.json` 重试。撤销使当前和过渡中的上一代同时失效,不删除订阅、任务、交付或历史记录。即使应用或命名空间已停用,管理撤销仍可执行。重复应用同一撤销文件只返回原回执,不追加重复审计。审计主体取自 MySQL 管理连接的 `CURRENT_USER()`,审计摘要不包含秘密原文或秘密摘要。
|
||||||
|
|
||||||
## 凭据格式与作用域
|
## 凭据格式与作用域
|
||||||
|
|
||||||
@@ -128,3 +161,7 @@ P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`
|
|||||||
P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。
|
P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。
|
||||||
|
|
||||||
P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`workflows:audit:read`。五个独立CLI开关`--workflow-run-access`、`--workflow-control-access`、`--workflow-signal-access`、`--workflow-recovery-access`、`--workflow-audit-access`分别明确授予。前四个同时包含实例读取;audit只授予流程操作审计读取。原`--workflow-access`仍仅授予定义读/发布,任务`--recovery-access`不授予流程恢复。全部固定队列仍按当前凭据校验,详见[实例权限表](workflows.md#1-路由权限与-sdk)。初始化权限集合在交付凭据前校验。
|
P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`workflows:audit:read`。五个独立CLI开关`--workflow-run-access`、`--workflow-control-access`、`--workflow-signal-access`、`--workflow-recovery-access`、`--workflow-audit-access`分别明确授予。前四个同时包含实例读取;audit只授予流程操作审计读取。原`--workflow-access`仍仅授予定义读/发布,任务`--recovery-access`不授予流程恢复。全部固定队列仍按当前凭据校验,详见[实例权限表](workflows.md#1-路由权限与-sdk)。初始化权限集合在交付凭据前校验。
|
||||||
|
|
||||||
|
## 部署升级
|
||||||
|
|
||||||
|
迁移 `000032` 为凭据表增加上一代摘要与 UTC 截止,并以 CHECK 约束成对为空;不新增外键、不改变已有秘密或有效期。该次基线同步到 32;当前基线还包含后续 000033 Token 加密保管字段。按部署流程应用迁移并更新全部 Orbit 节点后再使用平滑换证;旧版本节点只认识当前摘要,不能在混合版本期间承诺旧 Token 可用。既有凭据不会被迁移自动改为长期,须通过有审计的 expiry 操作显式调整。
|
||||||
|
|||||||
200
docs/api/console-applications.md
Normal file
200
docs/api/console-applications.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 控制台应用接入
|
||||||
|
|
||||||
|
单管理员在「应用接入」页面创建应用、初始队列与 SDK 凭据,查看环境、队列、权限和有效期,平滑换证,复制或下载 `.env` 配置。业务认证使用 Token 摘要;当前 Token 使用 AES-256-GCM 加密保管。管理员可以随时查看、复制完整接入配置和下载 orbit.env,列表仅返回脱敏元数据。
|
||||||
|
|
||||||
|
## 1. 启用和使用
|
||||||
|
|
||||||
|
部署配置指定唯一有跨应用接入管理权限的控制台登录名:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ORBIT_CONSOLE_APPLICATION_ADMIN=admin
|
||||||
|
export ORBIT_CREDENTIAL_KEY_FILE=/run/orbit-secrets/credential-keys.json
|
||||||
|
```
|
||||||
|
|
||||||
|
该账号必须已经通过 bootstrap 或 console-user create 创建。配置为空时所有应用接入管理请求返回 403,不会自动提升已有账号;多节点使用相同配置。继续使用单管理员,不引入角色管理系统。现有控制台账号与会话的绑定凭据仍须有效;到期前可在本页轮换。轮换 SDK Token 不改变浏览器独立会话秘密。
|
||||||
|
|
||||||
|
本地 Compose 已为 `admin` 配置该入口,访问 `http://127.0.0.1:18080/console/#/applications`。
|
||||||
|
|
||||||
|
1. 点击「新建应用」,填写应用名称、环境、命名空间、初始队列和操作原因。
|
||||||
|
2. 选择 Worker、结果订阅、定时、流程等需要的能力及有效期。基础权限包含任务提交/查询/取消、队列读取/管理。
|
||||||
|
3. 创建成功后复制或下载完整 `.env`,交给该应用的服务端配置。默认地址使用当前页面来源,详情中可改为容器地址或部署域名。
|
||||||
|
4. 已接入应用点击「查看接入信息」或「复制接入配置」,获取包含当前 Token 的完整配置;也可下载 orbit.env。
|
||||||
|
5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。
|
||||||
|
6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。
|
||||||
|
|
||||||
|
凭据卡片提供「查看接入信息」「复制接入配置」。配置包含 ORBIT_URL、ORBIT_APPLICATION_ID、ORBIT_NAMESPACE_ID、ORBIT_QUEUE_ID、ORBIT_TOKEN;可直接粘贴为环境配置。先选择业务服务实际可访问的地址和队列,再复制;应用 ID 用于识别,真正认证身份来自 Token。过渡期始终返回当前新 Token,调整有效期或结束旧代过渡后仍可查看。历史凭据尚无密文时提供「导入当前 Token」,不能把仍可用的旧代 Token 导入为当前代次。
|
||||||
|
|
||||||
|
环境与命名空间形成隔离边界。不同应用应分别创建;同一应用的多个命名空间在列表中分别展示。页面支持新建应用及其初始队列;业务列表可通过应用筛选栏切换范围,分别展示各应用数据。未提供应用删除、身份迁移或现有应用新增命名空间的表单;已有 CLI 的 `--application-id` 仍可增加命名空间,队列 SDK 可增加同范围队列。
|
||||||
|
|
||||||
|
## 2. 路由与认证
|
||||||
|
|
||||||
|
这些是浏览器管理端接口,不属于业务 SDK。只接受有效的 `orbit_console_session` HttpOnly Cookie,并在数据库事务中重新核验部署指定的管理员账号和会话;即使 SDK 凭据拥有全部业务权限也不能访问。
|
||||||
|
|
||||||
|
| 方法与路径 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /console/applications?after=<namespace_id>` | 按命名空间 ID 升序读取 50 条接入范围,返回 `items`、可选 `next`;`after` 为排他起点 |
|
||||||
|
| `GET /console/applications/{namespace_id}` | 应用/环境详情、最多 100 个队列和 100 张脱敏凭据;超出返回 `truncated=true` |
|
||||||
|
| `POST /console/applications/prepare` | 校验意图,生成固定操作 ID、资源 ID、Token、绝对到期时间;不创建资源、不轮换秘密 |
|
||||||
|
| `POST /console/applications/apply` | 提交准备结果,原样重报返回原脱敏回执 |
|
||||||
|
| `POST /console/applications/{namespace_id}/credentials/{credential_id}/token/reveal` | 管理员读取当前 Token;审计成功后才返回 |
|
||||||
|
| `POST /console/applications/{namespace_id}/credentials/{credential_id}/token/import` | 校验并加密补存当前 Token,不轮换身份 |
|
||||||
|
|
||||||
|
POST 必须有 `Content-Type: application/json` 和 `X-Orbit-Console: 1`;浏览器 Origin 必须匹配当前主机,拒绝跨站 Fetch Metadata,不提供跨域授权。响应禁止缓存,成功信封为 `{"data": ...}`。未知字段、重复 JSON 键、显式 null 和错误类型被拒绝,正文上限 512 KiB。
|
||||||
|
|
||||||
|
## 3. 新建应用
|
||||||
|
|
||||||
|
prepare 请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "CREATE",
|
||||||
|
"application": "order-service",
|
||||||
|
"namespace": "default",
|
||||||
|
"environment": "test",
|
||||||
|
"queue": "orders",
|
||||||
|
"capabilities": ["worker", "events", "schedules", "workflows"],
|
||||||
|
"expected_revision": "0",
|
||||||
|
"valid_days": 0,
|
||||||
|
"reason": "接入订单服务"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
名称以字母或数字开头,后续只允许字母、数字、`_ . : -`,environment 最长 32,其余最长 128。valid_days 为 0–365 的整数,默认 0 表示长期有效;reason 非空、最多 1024 个 Unicode 字符,不含换行或 NUL。capabilities 可选 `worker`、`events`、`schedules`、`workflows`、`recovery`、`resources`,不可重复。新建不传 namespace_id / credential_id,expected_revision 省略或为字符串 `"0"`。
|
||||||
|
|
||||||
|
prepare 响应 data 为固定操作对象:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"input": {"kind":"CREATE","application":"order-service","namespace":"default","environment":"test","queue":"orders","capabilities":["worker","events"],"expected_revision":"0","valid_days":0,"reason":"接入订单服务"},
|
||||||
|
"operation_id": "11111111111111111111111111111111",
|
||||||
|
"application_id": "22222222222222222222222222222222",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"queue_id": "44444444444444444444444444444444",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"token": "服务端生成的完整 Token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以上是结构示意,ID、令牌及时间以服务端返回为准。将 **data 整体原样** POST 到 apply,不包 `data` 外层。应用、额度、命名空间、队列、凭据摘要、Token 密文、审计和幂等回执在同一事务提交。初始队列并发 4、开始速率 10/秒,沿用 bootstrap 默认策略;凭据队列范围为新命名空间的全部队列。重名应用返回 409,不覆盖原资源。
|
||||||
|
|
||||||
|
## 4. 平滑换证
|
||||||
|
|
||||||
|
prepare 请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "ROTATE",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"expected_revision": "1",
|
||||||
|
"valid_days": 0,
|
||||||
|
"grace_days": 7,
|
||||||
|
"reason": "更新接入凭据"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:将原摘要保留到固定过渡截止,原子替换当前摘要、密文与可选到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止变更。ROTATE 的 grace_days 范围 0–30,API 省略/0 保持立即停用旧 Token 的兼容语义,页面显式提交 7;实际截止不超过旧 Token 原到期时间。过渡未结束时再次轮换返回 409。
|
||||||
|
|
||||||
|
页面在过渡期将入口标为「暂不可换证 · 查看原因」,同时显示实际截止和切换步骤。该入口可以点击,打开只读说明弹窗,不会提交换证或结束过渡。这表示上一轮换证已生效;再次轮换会挤掉仍在使用的旧 Token,所以暂时禁止。将已保存的新 Token 配置到所有服务、Worker 和结果消费者,并收尾在途任务。到期后页面刷新会恢复换证入口;只有确认全部切换完成,才可提前点「结束过渡,停用旧 Token」。结束过渡会立即停用旧 Token,不能仅为解锁按钮而操作。已撤销凭据单独显示「已撤销 · 查看原因」,同样只打开说明。
|
||||||
|
|
||||||
|
apply 成功 data:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation_id": "11111111111111111111111111111111",
|
||||||
|
"application_id": "22222222222222222222222222222222",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"revision": "2",
|
||||||
|
"deduplicated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 调整有效期与结束过渡
|
||||||
|
|
||||||
|
prepare 输入示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "SET_EXPIRY",
|
||||||
|
"namespace_id": "33333333333333333333333333333333",
|
||||||
|
"credential_id": "55555555555555555555555555555555",
|
||||||
|
"expected_revision": "2",
|
||||||
|
"valid_days": 0,
|
||||||
|
"reason": "服务凭据设置为长期有效"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长期,1–365 为从准备时间计算的天数。FINALIZE 使用相同身份/版本字段,kind 改为 FINALIZE,valid_days 必须为 0,用于提前停用旧 Token。两者均禁止 grace_days 和新建字段,prepare 不返回 token;FINALIZE 也不返回 expires_at。apply 使用完整原对象,具有相同的并发版本、幂等和审计约束。
|
||||||
|
|
||||||
|
新版 prepare 对象带 version=2;expires_at 为空时**省略字段**,不要传显式 null。ROTATE 有过渡期时另外包含固定 previous_valid_until;详情中的 previous_valid_until 是受旧到期时间约束后的实际截止,未设置时省略。详情/回执不会返回旧秘密。历史不带 version 的固定操作仍按原语义重放。
|
||||||
|
|
||||||
|
完整滚动更新及 SDK 客户端生命周期说明见[凭据手册](authentication.md)。更换配置不会自动刷新已创建的客户端,须在过渡截止前完成重建或逐台重启并排空在途任务。
|
||||||
|
|
||||||
|
## 6. 超时、重复和审计
|
||||||
|
|
||||||
|
- 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。
|
||||||
|
- 页面允许「保存待核对操作」下载私密 JSON;含真实 Token,应放到受限目录。恢复时登录同一管理员后,向 apply 发送该文件正文。不要把 Cookie 或操作文件提交 Git,也不要放到 URL。
|
||||||
|
- 关闭窗口后清除浏览器中的明文,管理员可以重新查看,无需再次换证。
|
||||||
|
- 原操作返回的回执是历史快照,可能已被后续轮换替代。页面会重新读取当前凭据版本后才展示配置。
|
||||||
|
- 管理审计主体是 `console:<user_id>`;正文只有权限、资源 ID、到期时间等元数据。列表、审计、幂等回执和业务记录都不保存秘密原文。
|
||||||
|
- 当前管理员退出、会话过期或账号停用后不能重放管理操作。SDK Token 撤销/到期与控制台登录资格沿用既有账号绑定约束。
|
||||||
|
|
||||||
|
| 错误 | HTTP | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| UNAUTHENTICATED | 401 | 登录有效控制台账号 |
|
||||||
|
| PERMISSION_DENIED | 403 | 核对唯一管理员配置、会话及同源请求 |
|
||||||
|
| INVALID_ARGUMENT | 400 | 核对字段、能力组、固定身份及有效期 |
|
||||||
|
| NOT_FOUND | 404 | 核对命名空间/凭据是否存在 |
|
||||||
|
| STATE_CONFLICT | 409 | 应用名称已存在、凭据已撤销、版本已变更或旧 Token 仍在过渡期;读取当前状态 |
|
||||||
|
| IDEMPOTENCY_CONFLICT | 409 | 原操作内容被修改,保留原始操作核对 |
|
||||||
|
| 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 |
|
||||||
|
|
||||||
|
可重复运行的真实 HTTP 场景见 [test-console-applications.py](../../scripts/test-console-applications.py),标准容器入口 `make console-e2e` 同时验证此链路。基础 SDK 使用方式见 [SDK 接入](../sdk/README.md)。
|
||||||
|
|
||||||
|
业务数据的应用筛选、队列分页及会话隔离见[应用范围接口](console-scope.md)。
|
||||||
|
|
||||||
|
## 7. 当前 Token 与历史导入
|
||||||
|
|
||||||
|
详情返回 `token_vault_enabled`;每张凭据的 `token_stored` 表示当前 Token 是否已保管。列表、详情、审计和幂等回执都不包含原文。
|
||||||
|
|
||||||
|
查看请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"expected_revision":"3"}
|
||||||
|
```
|
||||||
|
|
||||||
|
导入请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"expected_revision":"3","token":"已保存的当前完整 Token"}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":{"namespace_id":"33333333333333333333333333333333","credential_id":"55555555555555555555555555555555","revision":"3","token":"当前完整 Token"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
只接受当前凭据版本。版本冲突、撤销或旧记录未保管返回 409;导入的身份或秘密与当前摘要不符返回 400(旧代处于过渡期也不例外);密钥缺失/解密失败返回 503。读取需要 Cookie、同源和 X-Orbit-Console,与其他管理 POST 相同。响应 `Cache-Control: no-store`;审计动作分别是 `console.credential.token.view`、`console.credential.token.import`,不含 Token、摘要或密文。
|
||||||
|
|
||||||
|
导入可以重复提交同一当前 Token,每次成功操作记录审计;不修改认证 revision、到期时间、权限或旧代截止。网络结果不明时可重新读取元数据或重报同一导入。读取/导入遇到审计写入失败或提交结果不明,不返回秘密。读取与换证对同一凭据加锁,返回的是该事务看到的当前代次;之后其他管理员的新轮换仍以实际数据库状态为准。
|
||||||
|
|
||||||
|
## 8. 部署 Token 保管
|
||||||
|
|
||||||
|
启用 `ORBIT_CONSOLE_APPLICATION_ADMIN` 时必须配置 `ORBIT_CREDENTIAL_KEY_FILE`,文件格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"active":"key-v1","keys":{"key-v1":"32 字节随机密钥的标准 Base64 编码"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
主密钥由部署方生成,权限限定运行用户读取;不要使用示例文字作为密钥。所有服务节点和管理 CLI 使用同一份密钥环,通过只读文件挂载部署,不写进镜像、Git 或数据库。参考[本地部署](../../deploy/local/README.md)自动生成方案。最多 16 把密钥,文件最多 64 KiB。密文绑定 namespace_id / credential_id,不能跨凭据替换。
|
||||||
|
|
||||||
|
升级先停止旧节点、应用迁移 000033、配置密钥再启动全部新节点;旧写节点不认识密文列,不能混合版本承诺回看一致性。迁移仅增加两个可空字段和成对 CHECK,无外键,不改变已有 Token、版本、有效期或过渡时间。
|
||||||
|
|
||||||
|
数据库和密钥文件分别备份。更换主密钥时使用新 key ID,将其设为 active,同时保留仍有密文引用的旧 key;在更新所有节点之前不写新 key ID 的数据。历史密文可以先查看、再导入同一 Token 以重新加密,不需要更换业务 Token。不得将旧 ID 映射到不同密钥,也不得在密文尚未迁移时删除旧 key。
|
||||||
|
|
||||||
|
管理 CLI 的 bootstrap / credential rotate / apply 读取同一环境变量。未设置时保持摘要模式,轮换会清除旧密文;之后必须导入该次新 Token 才能回看。控制台启用应用管理员时不会允许遗漏主密钥配置。
|
||||||
|
|
||||||
|
历史库仅有 SHA-256 摘要,无法凭摘要重建原文。请从既有业务配置或下载文件导入当前 Token;不要为补存秘密提前结束仍在使用的旧 Token 过渡。
|
||||||
54
docs/api/console-scope.md
Normal file
54
docs/api/console-scope.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 控制台应用范围
|
||||||
|
|
||||||
|
此接口服务于内置控制台的单管理员,用于按应用环境读取列表、详情并执行已有管理操作;不是 SDK 的跨应用调用方式。业务服务继续使用自己应用的 Token 和队列。
|
||||||
|
|
||||||
|
## 查看范围元数据
|
||||||
|
|
||||||
|
`GET /console/scope?after=<queue_id>` 只接受登录 Cookie,返回 `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entry": {
|
||||||
|
"application_id": "11111111111111111111111111111111",
|
||||||
|
"application": "sample-app",
|
||||||
|
"namespace_id": "22222222222222222222222222222222",
|
||||||
|
"namespace": "default",
|
||||||
|
"environment": "test",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"queues": [{"id": "33333333333333333333333333333333", "name": "jobs"}],
|
||||||
|
"can_switch": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不指定范围头时返回账号原始应用及授权队列。队列按 ID 升序,每页最多 100 项,存在更多时返回 `next`,下次用 `after=next`;没有更多则省略。应用目录继续使用 `GET /console/applications` 的 50 项分页。名称全部来自数据库,ID 为授权身份,不按应用名称猜测关联。
|
||||||
|
|
||||||
|
## 显式选择应用环境
|
||||||
|
|
||||||
|
部署配置 `ORBIT_CONSOLE_APPLICATION_ADMIN` 指定的登录管理员可发送:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Orbit-Console: 1
|
||||||
|
X-Orbit-Namespace: 22222222222222222222222222222222
|
||||||
|
```
|
||||||
|
|
||||||
|
以上请求头只能搭配原登录 Cookie,不能搭配 `Authorization`。服务端不签发、不返回新业务 Token,不更改浏览器会话原始归属。部署配置为空或登录名不匹配时拒绝切换。
|
||||||
|
|
||||||
|
支持范围:
|
||||||
|
|
||||||
|
| 页面 | 请求 | 数据范围 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 任务执行 | 现有 `/v1/tasks` 列表、详情、历史及管理动作 | 所选命名空间 + 队列 |
|
||||||
|
| 定时计划 | 现有 `/v1/schedules` 查询、暂停/继续 | 所选命名空间 + 队列 |
|
||||||
|
| 流程实例 | 现有 `/v1/workflows` 查询、控制和人工恢复 | 所选命名空间,含流程固定版本所有队列 |
|
||||||
|
| 结果订阅 | 现有 `/v1/subscriptions` 查询、启停和交付重试 | 所选命名空间 + 队列,管理员可管理该范围内不同 SDK 凭据的已有订阅 |
|
||||||
|
| 保留策略 | `/console/retention`、预览、导出 | 所选命名空间 |
|
||||||
|
| 归档作业 | `/console/archive/plans`、`jobs` 及原管理动作 | 所选命名空间 |
|
||||||
|
|
||||||
|
队列选项仅对任务、计划、订阅生效。此桥接入口不提供创建任务/计划/订阅、发布定义、启动流程、发送信号、Worker 执行或结果消费权限。SDK 原有的权限与订阅归属校验不变。
|
||||||
|
|
||||||
|
每个业务事务重新核验管理员账号、原会话、绑定凭据有效性以及原/目标应用启用状态,再执行原有的资源归属、版本、幂等、核查证据和审计规则。管理审计操作者为 `console:<user_id>`,操作归入目标命名空间。读取其他范围的资源 ID 不会越权返回数据。不存在的范围、未授权范围不自动回退。
|
||||||
|
|
||||||
|
缺少 Cookie 或会话失效返回 401;不允许的范围切换、混用 SDK Bearer、跨站写操作返回 403;目标应用停用或资源不属于当前范围返回 404。非法范围 ID 返回 400。页面切换采用完整导航,旧详情和请求不会在新应用页面呈现;URL 只保留非敏感 ID。
|
||||||
|
|
||||||
|
任务列表沿用原有 SQL 筛选语义:请求其他应用的队列会得到空列表;该队列或任务的单条详情返回 404。页面会先核对 URL 中的队列归属,错误队列不会被当成正常空列表。
|
||||||
@@ -145,3 +145,7 @@ unfinished=0 rate_per_second=1 worker_drained=true
|
|||||||
创建命令省略新字段时,幂等摘要保留旧字段集合。旧创建回执不含新字段,SDK 解码为零,只表示历史快照缺少信息,实时配置另行 GetQueue。配置接口是新能力;自动 SDK 已能处理 RATE_LIMITED,进行降额管理前应先升级 Worker。
|
创建命令省略新字段时,幂等摘要保留旧字段集合。旧创建回执不含新字段,SDK 解码为零,只表示历史快照缺少信息,实时配置另行 GetQueue。配置接口是新能力;自动 SDK 已能处理 RATE_LIMITED,进行降额管理前应先升级 Worker。
|
||||||
|
|
||||||
本阶段验证见 [资源控制报告](../reports/phase-3-queue-resources.md)。[应用级跨命名空间配额](application-resources.md) 已实现并同时生效。[资源键许可](keyed-concurrency.md) 与 [优先级/节点公平派发](priority-and-fairness.md) 已实现;可靠事件积压限制及跨节点公平负载和容量验收继续推进。
|
本阶段验证见 [资源控制报告](../reports/phase-3-queue-resources.md)。[应用级跨命名空间配额](application-resources.md) 已实现并同时生效。[资源键许可](keyed-concurrency.md) 与 [优先级/节点公平派发](priority-and-fairness.md) 已实现;可靠事件积压限制及跨节点公平负载和容量验收继续推进。
|
||||||
|
|
||||||
|
## 永久退役
|
||||||
|
|
||||||
|
`POST /v1/queues/{id}:retire`,需要 `queues:manage`、原幂等键、`expected_revision` 和 `reason`。完整前置条件、错误和 SDK 用法见[通用资源退役](resource-retirement.md)。返回原 QueueResult,新增可空 `queue.retired_at`;暂停仍允许受理,退役禁止新工作。历史读取保持可用。
|
||||||
|
|||||||
85
docs/api/resource-retirement.md
Normal file
85
docs/api/resource-retirement.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 通用资源退役
|
||||||
|
|
||||||
|
适用于长期服务、临时环境和自动化回归。平台不根据应用名称、队列名称或测试前缀自动清理。退役是不可恢复的显式管理动作,保留资源 ID、名称、任务、交付、幂等回执与审计;不是硬删除,也不自动归档或缩小数据库。
|
||||||
|
|
||||||
|
## 接口与权限
|
||||||
|
|
||||||
|
| 操作 | HTTP | 权限 | Go SDK |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 退役订阅 | `POST /v1/subscriptions/{id}:retire` | `events:manage`,原凭据所有权和队列授权 | `RetireSubscription` |
|
||||||
|
| 退役队列 | `POST /v1/queues/{id}:retire` | `queues:manage` 和队列授权 | `RetireQueue` |
|
||||||
|
|
||||||
|
单管理员通过已登录的控制台管理授权范围;普通 SDK 不能操作其他凭据的订阅。请求需要 Bearer 身份及 `Idempotency-Key`,不接受查询参数,正文:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"expected_revision":"3","reason":"临时资源已结清,结束使用"}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功 HTTP 200,保持现有 `request_id + data/error` 信封。data 分别为 `{subscription,deduplicated}`、`{queue,deduplicated}`。查询及列表增加可选 `retired_at`;订阅同时增加十进制字符串 `retired_sequence`。旧记录不需要回填。
|
||||||
|
|
||||||
|
同键同内容返回原回执,不因状态已经退役而失败;同键不同内容为 `IDEMPOTENCY_CONFLICT`。无明确回执时必须保存并重用原目标、请求和键,不自动生成新键。新命令须携带最新配置 revision。
|
||||||
|
|
||||||
|
## 订阅的结清边界
|
||||||
|
|
||||||
|
退役事务锁定命名空间结果流的已提交水位,再锁订阅。必须同时满足:
|
||||||
|
|
||||||
|
- pending_count=0,实际不存在 PENDING、LEASED 或 EXPIRED 交付。
|
||||||
|
- 截止水位之前,没有该队列和类型过滤范围内尚未物化的结果;缺失来源或历史保留缺口也拒绝退役。
|
||||||
|
|
||||||
|
成功后 enabled=false,记录退役水位并推进 cursor 至该水位,释放一个活动名额。此后新产生的事件不再属于该订阅。管理员应先停止旧生产者并排空消费者;平台无法判断调用方将来是否还会提交任务。
|
||||||
|
|
||||||
|
暂停依然占活动名额;每命名空间最多 64 个未退役订阅。已退役记录仍可列表、读取和查交付历史,不能恢复、重置起点或更换原名称。不存在通过忽略未消费事件强制释放名额的接口。
|
||||||
|
|
||||||
|
## 队列的依赖保护
|
||||||
|
|
||||||
|
队列退役按应用额度、操作身份、队列持锁,与提交、执行和流程推进串行。下列情况返回 `RESOURCE_BUSY` 并说明原因:
|
||||||
|
|
||||||
|
- 有未完成或待核查任务、在途执行尝试或未释放执行许可。
|
||||||
|
- 有启用的定时计划。先暂停计划,已产生任务仍须结清。
|
||||||
|
- 有固定版本引用该队列的未终结流程,包括暂停、等待、尚未执行到该队列的分支。
|
||||||
|
- 有未退役订阅,包括暂停订阅。
|
||||||
|
|
||||||
|
成功后 paused=true,禁止新任务、新订阅、Worker 注册、计划创建/发布/恢复以及使用该队列启动新流程。已发布流程定义和暂停计划作为历史保留,不能绕过退役恢复新工作。任务与流程历史查询继续可用,原已完成命令的冻结回执不重写。队列名称不回收,重新使用需创建新 ID 和名称。
|
||||||
|
|
||||||
|
## SDK 与自动化收尾
|
||||||
|
|
||||||
|
使用 `git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2`;旧 v0.1.1 消费/提交协议继续兼容,但不包含新方法。当前仓库内可直接从 `sdk/go` 运行示例。
|
||||||
|
|
||||||
|
```go
|
||||||
|
sub, err := client.GetSubscription(ctx, subscriptionID)
|
||||||
|
if err != nil { return err }
|
||||||
|
// 实际服务应把 key 和请求持久保存,未知回执重放原内容。
|
||||||
|
retire := orbit.RetireResourceRequest{
|
||||||
|
ExpectedRevision: sub.Revision,
|
||||||
|
Reason: "临时资源已经结清",
|
||||||
|
}
|
||||||
|
_, err = client.RetireSubscription(ctx, subscriptionID, stableOperationKey, retire)
|
||||||
|
if err != nil { return err } // RESOURCE_BUSY 时先排空/核查,不能跳过结果。
|
||||||
|
|
||||||
|
queue, err := client.GetQueue(ctx, queueID)
|
||||||
|
if err != nil { return err }
|
||||||
|
_, err = client.RetireQueue(ctx, queueID, stableQueueOperationKey,
|
||||||
|
orbit.RetireResourceRequest{ExpectedRevision: queue.Revision, Reason: "停止使用队列"})
|
||||||
|
return err
|
||||||
|
```
|
||||||
|
|
||||||
|
建议顺序:停止生产者和计划 → 结清执行与流程 → 消费并确认所有订阅结果 → 退役订阅 → 退役队列。失败的测试可能保留待核查任务,收尾应报告资源 ID 与阻塞原因,不能把测试失败变成丢弃结果的理由。
|
||||||
|
|
||||||
|
完整可运行示例:[resource-lifecycle](../../sdk/go/examples/resource-lifecycle/README.md)。示例只操作本次新建空资源;进程异常终止仍可能留下资源,需要按输出 ID 核查,不能无限创建来绕过名额。
|
||||||
|
|
||||||
|
## 错误和管理页面
|
||||||
|
|
||||||
|
| HTTP / code | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 409 `RESOURCE_BUSY` | 尚未结清,message 给出依赖或积压原因;状态不变 |
|
||||||
|
| 409 `RESOURCE_RETIRED` | 新命令试图复用已退役资源 |
|
||||||
|
| 409 `STATE_CONFLICT` | 配置版本不匹配 |
|
||||||
|
| 409 `IDEMPOTENCY_CONFLICT` | 原键对应不同内容 |
|
||||||
|
| 410 `EVENT_HISTORY_EXPIRED` | 订阅存在无法验证的历史缺口 |
|
||||||
|
| 401 / 403 / 404 | 保持现有身份、权限和资源隔离语义 |
|
||||||
|
|
||||||
|
控制台“结果订阅”详情提供退役入口;“应用接入”提供所选队列退役和活动订阅名额。多队列时明确选择,浏览器只记忆命名空间对应的队列 ID;暂停/退役状态清晰展示。复制配置不会静默换成另一张凭据有权限的队列。
|
||||||
|
|
||||||
|
## 部署与保留
|
||||||
|
|
||||||
|
先停止旧写节点,再执行迁移 000034,再启动新版本;不要混跑不认识退役状态的旧服务端。现有资源默认未退役,不自动处理任何应用或测试数据。新库基线仍为零外键。历史保留、归档导出继续按现有配置执行;本次退役不引入自动删除机制。
|
||||||
@@ -14,10 +14,11 @@
|
|||||||
| GET /v1/subscriptions?queue_id=... | events:read | ListSubscriptions |
|
| GET /v1/subscriptions?queue_id=... | events:read | ListSubscriptions |
|
||||||
| GET /v1/subscriptions/{id} | events:read | GetSubscription |
|
| GET /v1/subscriptions/{id} | events:read | GetSubscription |
|
||||||
| POST /v1/subscriptions/{id}:configure | events:manage | ConfigureSubscription |
|
| POST /v1/subscriptions/{id}:configure | events:manage | ConfigureSubscription |
|
||||||
|
| POST /v1/subscriptions/{id}:retire | events:manage | RetireSubscription |
|
||||||
| GET /v1/subscriptions/{id}/deliveries | events:read | ListEventDeliveries |
|
| GET /v1/subscriptions/{id}/deliveries | events:read | ListEventDeliveries |
|
||||||
| GET /v1/subscriptions/{id}/deliveries/{delivery_id} | events:read | GetEventDelivery |
|
| GET /v1/subscriptions/{id}/deliveries/{delivery_id} | events:read | GetEventDelivery |
|
||||||
| POST /v1/subscriptions/{id}/deliveries/{delivery_id}:retry | events:manage | RetryEventDelivery |
|
| POST /v1/subscriptions/{id}/deliveries/{delivery_id}:retry | events:manage | RetryEventDelivery |
|
||||||
| GET /v1/events/connect(WebSocket Upgrade) | events:consume | 连接 SDK 待交付,可按下文协议接入 |
|
| GET /v1/events/connect(WebSocket Upgrade) | events:consume | ConnectEvents / NewEventConsumer |
|
||||||
|
|
||||||
HTTP 使用统一 request_id + data/error 信封,响应带 X-Request-ID 和 Cache-Control: no-store。管理请求 8 秒服务端超时、JSON 最大 512 KiB。未知字段、大小写别名、重复 JSON 键、未知或重复查询参数均被拒绝。计数/版本以规范十进制字符串传输,不能带正号、前导零或小数。
|
HTTP 使用统一 request_id + data/error 信封,响应带 X-Request-ID 和 Cache-Control: no-store。管理请求 8 秒服务端超时、JSON 最大 512 KiB。未知字段、大小写别名、重复 JSON 键、未知或重复查询参数均被拒绝。计数/版本以规范十进制字符串传输,不能带正号、前导零或小数。
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ POST /v1/subscriptions,Content-Type: application/json,Idempotency-Key: 调
|
|||||||
|
|
||||||
类型集合按固定顺序规范化。cursor_sequence 表示已经扫描并建立交付记录的位置,**不是确认水位**。pending_count 包含等待、租约中和次数耗尽记录。配置 revision 不随物化/ACK 用量变化。
|
类型集合按固定顺序规范化。cursor_sequence 表示已经扫描并建立交付记录的位置,**不是确认水位**。pending_count 包含等待、租约中和次数耗尽记录。配置 revision 不随物化/ACK 用量变化。
|
||||||
|
|
||||||
每命名空间最多 64 个订阅。from_sequence 不能超过已提交流头,也不能早于已清理的保留边界。现存日志从迁移 000013 启用,不补造此前的任务结果。
|
每命名空间最多 64 个未退役订阅;暂停仍占名额,成功退役后释放名额。from_sequence 不能超过已提交流头,也不能早于已清理的保留边界。现存日志从迁移 000013 启用,不补造此前的任务结果。
|
||||||
|
|
||||||
## 3. 查询和控制订阅
|
## 3. 查询和控制订阅
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ snapshot 还可包含业务 result 和 current_attempt_id;result 用原始 JSO
|
|||||||
| NOT_FOUND | 404;订阅/交付不在凭据与父资源范围;连接内发生时关闭 |
|
| NOT_FOUND | 404;订阅/交付不在凭据与父资源范围;连接内发生时关闭 |
|
||||||
| STATE_CONFLICT | 409;版本、状态或累计预算不允许操作 |
|
| STATE_CONFLICT | 409;版本、状态或累计预算不允许操作 |
|
||||||
| IDEMPOTENCY_CONFLICT | 409;同键内容不同 |
|
| IDEMPOTENCY_CONFLICT | 409;同键内容不同 |
|
||||||
| SUBSCRIPTION_LIMIT | 409;命名空间订阅数达到 64 |
|
| SUBSCRIPTION_LIMIT | 409;命名空间未退役订阅数达到 64 |
|
||||||
| EVENT_HISTORY_EXPIRED | 410;WSS 领取发现历史缺口则报错并关闭,不静默跳到最新位置 |
|
| EVENT_HISTORY_EXPIRED | 410;WSS 领取发现历史缺口则报错并关闭,不静默跳到最新位置 |
|
||||||
| DELIVERY_LEASE_LOST | WSS ACK 证明已替换或无效;连接保留,等待新交付或核对历史 |
|
| DELIVERY_LEASE_LOST | WSS ACK 证明已替换或无效;连接保留,等待新交付或核对历史 |
|
||||||
| PROTOCOL_REQUIRED | 升级前 426,必须指定 orbit.events.v1 |
|
| PROTOCOL_REQUIRED | 升级前 426,必须指定 orbit.events.v1 |
|
||||||
@@ -325,3 +326,7 @@ return err
|
|||||||
[完整管理示例](../../examples/subscriptions/main.go) 的启动、输入输出、失败和保留规则见 [场景文档](../examples/subscriptions.md)。它演示管理与幂等,不启动结果消费者。正式 [事件连接 SDK 与自动消费器](../sdk/event-consumer.md) 已提供接收、ACK、心跳、重连与有界收尾;[完整协议示例](../examples/event-protocol.md) 验证跨连接重报原确认。[独立 MySQL Inbox 示例](../examples/result-inbox.md) 已实现并验证消费者进程退出后的去重。
|
[完整管理示例](../../examples/subscriptions/main.go) 的启动、输入输出、失败和保留规则见 [场景文档](../examples/subscriptions.md)。它演示管理与幂等,不启动结果消费者。正式 [事件连接 SDK 与自动消费器](../sdk/event-consumer.md) 已提供接收、ACK、心跳、重连与有界收尾;[完整协议示例](../examples/event-protocol.md) 验证跨连接重报原确认。[独立 MySQL Inbox 示例](../examples/result-inbox.md) 已实现并验证消费者进程退出后的去重。
|
||||||
|
|
||||||
机器可读消息契约见 [events.schema.json](../../protocol/eventsv1/events.schema.json),使用 Draft 2020-12,clientFrame/serverFrame 分方向约束。运行时额外拒绝重复 JSON 键、超范围 uint64 及非规范 base64url。Schema 不代替授权、状态机、租约及事务校验。
|
机器可读消息契约见 [events.schema.json](../../protocol/eventsv1/events.schema.json),使用 Draft 2020-12,clientFrame/serverFrame 分方向约束。运行时额外拒绝重复 JSON 键、超范围 uint64 及非规范 base64url。Schema 不代替授权、状态机、租约及事务校验。
|
||||||
|
|
||||||
|
## 退役与资源名额
|
||||||
|
|
||||||
|
见[通用资源退役](resource-retirement.md)。退役保留订阅和交付历史,不支持重新启用;通过 `retired_at`、`retired_sequence` 区分暂停与退役。未确认、过期或尚未物化的匹配结果都会阻止退役。
|
||||||
|
|||||||
65
docs/console-login-security.md
Normal file
65
docs/console-login-security.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 控制台登录保护
|
||||||
|
|
||||||
|
从 main 的数据库迁移 `000035` 开始启用。仅保护管理端账号密码登录,不改变业务 SDK Token、Worker 或结果订阅认证。保持单管理员,不引入角色管理系统。
|
||||||
|
|
||||||
|
## 默认行为
|
||||||
|
|
||||||
|
- 15 分钟失败窗口内连续失败 5 次,临时锁定 15 分钟。成功登录清空失败计数;失败窗口或锁定自然到期后重新计数。锁定期间尝试正确密码也不能登录,继续请求不会延长锁定。
|
||||||
|
- 同一账号每分钟最多受理 10 次,同一来源 IP 每分钟最多 30 次。固定窗口从首次尝试开始;成功登录不清空限流计数。任意一项触发就拒绝。
|
||||||
|
- 账号不存在、停用、密码错误或绑定凭据失效,正常拒绝均为 401。未知账号也参与相同限流/锁定,使用相同成本的 bcrypt 校验。既有账号按规范用户 ID 计数,避免等价 Unicode 登录名绕过限制。
|
||||||
|
- 触发锁定或限流返回 429,响应包含 `Retry-After` 秒数。登录页显示原因和倒计时;刷新页面不能绕过服务端限制。已登录会话不受一次新登录锁定影响,不中断业务服务。
|
||||||
|
- 数据库或审计提交失败返回 503,不签发 Cookie。跨站登录请求为 403;非法/过大的 JSON 为 400。
|
||||||
|
|
||||||
|
所有保护状态存放 MySQL,多节点共用,重启不归零。昂贵的密码校验在事务外执行:先用短事务占用请求预算,校验结束后再次锁定账号保护行;失败计数、锁定、成功会话和最终审计在同一事务提交。并发结果不能越过已经生效的账号锁定。校验进程异常退出的审计保留 PENDING,超过 10 分钟由清理器标 ERROR;不将未知结果当作密码错误。
|
||||||
|
|
||||||
|
## 部署配置
|
||||||
|
|
||||||
|
所有 Orbit 节点必须使用一致配置;不允许用 0 关闭保护。修改部署配置并重启服务后生效,不重置已有计数或已记录的锁定截止。降低阈值后按新阈值检查。
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 约束 / 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT` | `5` | 1–100 次,触发临时锁定 |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW` | `15m` | 1s–24h,连续失败窗口 |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_LOCK_DURATION` | `15m` | 1s–24h,新触发锁定时长 |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT` | `10` | 1–1000 次 / 账号 / 限流窗口 |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_IP_LIMIT` | `30` | 1–10000 次 / IP / 限流窗口 |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_RATE_WINDOW` | `1m` | 1s–24h,固定限流窗口 |
|
||||||
|
| `ORBIT_CONSOLE_LOGIN_AUDIT_DAYS` | `90` | 1–3650 天,登录审计独立保留期 |
|
||||||
|
| `ORBIT_CONSOLE_TRUSTED_PROXIES` | 空 | 明确信任的代理 CIDR,逗号分隔 |
|
||||||
|
|
||||||
|
普通 Compose 和 HA Compose 均透传这些变量。本地测试环境在 `.local/compose-test/compose.env` 修改所需配置,再执行 `python3 scripts/orbit-local.py up`。此文件含其他私密配置,不提交仓库;不能仅在启动脚本外 export,脚本会隔离外部项目环境变量。
|
||||||
|
|
||||||
|
默认只使用 TCP 对端 IP,忽略客户端自带的 X-Forwarded-For、X-Real-IP。部署域名反向代理时,将实际代理的地址或精确网段填入 `ORBIT_CONSOLE_TRUSTED_PROXIES`,例如同机代理 `127.0.0.1/32,::1/128`。只在直连对端可信时解析 X-Forwarded-For,从右向左剥离可信代理;遇到第一个非可信地址停止。格式非法或跳数过多时退回 TCP 对端。代理应覆盖或按可信链追加转发头,不能把所有互联网地址都配置为受信代理。未配置时所有经代理的请求共用代理 IP 配额,账号维度仍独立生效。
|
||||||
|
|
||||||
|
## 查看审计
|
||||||
|
|
||||||
|
管理端右上角“登录审计”,仅部署指定的 `ORBIT_CONSOLE_APPLICATION_ADMIN` 可查看,默认每页 10 条。显示时间、登录名、来源 IP、结果和可重试时间;记录用户名不代表该账号一定存在。SDK Bearer、未登录或普通控制台账号无权读取全局记录。
|
||||||
|
|
||||||
|
接口:`GET /console/auth/audit?before=<上页next>`,认证为有效 HttpOnly 管理员会话 Cookie。响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":{"items":[{"id":"42","username":"operator","remote_ip":"192.0.2.1","user_agent":"browser","outcome":"LOCKED","created_at":"2026-09-29T12:00:00Z","finished_at":"2026-09-29T12:00:00Z","retry_at":"2026-09-29T12:15:00Z"}],"next":"42"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`next` 仅在还有下一页时提供,是倒序分页的排他上界;无全库计数、不承诺跨页快照。结果类型:SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT、PENDING、ERROR。审计从不保存密码、Cookie、Token 或这些秘密的摘要,浏览器列表不会自动拉取接入 Token。
|
||||||
|
|
||||||
|
登录拒绝示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Retry-After: 900
|
||||||
|
Cache-Control: no-store
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
限流代码为 `LOGIN_RATE_LIMITED`。不要让客户端自动循环重试账号密码登录。
|
||||||
|
|
||||||
|
## 保留与升级
|
||||||
|
|
||||||
|
迁移只新增 `orbit_console_login_guards` 与 `orbit_console_login_audit`,无外键,不更改已有用户、密码、会话、凭据及业务记录。先备份,停止旧节点,再前向迁移和统一部署;旧版没有保护,不能和新版混跑。
|
||||||
|
|
||||||
|
每分钟清理一次,每种语句最多处理 500 行:48 小时未更新的保护状态、超过配置天数的登录审计,以及超时 PENDING 的状态归类。有效保护窗口最长 24 小时,48 小时非活动清理不会提前解除有效锁定。清理有索引、有截止时间,失败保留记录并写错误日志;这不会缩小数据库已分配磁盘,也不替代备份。业务“保留策略”管理的是命名空间历史,登录审计的全局保留时间由上述独立配置管理。
|
||||||
|
|
||||||
|
锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。
|
||||||
|
锁定自然到期后使用正确密码登录即可;不自动改密码、撤销业务 Token 或踢出已有会话。若持续遭受攻击,管理员应按审计中的来源限制访问并核对反向代理配置,不能通过关闭计数绕过保护。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Orbit Console · 星轨控制台
|
# Orbit Console · 星轨控制台
|
||||||
|
|
||||||
Orbit 内置了一个不依赖 Node.js、外部 CDN 或额外文件挂载的管理控制台第一版。静态资源编译进 Orbit 二进制,入口为:
|
Orbit 内置了一个不依赖 Node.js、外部 CDN 或额外文件挂载的管理控制台。静态资源编译进 Orbit 二进制,入口为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:8080/console/
|
http://127.0.0.1:8080/console/
|
||||||
@@ -8,10 +8,10 @@ http://127.0.0.1:8080/console/
|
|||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
- 读取 `/health/ready` 和 `/version` 展示服务就绪状态及版本;数据库未就绪时明确提示不可用。
|
||||||
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。
|
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,普通业务页面无需业务 Token;应用接入页仅在管理员明确查看时读取当前 Token。
|
||||||
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。
|
- 总览展示各类最近 20 条记录的快照:任务成功占比、状态分布、启用计划、待核查流程和待确认结果,并提供异常入口与最近任务。已取消单独统计,不算失败;快照不是全库累计。
|
||||||
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
- 页顶选择应用、环境与队列;任务、计划、流程、订阅、归档列表和详情显示应用归属。应用和队列范围保存在 URL,重新打开、复制链接及后退可恢复;列表条件和页码在当前页面会话内保持。
|
||||||
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
||||||
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
||||||
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
|
- 点击定时计划查看当前版本、规则、下一次触发和触发记录;在具备 `schedules:manage` 权限时可暂停或恢复计划。
|
||||||
@@ -20,17 +20,18 @@ http://127.0.0.1:8080/console/
|
|||||||
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
- 在具备 `workflows:control` 权限时,流程详情可以暂停、恢复或取消;操作原因、`control_revision` 和幂等键交给服务端做最终校验。
|
||||||
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
||||||
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
||||||
- 左侧六个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows`、`#/subscriptions` 和 `#/retention` 定位;切换视图不会把所有模块堆在一张长页面上。
|
- 工作台固定导航和页头,内容区独立滚动;切换模块回到内容顶部。应用详情相同快照不重建,后台刷新会避让正在滚动、编辑或展开详情的操作。
|
||||||
|
- 左侧八个模块是独立视图:总览、任务、计划、流程、订阅、保留策略、归档作业和应用接入。
|
||||||
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
|
- “保留策略”只允许单管理员查看和修改命名空间级窗口;修改需要版本号、幂等键和原因并留下审计。预览和只读导出不会删除源数据,未确认交付、未知效果和未结束流程不会成为候选。
|
||||||
- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
|
- 当前视图按 10 秒自动刷新,可在页顶暂停;输入、打开详情或操作弹窗时不自动重载。页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
|
||||||
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
- 普通账号沿用绑定凭据范围;部署指定管理员可显式选择应用环境管理。读取失败显示错误,避免当作空列表。
|
||||||
|
|
||||||
## 本地打开
|
## 本地打开
|
||||||
|
|
||||||
启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。当前 Docker 开发环境入口:
|
启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。使用 `scripts/orbit-local.py up` 部署的持久本地联调入口(通用 Compose 默认端口仍为 8080):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:8080/console/
|
http://127.0.0.1:18080/console/
|
||||||
```
|
```
|
||||||
|
|
||||||
首次使用可以在 `orbit-admin bootstrap` 时一起创建控制台管理员。这样业务资源、业务凭据和管理员账号在同一个初始化事务中提交:
|
首次使用可以在 `orbit-admin bootstrap` 时一起创建控制台管理员。这样业务资源、业务凭据和管理员账号在同一个初始化事务中提交:
|
||||||
@@ -48,7 +49,7 @@ secret-manager read orbit/console-admin-password | \
|
|||||||
--output .local/production.credentials.json
|
--output .local/production.credentials.json
|
||||||
```
|
```
|
||||||
|
|
||||||
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
|
密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,未指定为应用管理员的账号沿用该凭据权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./bin/orbit-admin console-user create \
|
./bin/orbit-admin console-user create \
|
||||||
@@ -74,6 +75,38 @@ export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
|||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
控制台不直接访问数据库,也不绕过业务凭据权限。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台账号绑定业务凭据,不自动获得跨命名空间权限;不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
登录已具备持久化失败计数、临时锁定、账号/IP 限流和单管理员登录审计。默认 15 分钟内连续失败 5 次锁定 15 分钟,账号/IP 每分钟分别最多尝试 10/30 次;阈值、窗口、审计保留期和可信代理均可配置。页面显示 429 原因及倒计时,服务重启不清空保护状态。配置与接口见[登录保护](console-login-security.md)。
|
||||||
|
|
||||||
|
控制台通过服务端授权访问数据库。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。普通账号不自动获得跨命名空间权限;部署指定的单管理员可显式选择应用范围,每个业务事务重新核验原会话及目标应用。各应用分别展示,SDK 权限不变。
|
||||||
|
|
||||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||||
|
|
||||||
|
## 应用接入
|
||||||
|
|
||||||
|
侧栏「应用接入」提供应用创建、队列与凭据查看、Token 重新生成和完整 SDK 配置交付。配置 `ORBIT_CONSOLE_APPLICATION_ADMIN=admin` 指定唯一管理登录名,并配置 `ORBIT_CREDENTIAL_KEY_FILE` 保管密钥;本地 Compose 自动配置。可随时查看、复制当前完整接入信息。业务页面支持该管理员选择应用范围,订阅原 SDK 凭据绑定不变。[功能、接口、权限和恢复说明](api/console-applications.md)。
|
||||||
|
|
||||||
|
## 按应用查看数据
|
||||||
|
|
||||||
|
页顶「应用 / 环境」选择已创建的应用,再选择队列。两处下拉菜单支持搜索已加载的选项,应用名称和环境分行展示;支持方向键、Enter 选择及 Escape 关闭。任务、定时计划和结果订阅按队列读取;流程、保留策略和归档按应用环境所属命名空间读取,页面有范围提示。应用每页 50 项、队列每页 100 项,选择「加载更多…」获取下一页。没有队列的应用仍能查看流程和归档。
|
||||||
|
|
||||||
|
切换采用完整导航,隔离旧请求和详情;有未保存策略、未确认操作或打开的详情时需先处理完。URL 只保存非敏感 ID。无效范围显示错误,不降级读取默认应用。总览仍是最近查询窗口的统计,不是全库累计。
|
||||||
|
|
||||||
|
只有部署指定管理员可以切换应用;其他账号只展示自己的应用和授权队列。后台停用应用、撤销绑定凭据或退出账号后,原页面范围不能继续授予权限。[范围接口及授权规则](api/console-scope.md)。
|
||||||
|
|
||||||
|
## 资源退役与明确接入目标
|
||||||
|
|
||||||
|
应用接入页按命名空间记忆所选队列;多队列且没有记忆时要求明确选择,不根据名称猜测业务用途。下拉框显示暂停/退役状态,已退役队列不能导出成新接入配置;凭据无该队列权限时直接提示,不自动换目标。
|
||||||
|
|
||||||
|
结果订阅详情和应用接入页分别提供订阅、队列退役;每次提交需要原因和当前版本,网络不明确时保留原键重报。活动订阅名额包含暂停订阅,退役后释放,历史仍可查询;阻塞原因明确显示。未补存当前 Token 时,预览入口与过渡提示都要求先补存,避免误认为已有可复制的新配置。
|
||||||
|
|
||||||
|
## 查询与日常操作
|
||||||
|
|
||||||
|
1. 在页顶选择应用环境;任务、计划、订阅再选择队列。
|
||||||
|
2. 任务与流程可组合状态、完整业务键和创建时间查询。业务键精确匹配,保留大小写、特殊字符和尾部空格;时间输入按浏览器时区换算,开始包含、结束不包含。
|
||||||
|
3. 四个资源列表默认每页 10 条,可选择 10 / 20 / 50 / 100 条、下一页、上一页和回到最新。请求服务端游标,不在前端截取全部数据。自动刷新和手动「刷新数据」保留当前条件及页码;改变条件或页大小回到第一页。分页期间数据可能继续变化,不承诺固定快照。
|
||||||
|
4. 在页面右上输入完整 32 位资源 ID 可直接定位;详情可复制完整 ID。读取失败保留筛选并提示原因,迟到的旧查询不会覆盖新查询。详情打开后焦点留在抽屉内,Escape 关闭后回到原入口。
|
||||||
|
5. 应用接入页可搜索本页应用,先选择接入队列,再查看或复制完整配置。应用标识、权限、有效期调整、退役等低频信息折叠展示;查看与换证沿用原有身份、权限和审计规则。
|
||||||
|
|
||||||
|
全站采用浅色工作台和统一表格。窄屏导航可横向滑动,宽表格在自身容器中滚动;登录页与控制台使用相同视觉样式。
|
||||||
|
|
||||||
|
前端校验运行 `make console-check`(开发机需要 Node.js,运行服务无需 Node.js);真实 MySQL、登录保护、任务执行与控制台接口链路运行 `make console-e2e`。本批范围与浏览器验收见[管理端改版记录](reports/console-refresh.md)。
|
||||||
|
|||||||
29
docs/reports/console-applications.md
Normal file
29
docs/reports/console-applications.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 单管理员应用接入验收(2026-09-27)
|
||||||
|
|
||||||
|
本报告记录当时的立即换证语义;后续长期凭据和双 Token 过渡已在[增量报告](credential-overlap.md)单独说明。
|
||||||
|
|
||||||
|
## 交付范围
|
||||||
|
|
||||||
|
- 独立应用接入页:按应用/环境查看接入范围、队列与凭据,创建应用/命名空间/初始队列,选择业务能力,生成或轮换 Token,复制/下载 SDK 环境配置。
|
||||||
|
- 通过 `ORBIT_CONSOLE_APPLICATION_ADMIN` 显式指定唯一管理员,默认关闭;仅登录 Cookie 可访问管理端,业务 Token 不授予跨应用权限。同事务核验账号及会话,写请求额外核验同源与专用请求头。
|
||||||
|
- 新建资源、权限、审计、幂等回执同事务;轮换复用原凭据事务与 ID,任务、订阅和交付身份保留。只存秘密摘要,一次性交付明文;提交不明时保留原操作核对。
|
||||||
|
- 没有新增数据库表或外键,没有改变业务 SDK 接口,不需要升级 SDK v0.1.1。
|
||||||
|
|
||||||
|
## 已完成验证
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `make test vet build` | 七模块竞态测试、静态检查和构建通过 |
|
||||||
|
| `TestIntegrationConsoleApplications` | 真实 MySQL:SDK 身份与错误管理员拒绝、准备未生效、并发创建一次、同键改内容冲突、重名回滚、元数据脱敏、轮换原身份、旧秘密失效、重复回执、版本冲突、退出失效、审计主体及零外键通过 |
|
||||||
|
| 管理 HTTP 单元测试 | Cookie 必须、Bearer 单独拒绝、缺少防跨站头/异源/null Origin 拒绝、合法同源请求、未知字段拒绝、响应不可缓存通过 |
|
||||||
|
| 准备对象严格 JSON 往返 | CREATE 与 ROTATE 的可选 ID/空能力组原样提交通过;真实 HTTP 发现的空 ID 编码问题已修复并加入回归 |
|
||||||
|
| `scripts/test-console-applications.py` | 真实 HTTP 创建/重放/冲突,任务和订阅生成,轮换后旧 Token 401、新 Token 可读取原任务与订阅,订阅暂停收尾、退出会话失效通过 |
|
||||||
|
| `make console-e2e` | 全新 MySQL/容器初始化,应用接入新链路、登录/权限、真实 Worker/重试/定时、保留策略、周期归档及离线校验全部通过 |
|
||||||
|
| 本地 Compose | 更新现有 18080 环境,复用原管理员、初始业务凭据与数据库卷;新增独立 access-demo 测试应用,轮换只发生于该测试应用 |
|
||||||
|
| 浏览器检查 | 实际账号登录,独立页面与应用详情加载、新建表单显示、权限/队列/配置展示;当前 1280×720 视口布局正常,无浏览器错误 |
|
||||||
|
|
||||||
|
`make integration` 已完整退出 0:覆盖真实存储、HTTP/WSS、SDK、可运行示例、独立 Inbox/Outbox/HTTP 宿主、Worker 与事件进程故障以及流程多节点暂停恢复。页面布局与表单读取通过浏览器核对;实际创建与轮换的持久化链路通过真实 HTTP 验证,不混称为浏览器点击完成全部流程。文档链接检查覆盖 196 份 Markdown,全部解析通过。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
接入管理跨应用,其他业务页面仍使用账号原有默认范围。旧 Token 原文不可查看;新 Token 交付丢失时保留原应用并重新轮换。列表每页 50 个命名空间,单个详情队列和凭据各最多 100 项并显式标记截断。当前未增加删除应用、修改权限、凭据转移、复杂角色或多应用任务总览。
|
||||||
88
docs/reports/console-refresh.md
Normal file
88
docs/reports/console-refresh.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# 管理端简化与操作体验验收
|
||||||
|
|
||||||
|
日期:2026-09-29。仅调整内置控制台、查询状态与使用说明;API、SDK、MySQL 模型和权限不变。
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
- 统一浅色工作台、分组导航、紧凑总览、中文状态、表格、详情和登录页。
|
||||||
|
- 总览按各类最近 20 条记录展示快照,明确统计范围,取消与失败分别显示;需要关注的项目可进入对应列表。
|
||||||
|
- 任务/流程通过已有服务端 API 组合状态、精确业务键、创建时间查询;任务/计划/流程/订阅使用服务端游标翻页,支持 20/50/100 条与直接 ID 定位。
|
||||||
|
- 刷新保留条件和当前游标;新查询失效旧响应,详情关闭或换资源后忽略旧响应。详情焦点留在抽屉,关闭回到入口,完整 ID 可复制。
|
||||||
|
- 应用接入支持本页搜索;凭据配置为主要操作,技术标识、权限和低频管理操作折叠。管理员仍可查看和复制含 Token 的完整接入配置。
|
||||||
|
- 仅刷新当前页;自动刷新可暂停,打开详情、操作弹窗或编辑输入时不自动打断。
|
||||||
|
|
||||||
|
## 自动回归
|
||||||
|
|
||||||
|
- `make test vet build`:根模块、SDK、示例与适配包竞态测试、静态检查和构建通过。
|
||||||
|
- `make console-check`:JS 语法检查及 4 项查询状态回归通过。覆盖特殊字符/尾空格、游标条件不丢失、迟到响应隔离、失败保留查询、时间边界及流程命名空间范围。
|
||||||
|
- `make console-e2e`:隔离 MySQL 容器通过登录/未登录保护、CSRF、跨应用隔离、Token 查看/导入、换证过渡、订阅/队列退役、真实 WS Worker、任务重试、定时触发、归档及退出登录回归。
|
||||||
|
- HTML ID 唯一性检查通过。运行服务无需 Node.js 或新前端依赖。
|
||||||
|
|
||||||
|
## 浏览器与数据
|
||||||
|
|
||||||
|
本地同源预览使用当前持久 MySQL 数据;独立队列 `console-ui-297f4c61f275` 用于 25 条测试任务的分页验证。测试任务使用专用处理器且未启动 Worker,全部取消,无外部副作用。保留记录供复查。
|
||||||
|
|
||||||
|
已核对:
|
||||||
|
|
||||||
|
- 20 条首屏 → 5 条第二页 → 刷新仍在第二页 → 上一页;50 条页大小返回 25 条。
|
||||||
|
- 精确查询 `筛选 +%_ `(尾部两个空格)仅返回对应的一条记录;结束时间早于开始时间被阻止。
|
||||||
|
- 原联调队列 13 条记录按已取消状态查到 2 条;ID 定位、详情审计、复制完整 ID 通过。
|
||||||
|
- 定时计划当前规则/触发历史、流程步骤/事件/输出、订阅交付、保留策略只读预览及归档空态均可读取。
|
||||||
|
- 应用搜索、明确队列选择、查看/复制完整配置通过;检查仅记录存在性,不把 Token、Cookie 或密码写入报告/截图。
|
||||||
|
- 1440×1000 桌面与 390×844 窄屏检查:页面不横向溢出,窄屏表格在自身容器内滚动,登录页面适配。
|
||||||
|
|
||||||
|
## 口径与边界
|
||||||
|
|
||||||
|
首页不是全局累计统计;列表游标不承诺并发变更下的固定快照;详情历史仍是最近 20 条。应用搜索只搜索本页。完整浏览器重载会重置列表条件与页码,应用/队列范围从 URL 恢复。
|
||||||
|
|
||||||
|
本批不创建新的 SDK 标签,不变更或轮换现有业务凭据,不改变单管理员模式,不将 UI 回归当作生产容量验收。代码交付在 `main`,本地运行版本以 `/version` 中的构建修订为准。
|
||||||
|
|
||||||
|
## 本地部署复核
|
||||||
|
|
||||||
|
`scripts/orbit-local.py up` 已完成持久 Compose 环境更新。`http://127.0.0.1:18080/version` 返回构建修订 `92a40f4a37ece6c30396fcdf95fe2e73076dc5b5`,`/health/ready` 返回 `ready`。原管理员登录成功,从未登录任务链接进入时,登录后返回原任务页;正式本地入口的任务读取、应用搜索和总览通过,浏览器无 JavaScript 错误。
|
||||||
|
|
||||||
|
SDK、数据库 Schema 34、原应用与凭据保持。部署未重新 bootstrap;前端资源编译进原 Orbit 镜像,无额外前端服务。
|
||||||
|
|
||||||
|
## 第二轮:筛选与布局细化
|
||||||
|
|
||||||
|
- 应用/队列菜单支持已加载选项搜索、环境副标题、选中标记和无匹配提示;状态、页大小沿用同一菜单。原 select 继续承担表单值和 change 事件,无新依赖。
|
||||||
|
- 默认页大小从 20 调整为 10,另提供 20/50/100;首页最近 20 条快照与详情历史口径保持。
|
||||||
|
- 创建时间按需整行展开;长处理器名称省略显示并可悬停查看全文;主工作区限制大屏宽度,登录品牌内容在左半屏居中。
|
||||||
|
- 浏览器实测 25 条记录按 10/10/5 三页读取;切换 20 条回到第一页,查询保留特殊字符与尾部空格。
|
||||||
|
- 验证鼠标搜索并切换应用/队列、方向键/Home/End/Enter/Escape/Tab、无匹配提示、重置恢复原始状态、错误时间范围提示、未保存策略阻止范围切换且菜单恢复原值。
|
||||||
|
- 八个页面可正常打开;1440×1000、2048×1195 和 390×844 检查大屏及移动布局;手机菜单保持在视口内。
|
||||||
|
- 本轮基础 test/vet/build、控制台端到端和 console-check/docs-check 通过。部署复核另附下方。
|
||||||
|
|
||||||
|
第二轮已部署至持久 Compose 环境:`/version` 修订为 `df76dcb9c0b69b3c947d112de619737145c44015`,就绪检查 HTTP 200。实际 18080 入口应用菜单、10 条列表、退出和原管理员登录通过;原应用身份与数据库保留。本次不涉及 SDK 发版。
|
||||||
|
|
||||||
|
## 第三轮:滚动稳定性
|
||||||
|
|
||||||
|
原布局让整个 document 滚动,页头会被带离视口;应用详情每次轮询都重建 DOM。改为固定视口的工作台、独立内容滚动、固定页头和桌面应用目录;后台请求在返回时再次检查滚动/编辑状态,相同应用快照保留原有 DOM。切换视图重置内容滚动区域,菜单仍按当前控件坐标定位。
|
||||||
|
|
||||||
|
浏览器验证:1440×900 与 390×844 下,document 不滚动,页头和导航坐标固定;鼠标点击页头刷新前后内容 scrollTop 均为 293、scrollHeight 均为 1193;展开状态不因相同快照刷新被折叠;手机切页归零、详情打开/关闭和菜单定位通过,无横向页面溢出或 JS 错误。基础 test/vet/build、console-check/docs-check 通过。
|
||||||
|
|
||||||
|
第三轮当时的登录保护核对:`ConsoleAuthStore.Login` 与 HTTP 登录路由没有失败计数、临时锁定和登录限流;当时仅记录缺口。下方 2026-09-30 专项已补齐这些能力。
|
||||||
|
|
||||||
|
第三轮持久部署已核验:`/version` 修订 `23db3d8fe0de352be9e756f932b12559ea746ff3`,`/health/ready` 为 200 ready。实际 18080 应用页内容 scrollTop=293 时,document scrollY=0、导航和页头 top=0。服务原账号、SDK、Token 和数据库保持不变。
|
||||||
|
|
||||||
|
## 2026-09-30:边界回弹与登录保护
|
||||||
|
|
||||||
|
上一轮键盘翻页和静态位置检查没有覆盖触控板滚到边界的回弹。原 `overscroll-behavior: contain` 只阻止滚动传递,仍允许元素回弹([MDN 属性说明](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overscroll-behavior#values))。本轮在控制台根节点、侧栏、内容、应用列表及弹层的实际滚动容器设置 `none`,并固定根工作区;保留内容正常滚动和短屏菜单可达性。
|
||||||
|
|
||||||
|
浏览器预览验证:
|
||||||
|
|
||||||
|
- 1440×900,用滚动操作连续冲顶部、底部并反向:根 scrollY=0,侧栏 y=0、品牌 y=28、页头 y=0;内容 scrollTop 在 0–293 内移动。
|
||||||
|
- 1100×600,菜单在 0–198 内滚动,内容在 0–593 内独立滚动,另一侧不被拖动,页头 y=0。
|
||||||
|
- 390×844,顶部导航固定,内容滚动至 1112;根 scrollY=0、文档宽度 390,无根横向溢出。工具滚动不等同于所有设备的物理触控板惯性手势,以上为实际验证范围。
|
||||||
|
|
||||||
|
登录保护实现、配置和 API 见[控制台登录保护](../console-login-security.md)。已有真实 MySQL 回归覆盖失败/成功计数重置、两个存储实例与重建实例共用锁定、锁定不续期与自然到期、并发 6 次失败精确计数、账号/IP 配额、未知账号、失败窗口、审计提交失败回滚会话、管理员权限/分页、日志保留和零外键。独立 Docker 双服务实测还覆盖轮流失败、重启服务仍拒绝、已登录会话仍有效、伪造代理头无效、自然到期后登录成功。
|
||||||
|
|
||||||
|
端到端首轮旧管理查询曾超时;复跑该链路通过,未放宽断言。双节点测试首次重启后因 Docker 重新分配随机发布端口无法就绪,测试工具改为重读实际端口后通过,未修改锁定逻辑或删除状态。最终 `make console-e2e` 全链路通过,含任务执行、重试、定时计划、归档及退出登录。
|
||||||
|
|
||||||
|
本地持久 Compose 部署版本 `f56f233704700d43c6733b7f0fa14d67e1feddf7`,数据库 Schema 35、0 外键,readiness 为 ready。已有账号、密码、会话、凭据与业务记录未重新初始化。实际 18080 浏览器验证:独立的未知测试登录名在第 5 次错误后显示 15 分钟倒计时;真实管理员正常登录,回到原应用页,审计显示失败、锁定与成功。正式页面滚动至 293 时,根 y=0、侧栏 y=0、品牌 y=28、页头 y=0,无浏览器 JS 错误。
|
||||||
|
|
||||||
|
截图存放在本地忽略目录 `.local/console-ui-check/`:`login-locked.png`、`login-audit.png`、`scroll-boundary-fixed.png`,不含密码/Token 原文。
|
||||||
|
|
||||||
|
基础 `make test vet build console-check docs-check` 通过;真实 MySQL 存储全套通过,包含新保护专项 6 个子场景;独立 `make console-e2e` 通过。广泛 `make integration` 执行至 SDK 调度容量样本时,Docker 引擎读操作及旧服务健康请求均超时,样本未完成,因此这一次命令不记为通过。引擎自行恢复,部署完成,未重启 Docker 或其他项目。中断位置起使用相同脚本命令及参数在新的一次性数据库补跑。
|
||||||
|
|
||||||
|
尾段补跑完成:调度样本 6/6 触发并成功,流程样本 6/6 成功;其余 SDK、可运行示例、事务 Outbox/结果 Inbox、嵌入 HTTP 服务生命周期及真实进程恢复回归全部通过。覆盖租约恢复、ACK 丢响应、慢消费者、跨节点重连、优先级/并发控制、重试与截止恢复、定时计划恢复和流程暂停恢复。日志保留于 `.local/console-ui-check/security-integration-tail.log`;首段与该次补跑共同覆盖原集成脚本范围,不描述为单次全量命令通过。
|
||||||
24
docs/reports/console-scope.md
Normal file
24
docs/reports/console-scope.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 控制台应用范围回归
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
任务、计划、流程、订阅和归档列表显示应用名称与环境;保留策略及详情显示归属。管理员可选择应用环境和队列,范围保存在 URL,页面切换隔离旧数据。后台按 Cookie 会话授权,普通 SDK 不接受范围覆盖。无表结构变更,无新增外键。
|
||||||
|
|
||||||
|
## 验证记录(2026-09-27)
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `make test vet build` | 通过,服务端、SDK 和各独立示例/适配模块;最后的 HTTP 范围边界测试及编译检查也通过 |
|
||||||
|
| `make integration` | 通过:真实 MySQL 全量存储回归、HTTP/WS、SDK、Inbox/Outbox/HTTP 宿主示例、进程中断及多节点流程恢复;存储回归约 307 秒,最终 SDK 进程故障组约 131 秒 |
|
||||||
|
| `TestIntegrationConsoleScope` | 原始/目标元数据、普通账号队列白名单、二进制 ID 游标、SDK/非指定管理员拒绝、目标队列隔离、仅管理不受理新工作、目标保留策略原键重放、原应用不变、人类审计、停用目标/退出后旧范围拒绝、零外键断言 |
|
||||||
|
| `make console-e2e` | 通过:隔离 MySQL 和实际 Orbit 容器;新建应用、任务/计划/流程/订阅/保留/归档范围读取,订阅管理及重放保留原 SDK 归属,跨应用单条 404、列表无泄漏,Bearer 混用/跨站写入拒绝,退出后 401;原任务执行、计划和归档端到端也通过 |
|
||||||
|
| HTTP 范围边界 | 通过 11 个 Cookie/Bearer/CSRF/读写入口场景;Worker、结果消费和资源创建不能借管理范围获得权限 |
|
||||||
|
| 静态与配置检查 | JS 语法、Python 编译、Markdown 链接、`git diff --check` 通过;单节点 + HA Compose 配置解析通过,双节点使用同一管理员环境配置 |
|
||||||
|
| 浏览器实际页面 | 任务列和订阅列表/详情显示应用标签;应用及队列切换、无数据范围、保留策略和归档范围提示、非法范围不降级、重置范围恢复均已检查 |
|
||||||
|
| 本地部署 | 本地 Compose 已部署本批代码;现有两个应用分别读取自己的范围,原 SDK Token 仍有效,无业务接入参数变更 |
|
||||||
|
|
||||||
|
应用、队列名称来自授权后的数据库元数据;队列分页首屏不使用空 ID,白名单使用 BINARY ID 参数。URL 中显式指定其他应用的队列会校验失败,不静默切换队列。打开详情、策略未保存或操作结果未确认时,页面阻止切换范围。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
当前按所选应用环境分别查看,不提供跨应用混合分页或全库统计。任务、计划、订阅按队列筛选;流程、保留与归档按命名空间展示。应用目录 50 项一页、队列 100 项一页。普通账号保留原绑定权限;只有部署指定的单管理员具有显式管理范围。此回归不替代生产部署的容量和跨故障域验收。
|
||||||
34
docs/reports/credential-overlap.md
Normal file
34
docs/reports/credential-overlap.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 长期凭据与平滑换证(2026-09-27)
|
||||||
|
|
||||||
|
新建应用与 bootstrap 默认长期 Token,可选有限有效期。已有 Token 支持 SET_EXPIRY 原地修改有效期,原秘密和接入配置保持不变;默认换证保留 7 天过渡,支持按配置调整,确认全部服务切换后可提前 FINALIZE。REVOKE 同时拒绝两代秘密。
|
||||||
|
|
||||||
|
## 实现与兼容
|
||||||
|
|
||||||
|
- 000032 在 orbit_credentials 增加 previous_secret_digest、previous_valid_until,CHECK 要求成对为空/非空。中文模型与 SQL 注释齐全,当前空库基线为 32,无外键。历史迁移不修改。
|
||||||
|
- 认证在原事务共享锁下读取两代摘要及各自截止;HTTP 和 Worker/结果消费复用原认证入口。截止使用数据库时间,不依赖内存、清理任务或节点时钟。
|
||||||
|
- 旧代实际截止不晚于其原有效期;重复轮换不会挤掉尚未切换的旧代,调整当前有效期也不会顺带延长旧代。凭据 ID、权限、任务、订阅与交付身份不变。
|
||||||
|
- 管理操作、版本、固定过渡截止、审计及幂等回执同事务。格式 v2 新增长期和过渡语义,历史 v1 仍按原固定到期/立即轮换恢复;重放历史轮换不复活已经停用的旧代。
|
||||||
|
- 管理端增加长期选项、调整有效期、平滑换证及结束过渡;CLI 增加 expiry/finalize、默认 --valid-for 0 和轮换 --grace 168h。没有修改 SDK 接口或 Token 格式。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
| 验证 | 证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标准回归 | `make test vet build` 七模块竞态测试、静态检查和构建通过 |
|
||||||
|
| 原 Token 改长期与在途执行 | `TestIntegrationCredentialOverlap`:运行中的任务原凭据改长期后继续授权;换证期间旧 Token 续租、完成结果、释放许可;两代认证、并发原操作去重、重复轮换冲突、结束过渡、历史重报、两代撤销及审计脱敏通过 |
|
||||||
|
| 期限与旧代隔离 | `TestIntegrationCredentialOverlapDeadline`:不超过旧有效期、两代独立到期、到期无需清理即可拒绝、下一轮不复活更旧秘密通过;时间边界由隔离测试库明确设置,不把测试耗时当作长期运行证据 |
|
||||||
|
| 双节点真实 WS | `TestIntegrationCredentialOverlapWebSocket`:两个 HTTP/WS 节点共享真实 MySQL;原长连接在轮换后可发控制命令,旧 Token 可跨节点重连,新 Token 同时连接;结束过渡后旧连接命令和新握手被拒绝,新连接继续可用 |
|
||||||
|
| 提交结果未知 | `TestIntegrationCredentialCommitFaults`:COMMIT 前截断及数据库提交成功后丢确认;旧 Token 始终可用,新 Token 只在实际提交后生效,原操作核对恢复 |
|
||||||
|
| 管理 HTTP | `scripts/test-console-applications.py`:真实 Cookie 管理、CSRF、定期改长期且原 Token 200、双 Token 过渡、提前结束、旧 Token 401、新 Token 200、历史回执不能复活旧秘密、原任务/订阅读取和退出失效通过 |
|
||||||
|
| 容器端到端 | `make console-e2e` 全新 MySQL/Orbit、上述管理链路、原登录/Worker/重试/定时/归档验收通过 |
|
||||||
|
| 全量集成 | `make integration` 退出 0:真实 MySQL 全部存储用例、历史结构前向升级、HTTP/WS、独立 SDK、任务/定时/流程示例、Inbox/Outbox/HTTP 宿主、Worker/结果消费者进程故障及多节点流程恢复通过 |
|
||||||
|
| 本地升级 | 复用现有数据库卷与管理员;迁移至 32,旧 CREATE 固定对象重放命中原回执;通过有审计的 SET_EXPIRY 更新既有测试凭据,原 Token 请求 200,host.env/docker.env 字节不变,跨应用队列访问仍 404 |
|
||||||
|
| 浏览器 | 实际登录会话读取到长期有效;新建表单默认长期,平滑换证默认 7 天,调整有效期表单正确;窄窗口换证弹窗可完整操作,未通过浏览器触发实际换证 |
|
||||||
|
|
||||||
|
文档链接检查覆盖 197 份 Markdown,通过;JS 语法、Python 语法及 Git diff 检查通过。涉及 Token 的变更链路在隔离真实 HTTP/MySQL 环境验证,浏览器只验证页面和表单,不把两者混称为浏览器全链路测试。
|
||||||
|
|
||||||
|
## 使用边界
|
||||||
|
|
||||||
|
调用方在截止前完成滚动更新并正常收尾在途任务。SDK 在创建客户端时读取 Token,改 `.env` 不会热更新现有客户端;须重建客户端或逐台重启服务。单实例重启仍有短暂停顿,平台不能替业务服务保证副本容量。提前结束过渡或到期会拒绝尚未切换的旧 Token,不承诺强行停用旧身份后其连接还能继续工作。
|
||||||
|
|
||||||
|
这次数据库升级完成且所有 Orbit 节点更新后才能启用双 Token 换证;旧版本节点不认识上一代摘要。只修改有授权的本地测试凭据为长期,迁移不会自动延长其他已有凭据。完整操作顺序见[凭据手册](../api/authentication.md)和[管理接口](../api/console-applications.md)。
|
||||||
38
docs/reports/credential-token-vault.md
Normal file
38
docs/reports/credential-token-vault.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 管理员接入信息与 Token 保管验收
|
||||||
|
|
||||||
|
日期:2026-09-27。范围:当前 Token 的管理员查看、完整配置复制、历史凭据导入与本地部署。
|
||||||
|
|
||||||
|
## 交付行为
|
||||||
|
|
||||||
|
- 应用凭据卡片提供「查看接入信息」「复制接入配置」,弹窗可下载 `orbit.env`。
|
||||||
|
- 完整配置包含服务地址、应用 ID、命名空间 ID、授权队列 ID 和当前 Token;可指定主机、容器或域名地址。
|
||||||
|
- 关闭弹窗会清除页面中的明文,管理员可重新读取。业务列表不会自动拉取秘密。
|
||||||
|
- 新建、换证在原事务内保存 AES-256-GCM 密文;到期时间变更、结束过渡不影响回看;撤销清空密文。
|
||||||
|
- 过渡期可以查看当前新 Token。历史导入必须匹配当前摘要,旧代仍有效也不能冒充当前代次。
|
||||||
|
- 查看、导入均校验有效管理员会话、请求同源和版本;审计确认提交后才返回秘密。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
| 层次 | 验证内容 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 加密单测 | 随机 nonce、往返、密文篡改、跨凭据/命名空间、未知 key、保留旧 key、配置边界和格式脱敏 | 通过 |
|
||||||
|
| MySQL 专项 | 历史导入、重复导入、SDK/错误管理员拒绝、审计失败回滚与禁止交付秘密 | 通过 |
|
||||||
|
| MySQL 凭据生命周期 | 新建密文、读取/轮换并发、过渡期当前代次、拒绝旧代导入、expiry/finalize 保留、旧操作重报、旧 CLI 轮换清理、撤销清理 | 通过 |
|
||||||
|
| 历史升级 | 从历史 v9 重建并迁移到当前版本、原在途任务和额度继续结清;旧记录可空字段兼容 | 通过 |
|
||||||
|
| 标准回归 | `make test vet build`,包含根模块、SDK、示例与 Outbox 适配器 | 通过 |
|
||||||
|
| MySQL 全量存储 | `internal/storage/mysql` 的完整真实事务回归,含 `-race` | 通过 |
|
||||||
|
| HTTP/容器端到端 | `make console-e2e`:Cookie/CSRF/no-store、查看/导入、长期凭据、平滑换证及结束过渡、Worker/重试/计划/归档 | 通过 |
|
||||||
|
| 浏览器 | 完整配置字段与当前应用/授权队列一致、一键复制成功、Docker 地址切换、关闭清空、历史导入入口 | 通过 |
|
||||||
|
| 文档与格式 | 本地 Markdown 链接、JS 语法、Go 格式及 diff 检查 | 通过 |
|
||||||
|
|
||||||
|
`make integration` 已完整通过:独立 SDK、任务/计划/流程/结果订阅示例、事务 Outbox 与嵌入 HTTP 服务、跨节点重连和 Worker/Event/Workflow 全进程恢复均完成;退出码为 0。
|
||||||
|
|
||||||
|
本次回归曾发现历史升级测试还原 v9 时未移除新增密文列,以及空身份在数据库层被拒绝而非权限层拒绝;已修正并通过专项及后续全量存储回归。
|
||||||
|
|
||||||
|
## 部署与兼容边界
|
||||||
|
|
||||||
|
迁移 `000033` 增加两个有中文注释的可空字段和成对 CHECK;实际本地数据库版本 33,外键数量 0。主密钥在独立受限文件与只读卷中保存,重复部署保留,不修改应用 Token、权限、有效期或过渡截止。
|
||||||
|
|
||||||
|
历史摘要不能反推出原文。具有当前配置的应用可一次导入补齐;只有过渡期旧配置的应用仍需取得已保存的新 Token,不会自动结束过渡或重置身份。源码仓库不包含真实业务 Token、管理员密码或主密钥。
|
||||||
|
|
||||||
|
接口、密钥配置、多节点升级与备份说明见[应用接入](../api/console-applications.md)。该结果验证本次功能和本地环境,不替代生产数据库高可用及容量验收。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# P6:凭据操作与安全轮换
|
# P6:凭据操作与安全轮换
|
||||||
|
|
||||||
|
本报告记录当时的立即换证语义;后续长期凭据和双 Token 过渡已在[增量报告](credential-overlap.md)单独说明。
|
||||||
|
|
||||||
日期:2026-09-19。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。
|
日期:2026-09-19。本文只记录管理凭据操作的已实现边界,不把凭据功能等同于 P6 全部生产验收。
|
||||||
|
|
||||||
## 已实现行为
|
## 已实现行为
|
||||||
|
|||||||
39
docs/reports/resource-retirement.md
Normal file
39
docs/reports/resource-retirement.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 通用资源退役与接入体验验收
|
||||||
|
|
||||||
|
日期:2026-09-27。范围:平台、Go SDK、控制台及通用示例;不包含接入方业务处理器、支付渠道或业务数据变更。
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
迁移 000034 增加队列/订阅退役时间及订阅提交水位、活动索引和约束,无外键。退役、不可变审计、原操作回执同事务;新工作不能复用退役队列,已完成流程的原启动回执仍可重放。订阅结清释放名额,暂停仍占名额,不删除或伪造确认历史。
|
||||||
|
|
||||||
|
控制台增加明确队列选择/记忆、暂停/退役状态和名额统计,提供带原因和版本的退役入口。凭据未补存时统一提示补存,不能静默换队列复制。没有修改任何现有应用的 Token、权限或过渡期。
|
||||||
|
|
||||||
|
## 回归结果
|
||||||
|
|
||||||
|
- `make test`、`make vet build`、`make sdk-check protocol-check docs-check`、JavaScript 语法检查全部退出 0;七个模块的基础竞态回归与 SDK 独立构建通过。
|
||||||
|
- `make integration` 最终完整重跑退出 0:真实 MySQL 存储/迁移、HTTP、SDK、Worker/事件/流程进程故障、Outbox 与 HTTP 宿主链路通过。SDK 生命周期示例实际输出 `retired=true history_preserved=true subscription_replay=true`。
|
||||||
|
- 新增真实 MySQL 8 项场景通过:未物化/租约/耗尽交付保护、64 名额释放且历史保留、任务/订阅依赖、计划恢复阻止、等待流程保护、完成流程原回执和历史、真实锁竞争、审计失败回滚。保留零外键断言。
|
||||||
|
- `make console-e2e` 退出 0:跨应用管理员范围、CSRF、退役幂等、名额变化、历史读取,以及原登录/任务/计划/归档链路通过。
|
||||||
|
- 初轮完整 MySQL 回归发现两处旧升级夹具未计入新约束/索引,修正后完整重跑通过:旧结构还原时移除 000034 新字段与约束;原索引对比排除新加活动索引。没有放宽原索引、数据和零外键断言。
|
||||||
|
- 当前协议契约检查通过,未重跑历史版本服务端与历史 SDK 的双向兼容矩阵;不能把本次结果等同于重新验收所有历史组合。
|
||||||
|
|
||||||
|
|
||||||
|
## 本地部署与页面核对
|
||||||
|
|
||||||
|
持久 Compose 环境已在备份数据库和凭据保管密钥后升级到 000034,迁移及零外键核验成功,readiness 通过。原应用、凭据和过渡截止保持。部署脚本同时修正了重复构建时的版本时间戳,`/version` 不再沿用首次初始化时间。
|
||||||
|
|
||||||
|
浏览器已验证:多队列时不默认第一条,手动选择后刷新保留;暂停队列有标签;历史 Token 未补存时说明一致;队列还有订阅时显示中文阻止原因。使用独占空订阅验证了实际退役提交、退役状态、详情和历史仍可读,且不再出现恢复入口;活动名额从 7/64 回到 6/64。随后从页面退役对应空队列,配置选择中标记已退役并禁选。没有对接入方既有队列或凭据执行退役、轮换或提前结束过渡。
|
||||||
|
|
||||||
|
## 版本交付
|
||||||
|
|
||||||
|
`main` 与两个不可变标签 `v0.1.2`、`sdk/go/v0.1.2` 已原子推送到远端;标签代码提交为 `7b6f3ac60c0cf14113ee2bfc79de136ac05ef0aa`。Outbox 没有代码改动,继续使用 `adapters/outbox/mysql/v0.1.1`。
|
||||||
|
|
||||||
|
仓库外新模块使用空模块缓存、`GOWORK=off` 安装远端 SDK v0.1.2 与 Outbox v0.1.1,没有本地 `replace`。实际通过依赖解析、`go vet`、构建和 `go mod verify`,并连接本地真实平台执行新队列/订阅创建、订阅退役和原键重报、队列退役,输出 `retired=true history_preserved=true subscription_replay=true`。
|
||||||
|
|
||||||
|
本地运行 `v0.1.2-local` / `7b6f3ac`,构建时间为 `2026-09-27T06:50:22Z`;readiness 通过,HTTP/Worker/Event 仍为协议 1。后续报告提交只补验收记录,不改变标签代码或已部署二进制。
|
||||||
|
|
||||||
|
## 明确边界
|
||||||
|
|
||||||
|
不提供强制忽略结果或批量硬删除。历史未确认/未知效果须先核查;没有消费者时需要恢复消费并确认积压。资源名称不回收。归档仍按已有配置导出并保留源记录,退役不缩减物理存储。
|
||||||
|
|
||||||
|
新服务端禁止与忽略退役状态的旧写节点混跑。旧 SDK 既有 API 兼容;新的退役方法使用 v0.1.2。隔离回归不代替目标生产环境的容量、跨故障域或真实业务验收。
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## 本次验证
|
## 本次验证
|
||||||
|
|
||||||
发布前检查结果如下;远端安装结果在发布后回写 main 的本记录:
|
本地检查与发布后远端安装均已完成,结果如下:
|
||||||
|
|
||||||
| 检查 | 结果 |
|
| 检查 | 结果 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
| `make integration` | 通过,退出 0;真实 MySQL、27 个示例场景、事务/HTTP 回执丢失、进程强杀及多节点恢复 |
|
| `make integration` | 通过,退出 0;真实 MySQL、27 个示例场景、事务/HTTP 回执丢失、进程强杀及多节点恢复 |
|
||||||
| `make console-e2e` | 通过;登录/保护路由、Worker/重试/计划、保留策略/周期归档作业、离线核验和退出登录 |
|
| `make console-e2e` | 通过;登录/保护路由、Worker/重试/计划、保留策略/周期归档作业、离线核验和退出登录 |
|
||||||
| 历史任务 API 双向 smoke | 通过;当前 SDK 对 a50d5fa 服务端、a50d5fa SDK 对当前服务端 |
|
| 历史任务 API 双向 smoke | 通过;当前 SDK 对 a50d5fa 服务端、a50d5fa SDK 对当前服务端 |
|
||||||
| 远端 main 与三组标签 | 待发布 |
|
| 远端 main 与三组标签 | 原子推送成功,默认分支 main;三组标签均指向发布提交 81e56bd633cfe9f7669b4243dee5d7683e5f6697 |
|
||||||
| 仓库外固定版本安装、构建、HTTP smoke 与校验和 | 待发布后验证 |
|
| 仓库外固定版本安装、构建、HTTP smoke 与校验和 | 空模块缓存、GOWORK=off、无 replace;两个模块精确解析为 v0.1.1,全部退出 0 |
|
||||||
|
|
||||||
## 版本约定
|
## 版本约定
|
||||||
|
|
||||||
@@ -32,3 +32,34 @@
|
|||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
上述检查验证示例迁移、当前平台链路和远端发布,不等同于目标生产容量或跨故障域验收。历史 API smoke 不冒充全协议跨标签矩阵,具体矩阵见[兼容说明](../sdk/compatibility.md)。归档继续保留源数据,不承诺源数据删除或其他数据库适配。
|
上述检查验证示例迁移、当前平台链路和远端发布,不等同于目标生产容量或跨故障域验收。历史 API smoke 不冒充全协议跨标签矩阵,具体矩阵见[兼容说明](../sdk/compatibility.md)。归档继续保留源数据,不承诺源数据删除或其他数据库适配。
|
||||||
|
|
||||||
|
## 远端发布证据
|
||||||
|
|
||||||
|
发布提交为 `81e56bd633cfe9f7669b4243dee5d7683e5f6697`。通过 `git ls-remote --symref origin` 核对默认分支为 main,平台/SDK/Outbox 三组标签解引用到同一提交。随后仅补充本验证记录,既有标签不移动。远端 main 的根 README 与 examples/README 原文逐字节核对一致。
|
||||||
|
|
||||||
|
在仓库外新建 `example.com/orbit-release-consumer`,使用全新 GOPATH/GOMODCACHE、`GOWORK=off`,执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1 git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql@v0.1.1
|
||||||
|
go mod tidy
|
||||||
|
go build ./...
|
||||||
|
go run .
|
||||||
|
go mod verify
|
||||||
|
```
|
||||||
|
|
||||||
|
测试程序通过远端 SDK 调用本地 httptest 服务的健康接口,并读取远端 Outbox SchemaSQL 核对表名及零外键;这一步验证远端包安装与可用性,真实数据库事务/HTTP 业务链路由上方全量回归覆盖。实际输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
remote_sdk_http_smoke=passed outbox_schema_smoke=passed
|
||||||
|
all modules verified
|
||||||
|
remote_versions=v0.1.1 no_replace=true checksum_verification=enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
验证进程使用 `GOPROXY=https://goproxy.cn,direct`,仅在该进程沿用主机已有网络代理;`GOSUMDB=sum.golang.org` 保持开启,未关闭校验和、未设置私有模块例外、未修改全局 Go/Git 配置。解析结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.1 h1:xikN6Frz7HJsHdh4e3iHG9sX4cJNlRjKh0Wn/ApN3XI=
|
||||||
|
git.yuntuo.hk/k2safe/orbit/sdk/go v0.1.1/go.mod h1:9KlMW7Gy+EGaDoEwr3wHHFY47mnAymSfYeQpMS7h4ag=
|
||||||
|
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.1.1 h1:oqRfqtAq6Q1CWss4RFH1jnGu8S+2xr5e+QmeC5zfMPE=
|
||||||
|
git.yuntuo.hk/k2safe/orbit/adapters/outbox/mysql v0.1.1/go.mod h1:G+8DIXGFFssSeQ9T1HahnKUyoKNlF2GQDB21ENO3xqk=
|
||||||
|
```
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ task=<32位任务ID> duplicate=true listed=1 final_state=CANCELLED revision=2
|
|||||||
- [任务示例源码](../../examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。
|
- [任务示例源码](../../examples/tasks/main.go):提交两次同一身份、获取当前状态、列表、带版本取消,再核对直接来源。
|
||||||
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
|
- 任务示例也接受 ORBIT_TOKEN 和 ORBIT_QUEUE_ID,环境值优先于凭据文件。
|
||||||
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
|
- 示例发生错误会打印错误并非零退出;打印内容不包含原始 token。
|
||||||
- 初始凭据 30 天有效。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
|
- 初始凭据默认长期有效,可配置固定到期时间;支持新旧 Token 过渡换证。初始化行为和错误核对方式见 [凭据文档](../api/authentication.md)。
|
||||||
- `make integration` 会在一次性数据库中自动完成上面的真实服务与 SDK 示例验证。
|
- `make integration` 会在一次性数据库中自动完成上面的真实服务与 SDK 示例验证。
|
||||||
|
|
||||||
## 2. 创建客户端与提交任务
|
## 2. 创建客户端与提交任务
|
||||||
@@ -151,7 +151,7 @@ Options.HTTPClient 可注入专用 HTTP 客户端。SDK 复制配置,不修改
|
|||||||
业务项目需要 Go 1.27.1 或更高版本。在业务项目的 `go.mod` 所在目录安装当前接入版本:
|
业务项目需要 Go 1.27.1 或更高版本。在业务项目的 `go.mod` 所在目录安装当前接入版本:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1
|
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2
|
||||||
```
|
```
|
||||||
|
|
||||||
代码中导入:
|
代码中导入:
|
||||||
@@ -167,9 +167,9 @@ go mod tidy
|
|||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
SDK 位于仓库子目录,对应 Git 标签为 `sdk/go/v0.1.1`;`go get` 使用模块版本 `v0.1.1`。服务端对应仓库标签为 `v0.1.1`。业务项目固定版本并提交 `go.mod` 与 `go.sum`,无需本地源码、`replace` 或 Orbit 的 `go.work`。如果此前配置过本地替换,先执行 `go mod edit -dropreplace=git.yuntuo.hk/k2safe/orbit/sdk/go`。
|
SDK 位于仓库子目录,对应 Git 标签为 `sdk/go/v0.1.2`;`go get` 使用模块版本 `v0.1.2`。服务端对应仓库标签为 `v0.1.2`。业务项目固定版本并提交 `go.mod` 与 `go.sum`,无需本地源码、`replace` 或 Orbit 的 `go.work`。如果此前配置过本地替换,先执行 `go mod edit -dropreplace=git.yuntuo.hk/k2safe/orbit/sdk/go`。
|
||||||
|
|
||||||
这是开发与测试接入版本;生产部署仍按[交付清单](../release-checklist.md)完成目标环境验收。版本发布和校验规则见[发布说明](release.md),空缓存远端取包结果见[验证报告](../reports/v0.1.1-release.md)。
|
这是开发与测试接入版本;生产部署仍按[交付清单](../release-checklist.md)完成目标环境验收。版本发布和校验规则见[发布说明](release.md),空缓存远端取包结果见[验证报告](../reports/resource-retirement.md)。
|
||||||
|
|
||||||
## 6. 查询执行历史与证据
|
## 6. 查询执行历史与证据
|
||||||
|
|
||||||
|
|||||||
@@ -29,19 +29,21 @@ sh scripts/test-sdk-isolation.sh
|
|||||||
|
|
||||||
## 接入方式
|
## 接入方式
|
||||||
|
|
||||||
当前接入版本为 `v0.1.1`,需要 Go 1.27.1 或更高版本。接入方应固定 SDK 版本,升级前先执行自己的接口和业务幂等回归:
|
当前接入版本为 `v0.1.2`,需要 Go 1.27.1 或更高版本。接入方应固定 SDK 版本,升级前先执行自己的接口和业务幂等回归:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1
|
go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.2
|
||||||
# 在业务代码导入 SDK 并添加调用后:
|
# 在业务代码导入 SDK 并添加调用后:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
仓库主分支为 `main`。服务端标签 `v0.1.1` 和独立 SDK 标签 `sdk/go/v0.1.1` 指向同一发布提交;Go 子模块标签必须包含 `sdk/go/` 前缀,业务端的模块版本不带此前缀。已发布标签保持不可变,修复使用新的补丁版本。版本发布不替代目标环境验收,正式生产仍需完成[交付核对清单](../release-checklist.md)。
|
仓库主分支为 `main`。服务端标签 `v0.1.2` 和独立 SDK 标签 `sdk/go/v0.1.2` 指向同一发布提交;Go 子模块标签必须包含 `sdk/go/` 前缀,业务端的模块版本不带此前缀。已发布标签保持不可变,修复使用新的补丁版本。版本发布不替代目标环境验收,正式生产仍需完成[交付核对清单](../release-checklist.md)。
|
||||||
|
|
||||||
`v0.1.0` 已在仓库外的新业务 module、空模块缓存和 `GOWORK=off` 下通过公开远端下载、`go mod tidy`、编译、SDK HTTP smoke 和 `go mod verify`,解析版本精确为 `v0.1.0` 且不存在 `Replace`。发布提交、校验和与网络条件见[远端验证报告](../reports/phase-7-sdk-remote-release.md)。后续发布继续重复这一步,并按[兼容矩阵](compatibility.md)验证旧/新组合。
|
`v0.1.0` 已在仓库外的新业务 module、空模块缓存和 `GOWORK=off` 下通过公开远端下载、`go mod tidy`、编译、SDK HTTP smoke 和 `go mod verify`,解析版本精确为 `v0.1.0` 且不存在 `Replace`。发布提交、校验和与网络条件见[远端验证报告](../reports/phase-7-sdk-remote-release.md)。后续发布继续重复这一步,并按[兼容矩阵](compatibility.md)验证旧/新组合。
|
||||||
|
|
||||||
本地联调可以临时添加 `replace`,但不能提交到业务项目的发布分支。SDK 不负责 TLS 终止、凭据托管、对象存储或外部副作用核查;这些边界仍由平台接口手册和接入方运行手册定义。
|
本地联调可以临时添加 `replace`,但不能提交到业务项目的发布分支。SDK 不负责 TLS 终止、凭据托管、对象存储或外部副作用核查;这些边界仍由平台接口手册和接入方运行手册定义。
|
||||||
|
|
||||||
Outbox 独立模块使用 `adapters/outbox/mysql/v0.1.1` 标签,安装与版本边界见[适配包手册](outbox-mysql.md)。本次目录整理、发布与远端取包验证见[v0.1.1 记录](../reports/v0.1.1-release.md)。
|
Outbox 独立模块使用 `adapters/outbox/mysql/v0.1.1` 标签,安装与版本边界见[适配包手册](outbox-mysql.md)。本次目录整理、发布与远端取包验证见[v0.1.1 记录](../reports/v0.1.1-release.md)。
|
||||||
|
|
||||||
|
`v0.1.2` 发布队列/订阅退役与对应 SDK 方法,数据库升级到 000034;[本次报告](../reports/resource-retirement.md)列出隔离回归与远端安装结果。Outbox 不需要同步升级。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Orbit 可运行示例
|
# Orbit 可运行示例
|
||||||
|
|
||||||
这里集中提供 27 个场景:24 个轻量 SDK 程序和 3 个包含业务数据库的完整工程。每个目录的 README 都有用途、前置条件、运行命令、预期结果及详细接口链接。
|
这里集中提供 28 个场景:25 个轻量 SDK 程序和 3 个包含业务数据库的完整工程。每个目录的 README 都有用途、前置条件、运行命令、预期结果及详细接口链接。
|
||||||
|
|
||||||
## 第一次使用
|
## 第一次使用
|
||||||
|
|
||||||
@@ -104,3 +104,7 @@ go run ./workflow-recovery
|
|||||||
## 回归
|
## 回归
|
||||||
|
|
||||||
从仓库根目录执行 `make test vet build` 覆盖全部示例模块;`make integration` 使用临时 MySQL 和真实 Orbit 进程运行场景与故障测试。仅跑 `go test ./...` 而未提供集成环境时,外部服务用例会跳过。
|
从仓库根目录执行 `make test vet build` 覆盖全部示例模块;`make integration` 使用临时 MySQL 和真实 Orbit 进程运行场景与故障测试。仅跑 `go test ./...` 而未提供集成环境时,外部服务用例会跳过。
|
||||||
|
|
||||||
|
## 通用资源退役
|
||||||
|
|
||||||
|
[resource-lifecycle](../sdk/go/examples/resource-lifecycle/README.md) 位于 SDK module,可用当前 SDK 源码直接运行;只创建和退役本次独占的空队列及订阅,验证幂等与历史保留。
|
||||||
|
|||||||
@@ -10,35 +10,46 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
ConsoleLogin consoleauth.LoginPolicy // ConsoleLogin 登录保护策略,所有服务节点使用一致配置。
|
||||||
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
ConsoleApplicationAdmin string // ConsoleApplicationAdmin 可跨应用管理接入的唯一控制台登录名;空时禁用。
|
||||||
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
CredentialKeyFile string // CredentialKeyFile 管理员 Token 保管密钥文件路径;所有服务节点共用同一密钥环。
|
||||||
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
ArchiveRoot string // ArchiveRoot 私有持久归档卷绝对路径,空表示关闭后台执行器。
|
||||||
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
ArchiveStorageID core.ID // ArchiveStorageID 32位小写十六进制卷身份,各节点须挂载同一物理卷。
|
||||||
HTTPAddr string
|
ArchiveMaxBytes int64 // ArchiveMaxBytes 全卷文件预算,默认10GiB。
|
||||||
MySQLDSN string
|
ArchiveTickInterval time.Duration // ArchiveTickInterval 后台扫描间隔,默认10秒。
|
||||||
MaxOpen int
|
RetentionDefaults retention.Policy // RetentionDefaults 未保存数据库策略时使用的部署默认值。
|
||||||
MaxIdle int
|
HTTPAddr string
|
||||||
MetricsAddr string
|
MySQLDSN string
|
||||||
WorkflowTickInterval time.Duration // WorkflowTickInterval Weave 持久化推进扫描间隔,范围100ms–1m。
|
MaxOpen int
|
||||||
ScheduleTickInterval time.Duration // ScheduleTickInterval 计划扫描间隔,范围100ms–1m。
|
MaxIdle int
|
||||||
RecoveryTickInterval time.Duration // RecoveryTickInterval 执行租约恢复扫描间隔,范围100ms–1m。
|
MetricsAddr string
|
||||||
RuntimeBatchLimit int // RuntimeBatchLimit 每个后台批次的候选上限,范围1–100。
|
WorkflowTickInterval time.Duration // WorkflowTickInterval Weave 持久化推进扫描间隔,范围100ms–1m。
|
||||||
ShutdownTimeout time.Duration // ShutdownTimeout 优雅排空和关闭窗口,范围1s–5m。
|
ScheduleTickInterval time.Duration // ScheduleTickInterval 计划扫描间隔,范围100ms–1m。
|
||||||
WorkerConnectionLimit int // WorkerConnectionLimit 单节点 Worker WebSocket 连接上限,范围1–4096。
|
RecoveryTickInterval time.Duration // RecoveryTickInterval 执行租约恢复扫描间隔,范围100ms–1m。
|
||||||
EventConnectionLimit int // EventConnectionLimit 单节点 Event WebSocket 连接上限,范围1–4096。
|
RuntimeBatchLimit int // RuntimeBatchLimit 每个后台批次的候选上限,范围1–100。
|
||||||
DispatchWorkers int // DispatchWorkers 单个 Hub 并发执行数据库领取调用的协程数,范围1–256。
|
ShutdownTimeout time.Duration // ShutdownTimeout 优雅排空和关闭窗口,范围1s–5m。
|
||||||
DispatchCapacity int // DispatchCapacity 单个 Hub 领取调用的有界内存排队槽位,范围1–65536。
|
WorkerConnectionLimit int // WorkerConnectionLimit 单节点 Worker WebSocket 连接上限,范围1–4096。
|
||||||
|
EventConnectionLimit int // EventConnectionLimit 单节点 Event WebSocket 连接上限,范围1–4096。
|
||||||
|
DispatchWorkers int // DispatchWorkers 单个 Hub 并发执行数据库领取调用的协程数,范围1–256。
|
||||||
|
DispatchCapacity int // DispatchCapacity 单个 Hub 领取调用的有界内存排队槽位,范围1–65536。
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
func Load() (Config, error) { return FromEnv(os.Getenv) }
|
||||||
func FromEnv(get func(string) string) (Config, error) {
|
func FromEnv(get func(string) string) (Config, error) {
|
||||||
|
loginPolicy, err := loginPolicyFromEnv(get)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
c := Config{
|
c := Config{
|
||||||
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
ConsoleLogin: loginPolicy,
|
||||||
|
ConsoleApplicationAdmin: get("ORBIT_CONSOLE_APPLICATION_ADMIN"),
|
||||||
|
CredentialKeyFile: get("ORBIT_CREDENTIAL_KEY_FILE"),
|
||||||
|
ArchiveRoot: get("ORBIT_ARCHIVE_ROOT"), ArchiveStorageID: core.ID(get("ORBIT_ARCHIVE_STORAGE_ID")), ArchiveMaxBytes: 10 << 30, ArchiveTickInterval: 10 * time.Second,
|
||||||
HTTPAddr: get("ORBIT_HTTP_ADDR"),
|
HTTPAddr: get("ORBIT_HTTP_ADDR"),
|
||||||
MySQLDSN: get("ORBIT_MYSQL_DSN"),
|
MySQLDSN: get("ORBIT_MYSQL_DSN"),
|
||||||
MaxOpen: 20,
|
MaxOpen: 20,
|
||||||
@@ -150,7 +161,6 @@ func FromEnv(get func(string) string) (Config, error) {
|
|||||||
}
|
}
|
||||||
c.ArchiveTickInterval = d
|
c.ArchiveTickInterval = d
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
c.RetentionDefaults, err = retention.FromEnv(get)
|
c.RetentionDefaults, err = retention.FromEnv(get)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
|
|||||||
57
internal/config/console_login.go
Normal file
57
internal/config/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loginPolicyFromEnv(get func(string) string) (consoleauth.LoginPolicy, error) {
|
||||||
|
p := consoleauth.DefaultLoginPolicy()
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
dst *int
|
||||||
|
}{
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT", &p.FailureLimit},
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT", &p.AccountLimit},
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_IP_LIMIT", &p.IPLimit},
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_AUDIT_DAYS", &p.AuditDays},
|
||||||
|
} {
|
||||||
|
if raw := get(f.name); raw != "" {
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return p, fmt.Errorf("invalid %s", f.name)
|
||||||
|
}
|
||||||
|
*f.dst = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
dst *time.Duration
|
||||||
|
}{
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW", &p.FailureWindow},
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_LOCK_DURATION", &p.LockDuration},
|
||||||
|
{"ORBIT_CONSOLE_LOGIN_RATE_WINDOW", &p.RateWindow},
|
||||||
|
} {
|
||||||
|
if raw := get(f.name); raw != "" {
|
||||||
|
d, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
return p, fmt.Errorf("invalid %s", f.name)
|
||||||
|
}
|
||||||
|
*f.dst = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := get("ORBIT_CONSOLE_TRUSTED_PROXIES"); raw != "" {
|
||||||
|
for _, item := range strings.Split(raw, ",") {
|
||||||
|
prefix, err := netip.ParsePrefix(strings.TrimSpace(item))
|
||||||
|
if err != nil {
|
||||||
|
return p, fmt.Errorf("invalid ORBIT_CONSOLE_TRUSTED_PROXIES CIDR")
|
||||||
|
}
|
||||||
|
p.TrustedProxies = append(p.TrustedProxies, prefix.Masked())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, p.Validate()
|
||||||
|
}
|
||||||
30
internal/config/console_login_test.go
Normal file
30
internal/config/console_login_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsoleLoginConfiguration(t *testing.T) {
|
||||||
|
p, err := loginPolicyFromEnv(func(string) string { return "" })
|
||||||
|
if err != nil || p.FailureLimit != 5 || p.LockDuration != 15*time.Minute || p.AuditDays != 90 {
|
||||||
|
t.Fatalf("defaults: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
env := map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "3", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "20m", "ORBIT_CONSOLE_TRUSTED_PROXIES": "127.0.0.1/32, ::1/128"}
|
||||||
|
p, err = loginPolicyFromEnv(func(k string) string { return env[k] })
|
||||||
|
if err != nil || p.FailureLimit != 3 || len(p.TrustedProxies) != 2 {
|
||||||
|
t.Fatalf("configured: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{"ORBIT_CONSOLE_LOGIN_FAILURE_LIMIT": "0", "ORBIT_CONSOLE_LOGIN_ACCOUNT_LIMIT": "-1", "ORBIT_CONSOLE_LOGIN_IP_LIMIT": "10001", "ORBIT_CONSOLE_LOGIN_FAILURE_WINDOW": "0s", "ORBIT_CONSOLE_LOGIN_LOCK_DURATION": "25h", "ORBIT_CONSOLE_LOGIN_RATE_WINDOW": "bad", "ORBIT_CONSOLE_LOGIN_AUDIT_DAYS": "0", "ORBIT_CONSOLE_TRUSTED_PROXIES": "all"} {
|
||||||
|
t.Run(k, func(t *testing.T) {
|
||||||
|
if _, err := loginPolicyFromEnv(func(name string) string {
|
||||||
|
if name == k {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("invalid policy accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/consoleauth/applications.go
Normal file
105
internal/consoleauth/applications.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplicationEntry 是应用接入列表的一行;同一应用的不同命名空间分别展示。
|
||||||
|
type ApplicationEntry struct {
|
||||||
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 所属应用标识。
|
||||||
|
Application string `json:"application"` // Application 应用名称。
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 数据隔离范围标识。
|
||||||
|
Namespace string `json:"namespace"` // Namespace 命名空间名称。
|
||||||
|
Environment string `json:"environment"` // Environment 环境名称。
|
||||||
|
Enabled bool `json:"enabled"` // Enabled 应用与命名空间均启用时为真。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationPage 按命名空间标识分页,每页最多 50 行,不包含秘密。
|
||||||
|
type ApplicationPage struct {
|
||||||
|
Items []ApplicationEntry `json:"items"` // Items 当前页接入范围。
|
||||||
|
Next core.ID `json:"next,omitempty"` // Next 下一页的排他起点。
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessQueue 是接入配置可选的队列。
|
||||||
|
type AccessQueue struct {
|
||||||
|
Paused bool `json:"paused"` // Paused 暂停执行,不能表示已退役。
|
||||||
|
RetiredAt *time.Time `json:"retired_at,omitempty"` // RetiredAt 永久退役时间,历史仍可查询。
|
||||||
|
ID core.ID `json:"id"` // ID 队列标识。
|
||||||
|
Name string `json:"name"` // Name 队列名称。
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessCredential 是脱敏的凭据元数据,绝不返回秘密或秘密摘要。
|
||||||
|
type AccessCredential struct {
|
||||||
|
TokenStored bool `json:"token_stored"` // TokenStored 当前 Token 是否已加密保存;列表不会返回秘密。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。
|
||||||
|
ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。
|
||||||
|
Name string `json:"name"` // Name 凭据名称。
|
||||||
|
Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。
|
||||||
|
RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。
|
||||||
|
Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationDetail 展示一个命名空间的队列和凭据,读取量有界。
|
||||||
|
type ApplicationDetail struct {
|
||||||
|
ActiveSubscriptions uint64 `json:"active_subscriptions"` // ActiveSubscriptions 未退役订阅数量,包含暂停订阅。
|
||||||
|
SubscriptionLimit uint32 `json:"subscription_limit"` // SubscriptionLimit 命名空间活动订阅上限。
|
||||||
|
TokenVaultEnabled bool `json:"token_vault_enabled"` // TokenVaultEnabled 当前节点是否配置了凭据保管密钥。
|
||||||
|
Entry ApplicationEntry `json:"entry"` // Entry 当前应用与环境。
|
||||||
|
Queues []AccessQueue `json:"queues"` // Queues 最多 100 个队列。
|
||||||
|
Credentials []AccessCredential `json:"credentials"` // Credentials 最多 100 张凭据。
|
||||||
|
Truncated bool `json:"truncated"` // Truncated 为真时不得把当前结果解释为完整清单。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationInput 描述创建应用或变更凭据的管理员意图。
|
||||||
|
type ApplicationInput struct {
|
||||||
|
Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY 或 FINALIZE。
|
||||||
|
Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。
|
||||||
|
Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。
|
||||||
|
Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。
|
||||||
|
Queue string `json:"queue"` // Queue 初始队列名称,仅 CREATE 使用。
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"` // Capabilities 显式选择的业务权限组,仅 CREATE 使用。
|
||||||
|
NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。
|
||||||
|
CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。
|
||||||
|
ValidDays int `json:"valid_days"` // ValidDays 有效天数;0 为长期,1–365 为定期。
|
||||||
|
GraceDays int `json:"grace_days,omitempty"` // GraceDays ROTATE 的旧令牌过渡天数,0–30;0 显式立即失效。
|
||||||
|
Reason string `json:"reason"` // Reason 写入审计的变更原因。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationOperation 是浏览器在提交前保存的固定操作;启用保管时仅保存 Token 密文。
|
||||||
|
// 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。
|
||||||
|
type ApplicationOperation struct {
|
||||||
|
Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。
|
||||||
|
Input ApplicationInput `json:"input"` // Input 固定的管理员意图。
|
||||||
|
OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。
|
||||||
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 固定命名空间标识。
|
||||||
|
QueueID core.ID `json:"queue_id,omitempty"` // QueueID 新建应用的初始队列。
|
||||||
|
CredentialID core.ID `json:"credential_id"` // CredentialID 固定凭据身份。
|
||||||
|
Token string `json:"token,omitempty"` // Token 当前秘密;服务端启用保管后加密保存,不能进入日志。
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 固定到期时间,空表示长期或不适用。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil ROTATE 固定过渡截止。
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationReceipt 是数据库持久化的脱敏回执。
|
||||||
|
type ApplicationReceipt struct {
|
||||||
|
OperationID core.ID `json:"operation_id"` // OperationID 原操作身份。
|
||||||
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 应用标识。
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 命名空间标识。
|
||||||
|
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据标识。
|
||||||
|
Revision uint64 `json:"revision,string"` // Revision 操作提交后的凭据版本。
|
||||||
|
Deduplicated bool `json:"deduplicated"` // Deduplicated 命中原回执,不代表当前 Token 仍有效。
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applications 只面向部署指定的单管理员浏览器会话,不接收业务 SDK 身份。
|
||||||
|
type Applications interface {
|
||||||
|
ListApplications(context.Context, auth.Token, core.ID) (ApplicationPage, error)
|
||||||
|
GetApplication(context.Context, auth.Token, core.ID) (ApplicationDetail, error)
|
||||||
|
PrepareApplication(context.Context, auth.Token, ApplicationInput) (ApplicationOperation, error)
|
||||||
|
ApplyApplication(context.Context, auth.Token, ApplicationOperation) (ApplicationReceipt, error)
|
||||||
|
}
|
||||||
33
internal/consoleauth/credential_tokens.go
Normal file
33
internal/consoleauth/credential_tokens.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialTokenInput 用于管理员查看或补存已有 Token,不改变业务认证身份。
|
||||||
|
type CredentialTokenInput struct {
|
||||||
|
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 页面读取到的凭据版本;并发换证时拒绝返回混合版本。
|
||||||
|
Token string `json:"token,omitempty"` // Token 仅导入时填写;必须与当前摘要一致,旧代令牌不可导入。
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CredentialTokenInput) String() string { return "CredentialTokenInput{REDACTED}" }
|
||||||
|
func (v CredentialTokenInput) GoString() string { return v.String() }
|
||||||
|
|
||||||
|
// CredentialToken 只在已认证且已提交查看审计的管理员专用请求中返回。
|
||||||
|
type CredentialToken struct {
|
||||||
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 当前凭据的命名空间。
|
||||||
|
CredentialID core.ID `json:"credential_id"` // CredentialID 当前凭据标识。
|
||||||
|
Revision uint64 `json:"revision,string"` // Revision 读取时的当前凭据版本。
|
||||||
|
Token string `json:"token"` // Token 当前代次原文,禁止进入日志、列表或浏览器持久存储。
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CredentialToken) String() string { return "CredentialToken{REDACTED}" }
|
||||||
|
func (v CredentialToken) GoString() string { return v.String() }
|
||||||
|
|
||||||
|
// CredentialTokens 的 importCurrent 为真时补存原文;为假时只读取,两个操作均留审计。
|
||||||
|
type CredentialTokens interface {
|
||||||
|
AccessCredentialToken(context.Context, auth.Token, core.ID, core.ID, CredentialTokenInput, bool) (CredentialToken, error)
|
||||||
|
}
|
||||||
73
internal/consoleauth/login_policy.go
Normal file
73
internal/consoleauth/login_policy.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginPolicy 是所有节点统一配置的登录保护策略;计数及锁定保存在数据库。
|
||||||
|
type LoginPolicy struct {
|
||||||
|
FailureLimit int // FailureLimit 失败窗口内允许的连续失败次数,达到后锁定。
|
||||||
|
FailureWindow time.Duration // FailureWindow 连续失败的统计窗口。
|
||||||
|
LockDuration time.Duration // LockDuration 临时锁定时长,锁定期间请求不续期。
|
||||||
|
AccountLimit int // AccountLimit 每个限流窗口最多接受的账号登录尝试数。
|
||||||
|
IPLimit int // IPLimit 每个限流窗口最多接受的来源 IP 登录尝试数。
|
||||||
|
RateWindow time.Duration // RateWindow 固定限流窗口;成功登录不清空流量计数。
|
||||||
|
AuditDays int // AuditDays 登录审计保留天数,与业务数据策略独立。
|
||||||
|
TrustedProxies []netip.Prefix // TrustedProxies 明确信任的反向代理网段;空时忽略所有转发头。
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLoginPolicy() LoginPolicy {
|
||||||
|
return LoginPolicy{FailureLimit: 5, FailureWindow: 15 * time.Minute, LockDuration: 15 * time.Minute, AccountLimit: 10, IPLimit: 30, RateWindow: time.Minute, AuditDays: 90}
|
||||||
|
}
|
||||||
|
func (p LoginPolicy) Validate() error {
|
||||||
|
if p.FailureLimit < 1 || p.FailureLimit > 100 || p.AccountLimit < 1 || p.AccountLimit > 1000 || p.IPLimit < 1 || p.IPLimit > 10000 {
|
||||||
|
return errors.New("console login limits out of range")
|
||||||
|
}
|
||||||
|
for _, d := range []time.Duration{p.FailureWindow, p.LockDuration, p.RateWindow} {
|
||||||
|
if d < time.Second || d > 24*time.Hour {
|
||||||
|
return errors.New("console login durations must be 1s–24h")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.AuditDays < 1 || p.AuditDays > 3650 {
|
||||||
|
return errors.New("console login audit retention must be 1–3650 days")
|
||||||
|
}
|
||||||
|
for _, n := range p.TrustedProxies {
|
||||||
|
if !n.IsValid() {
|
||||||
|
return errors.New("invalid console trusted proxy CIDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginBlocked 对外不区分账号是否存在,只给出拒绝类型和最短等待时间。
|
||||||
|
type LoginBlocked struct {
|
||||||
|
Locked bool // Locked 表示临时锁定,否则为请求限流。
|
||||||
|
RetryAfter time.Duration // RetryAfter 按数据库时钟计算的剩余等待时间。
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*LoginBlocked) Error() string { return "console login temporarily blocked" }
|
||||||
|
|
||||||
|
// LoginAuditEntry 是脱敏的登录审计;从不记录密码、Cookie、Token 或它们的摘要。
|
||||||
|
type LoginAuditEntry struct {
|
||||||
|
ID uint64 `json:"id,string"` // ID 审计游标,按插入序号倒序分页。
|
||||||
|
Username string `json:"username"` // Username 提交的有界登录名,不证明账号存在。
|
||||||
|
RemoteIP string `json:"remote_ip"` // RemoteIP 经可信代理规则解析的来源地址。
|
||||||
|
UserAgent string `json:"user_agent"` // UserAgent 截断为最多 512 字节的浏览器信息。
|
||||||
|
Outcome string `json:"outcome"` // Outcome PENDING、SUCCESS、INVALID、LOCKED、ACCOUNT_LIMIT、IP_LIMIT 或 ERROR。
|
||||||
|
CreatedAt time.Time `json:"created_at"` // CreatedAt 尝试时间,UTC。
|
||||||
|
FinishedAt *time.Time `json:"finished_at,omitempty"` // FinishedAt 完成时间;空表示尚未确认结果。
|
||||||
|
RetryAt *time.Time `json:"retry_at,omitempty"` // RetryAt 拒绝时建议最早重试时间,UTC。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginAuditPage 固定每页 10 条,Next 是下一页排他上界;不承诺跨页快照。
|
||||||
|
type LoginAuditPage struct {
|
||||||
|
Items []LoginAuditEntry `json:"items"` // Items 当前页脱敏记录。
|
||||||
|
Next string `json:"next,omitempty"` // Next 下一页游标,无更多时为空。
|
||||||
|
}
|
||||||
|
type LoginAuditor interface {
|
||||||
|
ListLoginAudit(context.Context, auth.Token, string) (LoginAuditPage, error)
|
||||||
|
}
|
||||||
22
internal/consoleauth/scope.go
Normal file
22
internal/consoleauth/scope.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope 是当前查看范围的脱敏元数据。队列分页独立于应用分页。
|
||||||
|
type Scope struct {
|
||||||
|
Entry ApplicationEntry `json:"entry"` // Entry 数据库核实的应用、环境和命名空间。
|
||||||
|
Queues []AccessQueue `json:"queues"` // Queues 本页授权队列,最多 100 项。
|
||||||
|
Next core.ID `json:"next,omitempty"` // Next 下一页队列排他起点。
|
||||||
|
CanSwitch bool `json:"can_switch"` // CanSwitch 仅部署指定的管理员可以切换应用。
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes 只供登录 Cookie 使用,SDK 不具备范围切换接口。
|
||||||
|
type Scopes interface {
|
||||||
|
ResolveScope(context.Context, auth.Token, core.ID) (auth.Token, error)
|
||||||
|
GetScope(context.Context, auth.Token, core.ID) (Scope, error)
|
||||||
|
}
|
||||||
111
internal/credentialvault/vault.go
Normal file
111
internal/credentialvault/vault.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Package credentialvault 加密保存管理员可重新查看的当前业务 Token。
|
||||||
|
// 主密钥来自独立文件,不进入数据库、日志或 API 响应。
|
||||||
|
package credentialvault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keyring 为不可变密钥环;active 用于新写入,历史密钥用于解密既有记录。
|
||||||
|
type Keyring struct {
|
||||||
|
active string
|
||||||
|
keys map[string]cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Keyring) String() string { return "credentialvault.Keyring{REDACTED}" }
|
||||||
|
func (k *Keyring) GoString() string { return k.String() }
|
||||||
|
|
||||||
|
// LoadFile 读取最多 64 KiB 的 JSON 密钥文件。空路径表示未启用密文保管。
|
||||||
|
func LoadFile(path string) (*Keyring, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("cannot open credential key file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return Read(io.LimitReader(f, 65537))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read 校验 active 和 keys;每个 Base64 密钥必须恰好为 32 字节。
|
||||||
|
func Read(r io.Reader) (*Keyring, error) {
|
||||||
|
var config struct {
|
||||||
|
Active string `json:"active"`
|
||||||
|
Keys map[string]string `json:"keys"`
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, 65537))
|
||||||
|
if err != nil || len(raw) > 65536 {
|
||||||
|
return nil, errors.New("invalid credential key file")
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(raw, &config); err != nil || len(config.Keys) == 0 || len(config.Keys) > 16 {
|
||||||
|
return nil, errors.New("invalid credential key file")
|
||||||
|
}
|
||||||
|
out := &Keyring{active: config.Active, keys: make(map[string]cipher.AEAD)}
|
||||||
|
for id, encoded := range config.Keys {
|
||||||
|
key, err := base64.StdEncoding.Strict().DecodeString(encoded)
|
||||||
|
if err != nil || len(key) != 32 || !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(id) {
|
||||||
|
return nil, errors.New("invalid credential encryption key")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid credential encryption key")
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("cannot initialize credential encryption")
|
||||||
|
}
|
||||||
|
out.keys[id] = aead
|
||||||
|
}
|
||||||
|
if out.keys[out.active] == nil {
|
||||||
|
return nil, errors.New("active credential encryption key is missing")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aad(namespace, credential core.ID) []byte {
|
||||||
|
return []byte("orbit-credential-v1:" + string(namespace) + ":" + string(credential))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal 返回密钥标识和 nonce+ciphertext。未启用时两者均为空,兼容只存摘要的管理 CLI。
|
||||||
|
func (k *Keyring) Seal(namespace, credential core.ID, token string) (*string, []byte, error) {
|
||||||
|
if k == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
if namespace.Validate() != nil || credential.Validate() != nil || len(token) == 0 || len(token) > 512 {
|
||||||
|
return nil, nil, errors.New("invalid credential encryption input")
|
||||||
|
}
|
||||||
|
aead := k.keys[k.active]
|
||||||
|
nonce := make([]byte, aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, nil, errors.New("cannot generate credential encryption nonce")
|
||||||
|
}
|
||||||
|
id := k.active
|
||||||
|
return &id, aead.Seal(nonce, nonce, []byte(token), aad(namespace, credential)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 只解密指定凭据;调用方必须先认证管理员、校验版本,再核对当前摘要并记录审计。
|
||||||
|
func (k *Keyring) Open(namespace, credential core.ID, keyID string, ciphertext []byte) (string, error) {
|
||||||
|
if k == nil {
|
||||||
|
return "", errors.New("credential encryption is not configured")
|
||||||
|
}
|
||||||
|
aead := k.keys[keyID]
|
||||||
|
if aead == nil || len(ciphertext) < aead.NonceSize()+aead.Overhead() || len(ciphertext) > 1024 {
|
||||||
|
return "", errors.New("credential ciphertext cannot be decrypted")
|
||||||
|
}
|
||||||
|
raw, err := aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], aad(namespace, credential))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("credential ciphertext cannot be decrypted")
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
80
internal/credentialvault/vault_test.go
Normal file
80
internal/credentialvault/vault_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package credentialvault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCredentialEncryption(t *testing.T) {
|
||||||
|
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||||
|
ring, err := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
id, _ := core.NewID()
|
||||||
|
other, _ := core.NewID()
|
||||||
|
k, a, err := ring.Seal(ns, id, "test-only-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, b, _ := ring.Seal(ns, id, "test-only-secret")
|
||||||
|
if bytes.Equal(a, b) || bytes.Contains(a, []byte("test-only-secret")) {
|
||||||
|
t.Fatal("nonce reused or plaintext stored")
|
||||||
|
}
|
||||||
|
got, err := ring.Open(ns, id, *k, a)
|
||||||
|
if err != nil || got != "test-only-secret" {
|
||||||
|
t.Fatal("round trip failed")
|
||||||
|
}
|
||||||
|
if _, err = ring.Open(ns, other, *k, a); err == nil {
|
||||||
|
t.Fatal("different credential accepted")
|
||||||
|
}
|
||||||
|
if _, err = ring.Open(other, id, *k, a); err == nil {
|
||||||
|
t.Fatal("different namespace accepted")
|
||||||
|
}
|
||||||
|
if _, err = ring.Open(ns, id, "unknown", a); err == nil {
|
||||||
|
t.Fatal("unknown key accepted")
|
||||||
|
}
|
||||||
|
a[len(a)-1] ^= 1
|
||||||
|
if _, err = ring.Open(ns, id, *k, a); err == nil {
|
||||||
|
t.Fatal("tampered ciphertext accepted")
|
||||||
|
}
|
||||||
|
for _, v := range []string{fmt.Sprint(ring), fmt.Sprintf("%#v", ring)} {
|
||||||
|
if strings.Contains(v, key) || !strings.Contains(v, "REDACTED") {
|
||||||
|
t.Fatal("keyring formatting not redacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old, _ := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`))
|
||||||
|
k, c, _ := old.Seal(ns, id, "old-secret")
|
||||||
|
newKey := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))
|
||||||
|
rotated, err := Read(strings.NewReader(`{"active":"two","keys":{"one":"` + key + `","two":"` + newKey + `"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v, err := rotated.Open(ns, id, *k, c); err != nil || v != "old-secret" {
|
||||||
|
t.Fatal("retained key cannot read old data")
|
||||||
|
}
|
||||||
|
if k, _, _ := rotated.Seal(ns, id, "new-secret"); *k != "two" {
|
||||||
|
t.Fatal("active key ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialKeyValidation(t *testing.T) {
|
||||||
|
for _, raw := range []string{`{}`, `{"active":"x","keys":{"x":"bad"}}`, `{"active":"missing","keys":{"x":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `"}}`, strings.Repeat(" ", 65537)} {
|
||||||
|
if _, err := Read(strings.NewReader(raw)); err == nil {
|
||||||
|
t.Fatal("invalid key configuration accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k, err := LoadFile("")
|
||||||
|
if err != nil || k != nil {
|
||||||
|
t.Fatal("empty path must disable vault")
|
||||||
|
}
|
||||||
|
if id, c, err := k.Seal("", "", ""); err != nil || id != nil || c != nil {
|
||||||
|
t.Fatal("disabled vault must use SQL NULL pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ var (
|
|||||||
Unauthorized = New("UNAUTHENTICATED", "valid credentials are required")
|
Unauthorized = New("UNAUTHENTICATED", "valid credentials are required")
|
||||||
Forbidden = New("PERMISSION_DENIED", "operation is not allowed")
|
Forbidden = New("PERMISSION_DENIED", "operation is not allowed")
|
||||||
NotFound = New("NOT_FOUND", "resource not found")
|
NotFound = New("NOT_FOUND", "resource not found")
|
||||||
|
Retired = New("RESOURCE_RETIRED", "resource is retired; history remains available")
|
||||||
Conflict = New("STATE_CONFLICT", "resource state does not allow this operation")
|
Conflict = New("STATE_CONFLICT", "resource state does not allow this operation")
|
||||||
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
|
IdempotencyConflict = New("IDEMPOTENCY_CONFLICT", "idempotency key was used with different content")
|
||||||
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
|
Unavailable = New("UNAVAILABLE", "service temporarily unavailable; retry using the original operation identity")
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
|||||||
finished := retryStart(t, g)
|
finished := retryStart(t, g)
|
||||||
retryFinish(t, g, finished, "SUCCEEDED", false)
|
retryFinish(t, g, finished, "SUCCEEDED", false)
|
||||||
assertApplicationCounts(t, f, 4, 2, 1)
|
assertApplicationCounts(t, f, 4, 2, 1)
|
||||||
|
f.exec(`ALTER TABLE orbit_subscriptions DROP CHECK ck_subscription_retired,DROP INDEX ix_subscription_active,DROP COLUMN retired_at,DROP COLUMN retired_sequence`)
|
||||||
|
f.exec(`ALTER TABLE pulse_queues DROP CHECK ck_queue_retired,DROP COLUMN retired_at`)
|
||||||
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
|
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
|
||||||
f.exec(`ALTER TABLE orbit_events DROP INDEX ix_event_workflow_history,DROP COLUMN workflow_id`)
|
f.exec(`ALTER TABLE orbit_events DROP INDEX ix_event_workflow_history,DROP COLUMN workflow_id`)
|
||||||
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_observe_state_available`)
|
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_observe_state_available`)
|
||||||
@@ -94,7 +96,9 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
|||||||
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
|
f.exec(`ALTER TABLE pulse_worker_sessions DROP COLUMN concurrency_keys`)
|
||||||
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
|
// 新增的控制台和保留策略迁移也要还原到历史 v9,避免升级测试把
|
||||||
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
||||||
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
f.exec(`DROP TABLE orbit_console_login_audit,orbit_console_login_guards,orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||||
|
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
|
||||||
|
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`)
|
||||||
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
||||||
if err := Migrate(ctx, db); err != nil {
|
if err := Migrate(ctx, db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Orbit fresh-install baseline, generated from migration version 31.
|
-- Orbit fresh-install baseline, generated from migration version 33.
|
||||||
-- This file intentionally contains no database-level relation constraints.
|
-- This file intentionally contains no database-level relation constraints.
|
||||||
CREATE TABLE `orbit_application_resources` (
|
CREATE TABLE `orbit_application_resources` (
|
||||||
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
|
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
|
||||||
@@ -59,13 +59,19 @@ CREATE TABLE `orbit_credentials` (
|
|||||||
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||||
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||||
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
||||||
|
`secret_key_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
|
||||||
|
`secret_ciphertext` varbinary(1024) DEFAULT NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
|
||||||
|
`previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
|
||||||
|
`previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
|
||||||
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
||||||
`expires_at` datetime(6) DEFAULT NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间',
|
`expires_at` datetime(6) DEFAULT NULL COMMENT '凭据过期时间,统一 UTC;为空表示没有配置到期时间',
|
||||||
`revoked_at` datetime(6) DEFAULT NULL COMMENT '撤销时间;为空表示尚未撤销',
|
`revoked_at` datetime(6) DEFAULT NULL COMMENT '撤销时间;为空表示尚未撤销',
|
||||||
PRIMARY KEY (`namespace_id`,`id`),
|
PRIMARY KEY (`namespace_id`,`id`),
|
||||||
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
|
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
|
||||||
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0))
|
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)),
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
|
CONSTRAINT `ck_credential_ciphertext` CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
|
||||||
|
CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
|
||||||
CREATE TABLE `orbit_deliveries` (
|
CREATE TABLE `orbit_deliveries` (
|
||||||
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||||
`id` binary(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
`id` binary(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||||
@@ -927,3 +933,43 @@ CREATE TABLE orbit_archive_jobs (
|
|||||||
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
|
CONSTRAINT ck_archive_job_config CHECK(revision>0 AND plan_revision>0 AND policy_revision>0 AND batch_size BETWEEN 1 AND 100 AND max_bytes BETWEEN 1048576 AND 1073741824),
|
||||||
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
|
CONSTRAINT ck_archive_job_lease CHECK((state='RUNNING' AND lease_id IS NOT NULL AND lease_until IS NOT NULL) OR (state<>'RUNNING' AND lease_id IS NULL AND lease_until IS NULL))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='持久归档作业及租约,只读源数据,无外键';
|
||||||
|
|
||||||
|
-- 通用资源退役,只关闭未来工作并释放活动订阅名额,不删除历史或审计;禁止外键。
|
||||||
|
ALTER TABLE pulse_queues
|
||||||
|
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示未退役,退役后禁止新工作且不可恢复',
|
||||||
|
ADD CONSTRAINT ck_queue_retired CHECK (retired_at IS NULL OR paused=TRUE);
|
||||||
|
ALTER TABLE orbit_subscriptions
|
||||||
|
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示仍占用订阅名额,退役保留历史交付',
|
||||||
|
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||||
|
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||||
|
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||||
|
|
||||||
|
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。
|
||||||
|
CREATE TABLE orbit_console_login_guards (
|
||||||
|
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁',
|
||||||
|
subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token',
|
||||||
|
window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC',
|
||||||
|
requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零',
|
||||||
|
failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零',
|
||||||
|
first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败',
|
||||||
|
locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间',
|
||||||
|
expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口',
|
||||||
|
PRIMARY KEY(kind,subject),
|
||||||
|
KEY ix_console_guard_expiry(expires_at),
|
||||||
|
CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT'))
|
||||||
|
) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态';
|
||||||
|
|
||||||
|
CREATE TABLE orbit_console_login_audit (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页',
|
||||||
|
username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在',
|
||||||
|
remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP',
|
||||||
|
user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头',
|
||||||
|
outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR',
|
||||||
|
created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC',
|
||||||
|
finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR',
|
||||||
|
retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
KEY ix_console_login_audit_time(created_at),
|
||||||
|
KEY ix_console_login_audit_pending(outcome,created_at),
|
||||||
|
CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
@@ -16,39 +17,47 @@ import (
|
|||||||
|
|
||||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
|
ValidFor time.Duration // ValidFor 初始令牌有效时长;0 为长期,定期范围 1 小时至 365 天。
|
||||||
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
|
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
|
||||||
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
|
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
|
||||||
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
|
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
|
||||||
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
|
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
|
||||||
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
|
||||||
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
||||||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||||||
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||||||
Namespace string // Namespace 命名空间名称。
|
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
||||||
Environment string // Environment 环境名称,必须显式指定。
|
Namespace string // Namespace 命名空间名称。
|
||||||
Queue string // Queue 初始队列名称。
|
Environment string // Environment 环境名称,必须显式指定。
|
||||||
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
|
Queue string // Queue 初始队列名称。
|
||||||
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
|
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
|
||||||
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
|
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
|
||||||
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
|
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
|
||||||
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
|
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
|
||||||
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
|
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
|
||||||
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
|
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
|
||||||
|
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
|
||||||
}
|
}
|
||||||
|
|
||||||
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
|
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
|
||||||
type BootstrapResult struct {
|
type BootstrapResult struct {
|
||||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
|
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
|
||||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
||||||
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
||||||
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
||||||
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
||||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 初始凭据到期时间,UTC;空为长期。
|
||||||
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
||||||
}
|
}
|
||||||
|
|
||||||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
|
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error, vault ...*credentialvault.Keyring) error {
|
||||||
|
var ring *credentialvault.Keyring
|
||||||
|
if len(vault) > 0 {
|
||||||
|
ring = vault[0]
|
||||||
|
}
|
||||||
|
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||||
|
return fmt.Errorf("validity must be zero or between one hour and one year")
|
||||||
|
}
|
||||||
for _, v := range []string{in.Namespace, in.Queue} {
|
for _, v := range []string{in.Namespace, in.Queue} {
|
||||||
if !service.ValidName(v, 128) {
|
if !service.ValidName(v, 128) {
|
||||||
return fmt.Errorf("invalid bootstrap resource name")
|
return fmt.Errorf("invalid bootstrap resource name")
|
||||||
@@ -92,6 +101,10 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.Token = secret
|
out.Token = secret
|
||||||
|
keyID, ciphertext, err := ring.Seal(out.NamespaceID, out.CredentialID, secret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||||||
if in.WorkerAccess {
|
if in.WorkerAccess {
|
||||||
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
|
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
|
||||||
@@ -154,7 +167,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.ExpiresAt = now.Add(30 * 24 * time.Hour)
|
if in.ValidFor > 0 {
|
||||||
|
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||||||
|
}
|
||||||
if in.ExistingApplicationID == "" {
|
if in.ExistingApplicationID == "" {
|
||||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
|
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -174,7 +189,7 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
|||||||
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
|
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
|
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'bootstrap',?,?,?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt, keyID, ciphertext); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
actor, err := databaseActor(ctx, tx)
|
actor, err := databaseActor(ctx, tx)
|
||||||
|
|||||||
402
internal/storage/mysql/console_applications.go
Normal file
402
internal/storage/mysql/console_applications.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
driver "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsoleApplicationStore 仅允许部署显式指定的单管理员管理跨应用接入。
|
||||||
|
// 使用运行数据库的 DML 权限,不需要迁移或数据库管理员权限。
|
||||||
|
type ConsoleApplicationStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
username string
|
||||||
|
vault *credentialvault.Keyring
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConsoleApplicationStore(db *sql.DB, username string, vault ...*credentialvault.Keyring) *ConsoleApplicationStore {
|
||||||
|
s := &ConsoleApplicationStore{db: db, username: username}
|
||||||
|
if len(vault) > 0 {
|
||||||
|
s.vault = vault[0]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil)
|
||||||
|
|
||||||
|
// admin 在实际读写事务中锁定人类账号及会话;普通 SDK Token 无对应会话,无法通过。
|
||||||
|
// 不锁业务凭据,轮换当前账号所绑定的 SDK 凭据也不会产生共享锁升级。
|
||||||
|
func (s *ConsoleApplicationStore) admin(ctx context.Context, tx *sql.Tx, token auth.Token) (string, error) {
|
||||||
|
if s.username == "" || token.NamespaceID.Validate() != nil || token.CredentialID.Validate() != nil {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
var user core.ID
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
|
||||||
|
JOIN orbit_console_users u ON u.id=s.user_id
|
||||||
|
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
|
||||||
|
AND u.enabled=TRUE AND u.username=? FOR SHARE OF u,s`, token.NamespaceID, token.CredentialID, token.Digest, s.username).Scan(&user)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
return "console:" + string(user), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) ListApplications(ctx context.Context, token auth.Token, after core.ID) (consoleauth.ApplicationPage, error) {
|
||||||
|
out := consoleauth.ApplicationPage{Items: []consoleauth.ApplicationEntry{}}
|
||||||
|
if after != "" && after.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid application cursor")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query := `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id`
|
||||||
|
args := []any{}
|
||||||
|
if after != "" {
|
||||||
|
query += ` WHERE n.id>?`
|
||||||
|
args = append(args, after)
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, query+` ORDER BY n.id LIMIT 51`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var e consoleauth.ApplicationEntry
|
||||||
|
if err := rows.Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, e)
|
||||||
|
}
|
||||||
|
if len(out.Items) > 50 {
|
||||||
|
out.Items = out.Items[:50]
|
||||||
|
out.Next = out.Items[49].NamespaceID
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth.Token, namespace core.ID) (consoleauth.ApplicationDetail, error) {
|
||||||
|
out := consoleauth.ApplicationDetail{Queues: []consoleauth.AccessQueue{}, Credentials: []consoleauth.AccessCredential{}}
|
||||||
|
if namespace.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid namespace")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e := &out.Entry
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, namespace).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=? ORDER BY (retired_at IS NOT NULL),paused,created_at,id LIMIT 101`, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var q consoleauth.AccessQueue
|
||||||
|
if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Queues = append(out.Queues, q)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.SubscriptionLimit = events.MaxSubscriptions
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, namespace).Scan(&out.ActiveSubscriptions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.TokenVaultEnabled = s.vault != nil
|
||||||
|
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var c consoleauth.AccessCredential
|
||||||
|
var expires, revoked, previous sql.NullTime
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw, &c.TokenStored); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.ExpiresAt = nullableTime(expires)
|
||||||
|
c.RevokedAt = nullableTime(revoked)
|
||||||
|
c.PreviousValidUntil = nullableTime(previous)
|
||||||
|
out.Credentials = append(out.Credentials, c)
|
||||||
|
}
|
||||||
|
if len(out.Queues) > 100 {
|
||||||
|
out.Truncated = true
|
||||||
|
out.Queues = out.Queues[:100]
|
||||||
|
}
|
||||||
|
if len(out.Credentials) > 100 {
|
||||||
|
out.Truncated = true
|
||||||
|
out.Credentials = out.Credentials[:100]
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var applicationCapabilities = map[string][]string{
|
||||||
|
"worker": {"workers:connect", "workers:consume"},
|
||||||
|
"schedules": {"schedules:read", "schedules:manage"},
|
||||||
|
"events": {"events:read", "events:manage", "events:consume"},
|
||||||
|
"workflows": {"workflows:definitions:read", "workflows:definitions:publish", "workflows:start", "workflows:read", "workflows:control", "workflows:signal", "workflows:audit:read"},
|
||||||
|
"recovery": {"tasks:review", "tasks:retry", "tasks:audit:read", "workflows:recover", "workflows:read"},
|
||||||
|
"resources": {"application:resources:read", "application:resources:manage"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions, error) {
|
||||||
|
p := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||||||
|
if len(in.Capabilities) > len(applicationCapabilities) {
|
||||||
|
return p, fault.Invalid("invalid capabilities")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
actions := map[string]bool{}
|
||||||
|
for _, group := range in.Capabilities {
|
||||||
|
list, ok := applicationCapabilities[group]
|
||||||
|
if !ok || seen[group] {
|
||||||
|
return p, fault.Invalid("invalid or duplicate capability")
|
||||||
|
}
|
||||||
|
seen[group] = true
|
||||||
|
for _, a := range list {
|
||||||
|
if !actions[a] {
|
||||||
|
p.Actions = append(p.Actions, a)
|
||||||
|
actions[a] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, p.Validate()
|
||||||
|
}
|
||||||
|
func validateApplicationInput(in consoleauth.ApplicationInput) error {
|
||||||
|
if in.ValidDays < 0 || in.ValidDays > 365 {
|
||||||
|
return fault.Invalid("valid_days must be 0 (permanent) or 1-365")
|
||||||
|
}
|
||||||
|
if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) {
|
||||||
|
return fault.Invalid("grace_days must be 0-30 for rotation only")
|
||||||
|
}
|
||||||
|
if in.Kind == "FINALIZE" && in.ValidDays != 0 {
|
||||||
|
return fault.Invalid("finalize cannot set validity")
|
||||||
|
}
|
||||||
|
if err := validCredentialReason(in.Reason); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch in.Kind {
|
||||||
|
case "CREATE":
|
||||||
|
if !service.ValidName(in.Application, 128) || !service.ValidName(in.Namespace, 128) || !service.ValidName(in.Environment, 32) || !service.ValidName(in.Queue, 128) || in.NamespaceID != "" || in.CredentialID != "" || in.ExpectedRevision != 0 {
|
||||||
|
return fault.Invalid("invalid new application fields")
|
||||||
|
}
|
||||||
|
_, err := applicationPermissions(in)
|
||||||
|
return err
|
||||||
|
case "ROTATE", "SET_EXPIRY", "FINALIZE":
|
||||||
|
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 {
|
||||||
|
return fault.Invalid("invalid rotation fields")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||||
|
out := consoleauth.ApplicationOperation{Version: 2, Input: in}
|
||||||
|
if err := validateApplicationInput(in); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now, err := dbNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.ValidDays > 0 {
|
||||||
|
out.ExpiresAt = timePtr(now.Add(time.Duration(in.ValidDays) * 24 * time.Hour))
|
||||||
|
}
|
||||||
|
if in.GraceDays > 0 {
|
||||||
|
out.PreviousValidUntil = timePtr(now.Add(time.Duration(in.GraceDays) * 24 * time.Hour))
|
||||||
|
}
|
||||||
|
out.OperationID, err = core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Kind == "CREATE" {
|
||||||
|
for _, id := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} {
|
||||||
|
*id, err = core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.NamespaceID, out.CredentialID = in.NamespaceID, in.CredentialID
|
||||||
|
var revision uint64
|
||||||
|
var revoked sql.NullTime
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT n.application_id,c.revision,c.revoked_at FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id WHERE c.namespace_id=? AND c.id=?`, in.NamespaceID, in.CredentialID).Scan(&out.ApplicationID, &revision, &revoked)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if revision != in.ExpectedRevision || revoked.Valid {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Kind == "CREATE" || in.Kind == "ROTATE" {
|
||||||
|
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||||
|
out := consoleauth.ApplicationReceipt{}
|
||||||
|
if err := validateApplicationInput(op.Input); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid fixed operation identity")
|
||||||
|
}
|
||||||
|
if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) {
|
||||||
|
return out, fault.Invalid("fixed lifetime does not match input")
|
||||||
|
}
|
||||||
|
version := op.Version
|
||||||
|
if version == 0 {
|
||||||
|
version = 1
|
||||||
|
}
|
||||||
|
if op.Input.Kind != "CREATE" {
|
||||||
|
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
|
||||||
|
return out, fault.Invalid("credential identity mismatch")
|
||||||
|
}
|
||||||
|
change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil}
|
||||||
|
receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) {
|
||||||
|
actor, err := s.admin(ctx, tx, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var app core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT application_id FROM orbit_namespaces WHERE id=?`, op.NamespaceID).Scan(&app)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if app != op.ApplicationID {
|
||||||
|
return "", fault.Invalid("application identity mismatch")
|
||||||
|
}
|
||||||
|
return actor, nil
|
||||||
|
}, s.vault)
|
||||||
|
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
|
||||||
|
}
|
||||||
|
parsed, err := auth.Parse(op.Token)
|
||||||
|
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || (version == 1 && op.ExpiresAt == nil) {
|
||||||
|
return out, fault.Invalid("invalid new credential")
|
||||||
|
}
|
||||||
|
if op.QueueID.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid queue identity")
|
||||||
|
}
|
||||||
|
permissions, err := applicationPermissions(op.Input)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(permissions)
|
||||||
|
digest, err := requestDigest(op)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID)))
|
||||||
|
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
actor, err := s.admin(ctx, tx, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at)
|
||||||
|
VALUES (?,?,'console.application.create.v1',?,?,?,'application',?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY)) ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, token.NamespaceID, op.OperationID, actor, keyDigest, digest, op.ApplicationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var stored core.Digest
|
||||||
|
var response []byte
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT request_digest,response FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='console.application.create.v1' AND scope_key=? AND key_digest=? FOR UPDATE`, token.NamespaceID, actor, keyDigest).Scan(&stored, &response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtleCompareDigest(stored, digest) != 1 {
|
||||||
|
return fault.IdempotencyConflict
|
||||||
|
}
|
||||||
|
if string(response) != "{}" {
|
||||||
|
if err := json.Unmarshal(response, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Deduplicated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now, err := dbNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
|
||||||
|
return fault.Invalid("expired or excessive credential lifetime")
|
||||||
|
}
|
||||||
|
keyID, ciphertext, err := s.vault.Seal(op.NamespaceID, op.CredentialID, op.Token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statements := []struct {
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{`INSERT INTO orbit_applications(id,name) VALUES (?,?)`, []any{op.ApplicationID, op.Input.Application}},
|
||||||
|
{`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}},
|
||||||
|
{`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}},
|
||||||
|
{`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}},
|
||||||
|
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'console-issued',?,?,?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt, keyID, ciphertext}},
|
||||||
|
}
|
||||||
|
for _, st := range statements {
|
||||||
|
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {
|
||||||
|
var e *driver.MySQLError
|
||||||
|
if errors.As(err, &e) && e.Number == 1062 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, "console.application.create", op.CredentialID, op.Input.Reason, nil, map[string]any{"application_id": op.ApplicationID, "application": op.Input.Application, "queue_id": op.QueueID, "permissions": permissions, "expires_at": op.ExpiresAt}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: 1}
|
||||||
|
receipt, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1 WHERE namespace_id=? AND id=?`, receipt, token.NamespaceID, op.OperationID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationConsoleApplications(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := Open(ctx, dsn, 10, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
username := "access-" + string(testID(t))
|
||||||
|
password := "local-test-password-12345"
|
||||||
|
var initial BootstrapResult
|
||||||
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "initial", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
login, err := NewConsoleAuthStore(db).Login(ctx, username, password, "test", "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, _ := auth.Parse(login.Token)
|
||||||
|
sdk, _ := auth.Parse(initial.Token)
|
||||||
|
store := NewConsoleApplicationStore(db, username)
|
||||||
|
if _, err := store.ListApplications(ctx, sdk, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("SDK reached admin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewConsoleApplicationStore(db, "").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatal("disabled admin accepted")
|
||||||
|
}
|
||||||
|
if _, err := NewConsoleApplicationStore(db, "different-admin").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatal("other user accepted")
|
||||||
|
}
|
||||||
|
in := consoleauth.ApplicationInput{Kind: "CREATE", Application: "created-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Capabilities: []string{"worker", "events", "schedules", "workflows"}, ValidDays: 30, Reason: "integration onboarding"}
|
||||||
|
op, err := store.PrepareApplication(ctx, admin, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 准备只生成秘密,尚未允许业务访问。
|
||||||
|
candidate, _ := auth.Parse(op.Token)
|
||||||
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err == nil {
|
||||||
|
t.Fatal("prepared token accepted before commit")
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
receipts := make([]consoleauth.ApplicationReceipt, 2)
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i := range receipts {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); receipts[i], errs[i] = store.ApplyApplication(ctx, admin, op) }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if receipts[0].Deduplicated == receipts[1].Deduplicated {
|
||||||
|
t.Fatal("concurrent original operation did not deduplicate")
|
||||||
|
}
|
||||||
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 同一操作身份不可改变请求,重名不能留下半套资源。
|
||||||
|
changed := op
|
||||||
|
changed.Input.Reason = "modified"
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
||||||
|
t.Fatalf("changed operation accepted: %v", err)
|
||||||
|
}
|
||||||
|
duplicate, err := store.PrepareApplication(ctx, admin, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, duplicate); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatalf("duplicate name accepted: %v", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_namespaces WHERE id=?`, duplicate.NamespaceID).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatal("duplicate left namespace")
|
||||||
|
}
|
||||||
|
detail, err := store.GetApplication(ctx, admin, op.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if detail.Entry.ApplicationID != op.ApplicationID || len(detail.Queues) != 1 || len(detail.Credentials) != 1 || !detail.Credentials[0].Permissions.Allows("events:consume") {
|
||||||
|
t.Fatal("incomplete onboarding")
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(detail)
|
||||||
|
if strings.Contains(string(raw), op.Token) || strings.Contains(string(raw), "secret_digest") {
|
||||||
|
t.Fatal("detail leaked credential")
|
||||||
|
}
|
||||||
|
rotation := consoleauth.ApplicationInput{Kind: "ROTATE", NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: 1, ValidDays: 90, Reason: "scheduled rotation"}
|
||||||
|
rotated, err := store.PrepareApplication(ctx, admin, rotation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
receipt, err := store.ApplyApplication(ctx, admin, rotated)
|
||||||
|
if err != nil || receipt.Revision != 2 {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
newToken, _ := auth.Parse(rotated.Token)
|
||||||
|
if NewTaskStore(db).AuthorizeSubmit(ctx, candidate) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
|
||||||
|
t.Fatal("rotation auth boundary failed")
|
||||||
|
}
|
||||||
|
replay, err := store.ApplyApplication(ctx, admin, rotated)
|
||||||
|
if err != nil || !replay.Deduplicated {
|
||||||
|
t.Fatalf("rotation replay: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.PrepareApplication(ctx, admin, rotation); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatalf("stale revision accepted: %v", err)
|
||||||
|
}
|
||||||
|
detail, err = store.GetApplication(ctx, admin, op.NamespaceID)
|
||||||
|
if err != nil || len(detail.Credentials) != 1 || detail.Credentials[0].ID != op.CredentialID || detail.Queues[0].ID != op.QueueID {
|
||||||
|
t.Fatal("rotation changed identity")
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT actor_id,CAST(`+"`after`"+` AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND action IN ('console.application.create','credential.rotate')`, op.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var actor, body string
|
||||||
|
if err := rows.Scan(&actor, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if actor != "console:"+login.User.ID || strings.Contains(body, op.Token) || strings.Contains(body, rotated.Token) {
|
||||||
|
t.Fatal("audit principal or secrecy")
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err != nil || count != 2 {
|
||||||
|
t.Fatalf("audit count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
// 登出在数据库生效后,原会话不能再次应用操作,即使持有原回执内容。
|
||||||
|
if err := NewConsoleAuthStore(db).RevokeSession(ctx, admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, rotated); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("logged out replay accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatalf("foreign keys=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,20 @@ const (
|
|||||||
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
|
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
|
||||||
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
|
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
|
||||||
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
|
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
|
||||||
type ConsoleAuthStore struct{ db *sql.DB }
|
type ConsoleAuthStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
policy consoleauth.LoginPolicy
|
||||||
|
}
|
||||||
|
|
||||||
// NewConsoleAuthStore 创建控制台认证存储。
|
// NewConsoleAuthStore 创建控制台认证存储。
|
||||||
func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} }
|
func NewConsoleAuthStore(db *sql.DB, policies ...consoleauth.LoginPolicy) *ConsoleAuthStore {
|
||||||
|
policy := consoleauth.DefaultLoginPolicy()
|
||||||
|
if len(policies) > 0 {
|
||||||
|
policy = policies[0]
|
||||||
|
}
|
||||||
|
return &ConsoleAuthStore{db: db, policy: policy}
|
||||||
|
}
|
||||||
|
func (s *ConsoleAuthStore) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||||
|
|
||||||
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
|
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
|
||||||
|
|
||||||
@@ -108,59 +118,6 @@ func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUser
|
|||||||
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
|
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。
|
|
||||||
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
|
||||||
if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 {
|
|
||||||
return ConsoleLogin{}, fault.Unauthorized
|
|
||||||
}
|
|
||||||
var user ConsoleUser
|
|
||||||
var userID core.ID
|
|
||||||
var namespace, credential core.ID
|
|
||||||
var hash string
|
|
||||||
var enabled bool
|
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id
|
|
||||||
FROM orbit_console_users u
|
|
||||||
JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id
|
|
||||||
JOIN orbit_namespaces n ON n.id=u.namespace_id
|
|
||||||
JOIN orbit_applications a ON a.id=n.application_id
|
|
||||||
WHERE u.username=? AND c.revoked_at IS NULL
|
|
||||||
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
|
||||||
AND n.enabled=TRUE AND a.enabled=TRUE`, username).
|
|
||||||
Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
|
||||||
if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
|
||||||
return ConsoleLogin{}, fault.Unauthorized
|
|
||||||
}
|
|
||||||
user.ID = string(userID)
|
|
||||||
token, secretDigest, err := auth.Issue(namespace, credential)
|
|
||||||
if err != nil {
|
|
||||||
return ConsoleLogin{}, err
|
|
||||||
}
|
|
||||||
var now time.Time
|
|
||||||
if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
|
|
||||||
return ConsoleLogin{}, err
|
|
||||||
}
|
|
||||||
now = now.UTC()
|
|
||||||
expires := now.Add(ConsoleSessionLifetime)
|
|
||||||
sessionID, err := core.NewID()
|
|
||||||
if err != nil {
|
|
||||||
return ConsoleLogin{}, err
|
|
||||||
}
|
|
||||||
if len(userAgent) > 512 {
|
|
||||||
userAgent = userAgent[:512]
|
|
||||||
}
|
|
||||||
if len(remoteAddr) > 64 {
|
|
||||||
remoteAddr = remoteAddr[:64]
|
|
||||||
}
|
|
||||||
_, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions
|
|
||||||
(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr)
|
|
||||||
VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`,
|
|
||||||
sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return ConsoleLogin{}, err
|
|
||||||
}
|
|
||||||
return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
|
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
|
||||||
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
|
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
|
||||||
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
|
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
|
||||||
|
|||||||
85
internal/storage/mysql/console_credential_tokens.go
Normal file
85
internal/storage/mysql/console_credential_tokens.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ consoleauth.CredentialTokens = (*ConsoleApplicationStore)(nil)
|
||||||
|
|
||||||
|
// AccessCredentialToken 认证、版本核对、密文读取/补存和审计在同一事务完成。
|
||||||
|
// 导入相同当前秘密可重复调用,不变更认证 revision、到期时间或旧代过渡截止。
|
||||||
|
func (s *ConsoleApplicationStore) AccessCredentialToken(ctx context.Context, session auth.Token, namespace, credential core.ID, in consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
|
||||||
|
var out consoleauth.CredentialToken
|
||||||
|
if namespace.Validate() != nil || credential.Validate() != nil || in.ExpectedRevision == 0 || (!importCurrent && in.Token != "") {
|
||||||
|
return out, fault.Invalid("invalid credential token request")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
actor, err := s.admin(ctx, tx, session)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.vault == nil {
|
||||||
|
return fault.New("UNAVAILABLE", "Token 保管密钥尚未配置,请联系部署管理员")
|
||||||
|
}
|
||||||
|
var revision uint64
|
||||||
|
var revoked sql.NullTime
|
||||||
|
var digest core.Digest
|
||||||
|
var keyID sql.NullString
|
||||||
|
var ciphertext []byte
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT revision,revoked_at,secret_digest,secret_key_id,secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision, &revoked, &digest, &keyID, &ciphertext)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if revision != in.ExpectedRevision || revoked.Valid {
|
||||||
|
return fault.New("STATE_CONFLICT", "凭据已变更或撤销,请刷新后重试")
|
||||||
|
}
|
||||||
|
raw := in.Token
|
||||||
|
action := "console.credential.token.view"
|
||||||
|
if !importCurrent {
|
||||||
|
if !keyID.Valid {
|
||||||
|
return fault.New("STATE_CONFLICT", "此历史凭据尚未保管 Token,请先导入当前 Token")
|
||||||
|
}
|
||||||
|
raw, err = s.vault.Open(namespace, credential, keyID.String, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return fault.New("UNAVAILABLE", "Token 解密失败,请检查保管密钥文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, err := auth.Parse(raw)
|
||||||
|
if err != nil || parsed.NamespaceID != namespace || parsed.CredentialID != credential || subtleCompareDigest(parsed.Digest, digest) != 1 {
|
||||||
|
return fault.Invalid("Token 与当前凭据不匹配;不能导入过渡期旧 Token")
|
||||||
|
}
|
||||||
|
if importCurrent {
|
||||||
|
action = "console.credential.token.import"
|
||||||
|
id, encrypted, err := s.vault.Seal(namespace, credential, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET secret_key_id=?,secret_ciphertext=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, id, encrypted, namespace, credential); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operation, err := core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := appendAuditActor(ctx, tx, namespace, actor, operation, action, credential, "administrator access to current integration configuration", nil, map[string]any{"revision": revision}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = consoleauth.CredentialToken{NamespaceID: namespace, CredentialID: credential, Revision: revision, Token: raw}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// 包括提交结果未知:只有确认审计提交成功,才允许向调用方交付秘密。
|
||||||
|
if err != nil {
|
||||||
|
return consoleauth.CredentialToken{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
337
internal/storage/mysql/console_login.go
Normal file
337
internal/storage/mysql/console_login.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 不存在/停用的账号仍走相同成本校验,限流先于昂贵的密码计算。
|
||||||
|
var consoleDummyHash = func() []byte {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("orbit-dummy-login-password"), consolePasswordCost)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}()
|
||||||
|
|
||||||
|
// loginGuard 对应持久限流行;所有字段的时间均由 MySQL UTC 时钟决定。
|
||||||
|
type loginGuard struct {
|
||||||
|
Kind string // Kind 为 IP 或 ACCOUNT,跨维度固定先 IP 后账号加锁。
|
||||||
|
Subject string // Subject 为规范身份的 SHA-256,不含秘密。
|
||||||
|
WindowStart time.Time // WindowStart 请求窗口起点。
|
||||||
|
Requests int // Requests 已受理请求数,拒绝时不增加以免溢出。
|
||||||
|
Failures int // Failures 连续失败计数。
|
||||||
|
FirstFailed sql.NullTime // FirstFailed 失败窗口起点,空表示无失败。
|
||||||
|
LockedUntil sql.NullTime // LockedUntil 锁定截止,空表示未锁定。
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginSubject(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
func loginText(s string, max int) string {
|
||||||
|
s = strings.ToValidUTF8(s, "")
|
||||||
|
if len(s) > max {
|
||||||
|
s = s[:max]
|
||||||
|
for !utf8.ValidString(s) {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func loginNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||||
|
var now time.Time
|
||||||
|
err := tx.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now)
|
||||||
|
return now.UTC(), err
|
||||||
|
}
|
||||||
|
func loadLoginGuard(ctx context.Context, tx *sql.Tx, kind, key string, now time.Time) (loginGuard, error) {
|
||||||
|
g := loginGuard{Kind: kind, Subject: key}
|
||||||
|
_, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_guards(kind,subject,window_start,expires_at) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE subject=VALUES(subject)`, kind, key, now, now.Add(48*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT window_start,requests,failures,first_failed_at,locked_until FROM orbit_console_login_guards WHERE kind=? AND subject=? FOR UPDATE`, kind, key).Scan(&g.WindowStart, &g.Requests, &g.Failures, &g.FirstFailed, &g.LockedUntil)
|
||||||
|
return g, err
|
||||||
|
}
|
||||||
|
func saveLoginGuard(ctx context.Context, tx *sql.Tx, g loginGuard, now time.Time) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_guards SET window_start=?,requests=?,failures=?,first_failed_at=?,locked_until=?,expires_at=? WHERE kind=? AND subject=?`, g.WindowStart, g.Requests, g.Failures, g.FirstFailed, g.LockedUntil, now.Add(48*time.Hour), g.Kind, g.Subject)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (g *loginGuard) resetExpired(now time.Time, p consoleauth.LoginPolicy) {
|
||||||
|
if !now.Before(g.WindowStart.Add(p.RateWindow)) {
|
||||||
|
g.WindowStart = now
|
||||||
|
g.Requests = 0
|
||||||
|
}
|
||||||
|
if g.LockedUntil.Valid && !now.Before(g.LockedUntil.Time) {
|
||||||
|
g.LockedUntil = sql.NullTime{}
|
||||||
|
g.FirstFailed = sql.NullTime{}
|
||||||
|
g.Failures = 0
|
||||||
|
}
|
||||||
|
if !g.LockedUntil.Valid && g.FirstFailed.Valid && !now.Before(g.FirstFailed.Time.Add(p.FailureWindow)) {
|
||||||
|
g.FirstFailed = sql.NullTime{}
|
||||||
|
g.Failures = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 先在短事务中占用账号/IP 请求预算,再在事务外计算 bcrypt。
|
||||||
|
// 最终事务重新锁定账号保护行,失败、锁定、成功会话及审计原子提交;并发校验不能穿过已生效的锁定。
|
||||||
|
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return ConsoleLogin{}, errors.New("console authentication unavailable")
|
||||||
|
}
|
||||||
|
if err := s.policy.Validate(); err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
if username == "" || len(username) > 128 || !utf8.ValidString(username) || password == "" || len(password) > 1024 {
|
||||||
|
return ConsoleLogin{}, fault.Unauthorized
|
||||||
|
}
|
||||||
|
ip, err := netip.ParseAddr(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleLogin{}, fault.Invalid("invalid login source address")
|
||||||
|
}
|
||||||
|
remoteAddr = ip.Unmap().String()
|
||||||
|
userAgent = loginText(userAgent, 512)
|
||||||
|
var user ConsoleUser
|
||||||
|
var userID, namespace, credential core.ID
|
||||||
|
var hash string
|
||||||
|
var enabled bool
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,enabled,namespace_id,credential_id FROM orbit_console_users WHERE username=?`, username).Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
exists := err == nil
|
||||||
|
// 使用已存在账号的 ID,避免 Unicode 等价拼写绕过 MySQL 登录名匹配对应的限额。
|
||||||
|
accountKey := loginSubject("unknown:" + username)
|
||||||
|
if exists {
|
||||||
|
accountKey = loginSubject("user:" + string(userID))
|
||||||
|
}
|
||||||
|
var attemptID int64
|
||||||
|
var blocked *consoleauth.LoginBlocked
|
||||||
|
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
blocked = nil
|
||||||
|
now, err := loginNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ipGuard, err := loadLoginGuard(ctx, tx, "IP", loginSubject(remoteAddr), now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ipGuard.resetExpired(now, s.policy)
|
||||||
|
outcome := "PENDING"
|
||||||
|
var retryAt *time.Time
|
||||||
|
reject := func(reason string, until time.Time, locked bool) {
|
||||||
|
outcome = reason
|
||||||
|
retryAt = &until
|
||||||
|
blocked = &consoleauth.LoginBlocked{Locked: locked, RetryAfter: until.Sub(now)}
|
||||||
|
}
|
||||||
|
if ipGuard.Requests >= s.policy.IPLimit {
|
||||||
|
reject("IP_LIMIT", ipGuard.WindowStart.Add(s.policy.RateWindow), false)
|
||||||
|
} else {
|
||||||
|
ipGuard.Requests++
|
||||||
|
account, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
account.resetExpired(now, s.policy)
|
||||||
|
if account.LockedUntil.Valid {
|
||||||
|
reject("LOCKED", account.LockedUntil.Time, true)
|
||||||
|
} else if account.Requests >= s.policy.AccountLimit {
|
||||||
|
reject("ACCOUNT_LIMIT", account.WindowStart.Add(s.policy.RateWindow), false)
|
||||||
|
} else {
|
||||||
|
account.Requests++
|
||||||
|
}
|
||||||
|
if err = saveLoginGuard(ctx, tx, account, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = saveLoginGuard(ctx, tx, ipGuard, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var finished *time.Time
|
||||||
|
if blocked != nil {
|
||||||
|
finished = &now
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_login_audit(username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at) VALUES(?,?,?,?,?,?,?)`, username, remoteAddr, userAgent, outcome, now, finished, retryAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attemptID, err = result.LastInsertId()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
if blocked != nil {
|
||||||
|
return ConsoleLogin{}, blocked
|
||||||
|
}
|
||||||
|
checkHash := consoleDummyHash
|
||||||
|
if exists && enabled {
|
||||||
|
checkHash = []byte(hash)
|
||||||
|
}
|
||||||
|
valid := bcrypt.CompareHashAndPassword(checkHash, []byte(password)) == nil && exists && enabled
|
||||||
|
var out ConsoleLogin
|
||||||
|
var rejected error
|
||||||
|
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
out = ConsoleLogin{}
|
||||||
|
rejected = nil
|
||||||
|
now, err := loginNow(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
guard, err := loadLoginGuard(ctx, tx, "ACCOUNT", accountKey, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
guard.resetExpired(now, s.policy)
|
||||||
|
outcome := "INVALID"
|
||||||
|
var retryAt *time.Time
|
||||||
|
if guard.LockedUntil.Valid {
|
||||||
|
outcome = "LOCKED"
|
||||||
|
until := guard.LockedUntil.Time
|
||||||
|
retryAt = &until
|
||||||
|
rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: until.Sub(now)}
|
||||||
|
} else {
|
||||||
|
stillValid := valid
|
||||||
|
if stillValid {
|
||||||
|
var currentHash string
|
||||||
|
// 密码/账号/关联权限在最终事务中复查;停用、换密和失效凭据不能签发新会话。
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT u.password_hash FROM orbit_console_users u JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id JOIN orbit_namespaces n ON n.id=u.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE u.id=? AND u.namespace_id=? AND u.credential_id=? AND u.enabled=TRUE AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, userID, namespace, credential).Scan(¤tHash)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stillValid = err == nil && currentHash == hash
|
||||||
|
}
|
||||||
|
if stillValid {
|
||||||
|
token, digest, err := auth.Issue(namespace, credential)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sessionID, err := core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expires := now.Add(ConsoleSessionLifetime)
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_console_sessions(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr) VALUES(?,?,?,?,?,?,?,?,?)`, sessionID, userID, namespace, credential, digest, expires, now, userAgent, remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.ID = string(userID)
|
||||||
|
out = ConsoleLogin{User: user, Token: token, ExpiresAt: expires}
|
||||||
|
outcome = "SUCCESS"
|
||||||
|
guard.Failures = 0
|
||||||
|
guard.FirstFailed = sql.NullTime{}
|
||||||
|
guard.LockedUntil = sql.NullTime{}
|
||||||
|
} else {
|
||||||
|
rejected = fault.Unauthorized
|
||||||
|
if !guard.FirstFailed.Valid {
|
||||||
|
guard.FirstFailed = sql.NullTime{Time: now, Valid: true}
|
||||||
|
}
|
||||||
|
guard.Failures++
|
||||||
|
if guard.Failures >= s.policy.FailureLimit {
|
||||||
|
until := now.Add(s.policy.LockDuration)
|
||||||
|
guard.LockedUntil = sql.NullTime{Time: until, Valid: true}
|
||||||
|
outcome = "LOCKED"
|
||||||
|
retryAt = &until
|
||||||
|
rejected = &consoleauth.LoginBlocked{Locked: true, RetryAfter: s.policy.LockDuration}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = saveLoginGuard(ctx, tx, guard, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE orbit_console_login_audit SET outcome=?,finished_at=?,retry_at=? WHERE id=? AND outcome='PENDING'`, outcome, now, retryAt, attemptID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return errors.New("login audit result not confirmed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
return out, rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupLoginSecurity 每批最多清理 500 行,过期时间有索引,不触碰业务审计或有效锁定。
|
||||||
|
func (s *ConsoleAuthStore) CleanupLoginSecurity(ctx context.Context) error {
|
||||||
|
if err := s.policy.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, q := range []struct {
|
||||||
|
sql string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{`UPDATE orbit_console_login_audit SET outcome='ERROR',finished_at=UTC_TIMESTAMP(6) WHERE outcome='PENDING' AND created_at<UTC_TIMESTAMP(6)-INTERVAL 10 MINUTE ORDER BY created_at LIMIT 500`, nil},
|
||||||
|
{`DELETE FROM orbit_console_login_guards WHERE expires_at<UTC_TIMESTAMP(6) ORDER BY expires_at LIMIT 500`, nil},
|
||||||
|
{`DELETE FROM orbit_console_login_audit WHERE created_at<UTC_TIMESTAMP(6)-INTERVAL ? DAY ORDER BY created_at LIMIT 500`, []any{s.policy.AuditDays}},
|
||||||
|
} {
|
||||||
|
if _, err := s.db.ExecContext(ctx, q.sql, q.args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLoginAudit 仅沿用部署指定的单管理员权限;普通会话及 SDK Token 不能读取全局登录记录。
|
||||||
|
func (s *ConsoleApplicationStore) ListLoginAudit(ctx context.Context, token auth.Token, before string) (consoleauth.LoginAuditPage, error) {
|
||||||
|
out := consoleauth.LoginAuditPage{Items: []consoleauth.LoginAuditEntry{}}
|
||||||
|
var cursor uint64
|
||||||
|
if before != "" {
|
||||||
|
var err error
|
||||||
|
cursor, err = strconv.ParseUint(before, 10, 64)
|
||||||
|
if err != nil || cursor == 0 {
|
||||||
|
return out, fault.Invalid("invalid login audit cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := s.admin(ctx, tx, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := `SELECT id,username,remote_ip,user_agent,outcome,created_at,finished_at,retry_at FROM orbit_console_login_audit`
|
||||||
|
args := []any{}
|
||||||
|
if cursor != 0 {
|
||||||
|
q += ` WHERE id<?`
|
||||||
|
args = append(args, cursor)
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, q+` ORDER BY id DESC LIMIT 11`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out.Items = []consoleauth.LoginAuditEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e consoleauth.LoginAuditEntry
|
||||||
|
if err = rows.Scan(&e.ID, &e.Username, &e.RemoteIP, &e.UserAgent, &e.Outcome, &e.CreatedAt, &e.FinishedAt, &e.RetryAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, e)
|
||||||
|
}
|
||||||
|
if len(out.Items) > 10 {
|
||||||
|
out.Items = out.Items[:10]
|
||||||
|
out.Next = strconv.FormatUint(out.Items[9].ID, 10)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
262
internal/storage/mysql/console_login_integration_test.go
Normal file
262
internal/storage/mysql/console_login_integration_test.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationConsoleLoginProtection(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := Open(ctx, dsn, 16, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
username := "login-" + string(testID(t))
|
||||||
|
password := "login-protection-test-12345"
|
||||||
|
var boot BootstrapResult
|
||||||
|
if err = Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { boot = v; return nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := consoleauth.DefaultLoginPolicy()
|
||||||
|
p.FailureLimit = 3
|
||||||
|
p.IPLimit = 1000
|
||||||
|
p.AccountLimit = 100
|
||||||
|
p.LockDuration = time.Second
|
||||||
|
first, second := NewConsoleAuthStore(db, p), NewConsoleAuthStore(db, p)
|
||||||
|
exec := func(q string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var userID string
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT LOWER(HEX(id)) FROM orbit_console_users WHERE username=?`, username).Scan(&userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := loginSubject("user:" + userID)
|
||||||
|
clear := func() { exec(`DELETE FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key) }
|
||||||
|
login := func(s *ConsoleAuthStore, pass, ip string) error {
|
||||||
|
t.Helper()
|
||||||
|
_, err := s.Login(ctx, username, pass, "login-regression", ip)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expectBlocked := func(err error, locked bool) {
|
||||||
|
t.Helper()
|
||||||
|
var b *consoleauth.LoginBlocked
|
||||||
|
if !errors.As(err, &b) || b.Locked != locked || b.RetryAfter <= 0 {
|
||||||
|
t.Fatalf("expected block locked=%v got %v", locked, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Run("failure_success_reset_cross_node_and_expiry", func(t *testing.T) {
|
||||||
|
if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = login(second, password, "192.0.2.11"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err = login(first, "wrong", "192.0.2.10"); !errors.Is(err, fault.Unauthorized) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectBlocked(login(second, "wrong", "192.0.2.11"), true)
|
||||||
|
var until, after time.Time
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&until); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectBlocked(login(NewConsoleAuthStore(db, p), password, "192.0.2.12"), true)
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT locked_until FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&after); err != nil || !until.Equal(after) {
|
||||||
|
t.Fatal("lock was extended", err)
|
||||||
|
}
|
||||||
|
// 使用数据库自身时钟等待真实截止,重建存储实例不能提前解除锁定。
|
||||||
|
for {
|
||||||
|
var elapsed bool
|
||||||
|
if err = db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)>=?", until).Scan(&elapsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if elapsed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
case <-time.After(25 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = login(second, password, "192.0.2.12"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("concurrent_failures_serialize_and_lock", func(t *testing.T) {
|
||||||
|
clear()
|
||||||
|
policy := p
|
||||||
|
policy.LockDuration = time.Minute
|
||||||
|
a, b := NewConsoleAuthStore(db, policy), NewConsoleAuthStore(db, policy)
|
||||||
|
results := make(chan error, 6)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
s := a
|
||||||
|
if i%2 == 1 {
|
||||||
|
s = b
|
||||||
|
}
|
||||||
|
results <- login(s, "wrong", "192.0.2.20")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
invalid, blocked := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
var v *consoleauth.LoginBlocked
|
||||||
|
if errors.As(err, &v) && v.Locked {
|
||||||
|
blocked++
|
||||||
|
} else if errors.Is(err, fault.Unauthorized) {
|
||||||
|
invalid++
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invalid != 2 || blocked != 4 {
|
||||||
|
t.Fatalf("invalid=%d blocked=%d", invalid, blocked)
|
||||||
|
}
|
||||||
|
expectBlocked(login(a, password, "192.0.2.21"), true)
|
||||||
|
var count int
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT failures FROM orbit_console_login_guards WHERE kind='ACCOUNT' AND subject=?`, key).Scan(&count); err != nil || count != 3 {
|
||||||
|
t.Fatalf("failure count=%d %v", count, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("account_and_ip_limits", func(t *testing.T) {
|
||||||
|
clear()
|
||||||
|
policy := p
|
||||||
|
policy.AccountLimit = 2
|
||||||
|
s := NewConsoleAuthStore(db, policy)
|
||||||
|
for _, ip := range []string{"192.0.2.30", "192.0.2.31"} {
|
||||||
|
if err = login(s, password, ip); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectBlocked(login(NewConsoleAuthStore(db, policy), password, "192.0.2.32"), false)
|
||||||
|
clear()
|
||||||
|
policy.IPLimit = 2
|
||||||
|
s = NewConsoleAuthStore(db, policy)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, err = s.Login(ctx, "unknown-"+string(testID(t)), "wrong", "test", "192.0.2.40")
|
||||||
|
if !errors.Is(err, fault.Unauthorized) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectBlocked(login(s, password, "192.0.2.40"), false)
|
||||||
|
exec(`UPDATE orbit_console_login_guards SET window_start=UTC_TIMESTAMP(6)-INTERVAL 2 MINUTE WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.40"))
|
||||||
|
if err = login(s, password, "192.0.2.40"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unknown_account_and_failure_window", func(t *testing.T) {
|
||||||
|
unknown := "unknown-" + string(testID(t))
|
||||||
|
policy := p
|
||||||
|
policy.FailureLimit = 2
|
||||||
|
s := NewConsoleAuthStore(db, policy)
|
||||||
|
_, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50")
|
||||||
|
if !errors.Is(err, fault.Unauthorized) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = s.Login(ctx, unknown, "wrong", "test", "192.0.2.50")
|
||||||
|
expectBlocked(err, true)
|
||||||
|
clear()
|
||||||
|
if err = login(first, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec(`UPDATE orbit_console_login_guards SET failures=2,first_failed_at=UTC_TIMESTAMP(6)-INTERVAL 1 HOUR WHERE kind='ACCOUNT' AND subject=?`, key)
|
||||||
|
if err = login(second, "wrong", "192.0.2.51"); !errors.Is(err, fault.Unauthorized) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("audit_failure_cannot_issue_session", func(t *testing.T) {
|
||||||
|
clear()
|
||||||
|
var before, after int
|
||||||
|
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&before)
|
||||||
|
trigger := "login_audit_" + string(testID(t))
|
||||||
|
exec(`CREATE TRIGGER ` + trigger + ` BEFORE UPDATE ON orbit_console_login_audit FOR EACH ROW BEGIN IF NEW.username='` + username + `' AND NEW.outcome='SUCCESS' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test audit unavailable'; END IF; END`)
|
||||||
|
defer exec("DROP TRIGGER " + trigger)
|
||||||
|
if err = login(first, password, "192.0.2.60"); err == nil {
|
||||||
|
t.Fatal("audit failure issued session")
|
||||||
|
}
|
||||||
|
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_sessions WHERE user_id=UNHEX(?)`, userID).Scan(&after)
|
||||||
|
if before != after {
|
||||||
|
t.Fatal("session survived failed audit")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("audit_admin_pagination_secrets_and_cleanup", func(t *testing.T) {
|
||||||
|
clear()
|
||||||
|
result, err := first.Login(ctx, username, password, "test", "192.0.2.70")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, _ := auth.Parse(result.Token)
|
||||||
|
sdk, _ := auth.Parse(boot.Token)
|
||||||
|
store := NewConsoleApplicationStore(db, username)
|
||||||
|
if _, err = store.ListLoginAudit(ctx, sdk, ""); !errors.Is(err, fault.Forbidden) {
|
||||||
|
t.Fatal("SDK read login audit", err)
|
||||||
|
}
|
||||||
|
if _, err = NewConsoleApplicationStore(db, "another-admin").ListLoginAudit(ctx, session, ""); !errors.Is(err, fault.Forbidden) {
|
||||||
|
t.Fatal("non-admin read audit", err)
|
||||||
|
}
|
||||||
|
page, err := store.ListLoginAudit(ctx, session, "")
|
||||||
|
if err != nil || len(page.Items) != 10 || page.Next == "" {
|
||||||
|
t.Fatalf("page: %+v %v", page, err)
|
||||||
|
}
|
||||||
|
next, err := store.ListLoginAudit(ctx, session, page.Next)
|
||||||
|
if err != nil || len(next.Items) == 0 || next.Items[0].ID >= page.Items[9].ID {
|
||||||
|
t.Fatal("audit pagination", err)
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT CONCAT(username,remote_ip,user_agent,outcome) FROM orbit_console_login_audit WHERE username=?`, username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
if err = rows.Scan(&value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(value, password) || strings.Contains(value, result.Token) {
|
||||||
|
t.Fatal("audit contains secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 91 DAY WHERE username=? AND outcome='INVALID'`, username)
|
||||||
|
exec(`UPDATE orbit_console_login_audit SET created_at=UTC_TIMESTAMP(6)-INTERVAL 11 MINUTE WHERE username=? AND outcome='PENDING'`, username)
|
||||||
|
exec(`UPDATE orbit_console_login_guards SET expires_at=UTC_TIMESTAMP(6)-INTERVAL 1 DAY WHERE kind='IP' AND subject=?`, loginSubject("192.0.2.70"))
|
||||||
|
if err = first.CleanupLoginSecurity(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_console_login_audit WHERE username=? AND (outcome='PENDING' OR created_at<UTC_TIMESTAMP(6)-INTERVAL 90 DAY)`, username).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatal("audit cleanup", n, err)
|
||||||
|
}
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||||
|
t.Fatal("foreign keys", n, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
135
internal/storage/mysql/console_scope.go
Normal file
135
internal/storage/mysql/console_scope.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleScopeActor 在业务读写的同一事务校验原会话、部署指定管理员及目标应用。
|
||||||
|
// 仅持共享身份锁,之后沿用业务既有的额度→队列→资源锁序。
|
||||||
|
func consoleScopeActor(ctx context.Context, tx *sql.Tx, t auth.Token) (core.ID, error) {
|
||||||
|
if t.Console == nil || t.Console.Username == "" || t.Console.SessionNamespaceID.Validate() != nil || t.NamespaceID.Validate() != nil {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
var user core.ID
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
|
||||||
|
JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id
|
||||||
|
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
|
||||||
|
JOIN orbit_namespaces n ON n.id=s.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
||||||
|
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE AND u.username=?
|
||||||
|
AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||||
|
AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.Console.SessionNamespaceID, t.CredentialID, t.Digest, t.Console.Username).Scan(&user)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if t.Console.UserID != "" && user != t.Console.UserID {
|
||||||
|
return "", fault.Forbidden
|
||||||
|
}
|
||||||
|
var id core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT n.id FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.NamespaceID).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fault.NotFound
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleScopePermissions() auth.Permissions {
|
||||||
|
return auth.Permissions{Actions: []string{"tasks:read", "tasks:cancel", "tasks:review", "tasks:retry", "tasks:audit:read", "queues:read", "queues:manage", "schedules:read", "schedules:manage", "events:read", "events:manage", "workflows:read", "workflows:control", "workflows:recover", "workflows:audit:read"}, Queues: []string{"*"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) ResolveScope(ctx context.Context, t auth.Token, target core.ID) (auth.Token, error) {
|
||||||
|
if target.Validate() != nil {
|
||||||
|
return auth.Token{}, fault.Invalid("invalid console namespace")
|
||||||
|
}
|
||||||
|
out := t
|
||||||
|
out.NamespaceID = target
|
||||||
|
out.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: s.username}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
user, err := consoleScopeActor(ctx, tx, out)
|
||||||
|
out.Console.UserID = user
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, after core.ID) (consoleauth.Scope, error) {
|
||||||
|
out := consoleauth.Scope{Queues: []consoleauth.AccessQueue{}}
|
||||||
|
if after != "" && after.Validate() != nil {
|
||||||
|
return out, fault.Invalid("invalid queue cursor")
|
||||||
|
}
|
||||||
|
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
p, err := authenticate(ctx, tx, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Console != nil {
|
||||||
|
out.CanSwitch = true
|
||||||
|
} else {
|
||||||
|
// 元数据接口也必须核验真实控制台会话,SDK 不能读取此入口。
|
||||||
|
var user core.ID
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT u.id,(u.username=?) FROM orbit_console_sessions s JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, s.username, t.NamespaceID, t.CredentialID, t.Digest).Scan(&user, &out.CanSwitch)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.Unauthorized
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e := &out.Entry
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, t.NamespaceID).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query := `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=?`
|
||||||
|
args := []any{t.NamespaceID}
|
||||||
|
if after != "" {
|
||||||
|
query += ` AND id>?`
|
||||||
|
args = append(args, after)
|
||||||
|
}
|
||||||
|
all := false
|
||||||
|
for _, q := range p.Queues {
|
||||||
|
if q == "*" {
|
||||||
|
all = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !all {
|
||||||
|
query += " AND id IN ("
|
||||||
|
for i, q := range p.Queues {
|
||||||
|
if i > 0 {
|
||||||
|
query += ","
|
||||||
|
}
|
||||||
|
query += "?"
|
||||||
|
args = append(args, core.ID(q))
|
||||||
|
}
|
||||||
|
query += ")"
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, query+` ORDER BY id LIMIT 101`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var q consoleauth.AccessQueue
|
||||||
|
if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Queues = append(out.Queues, q)
|
||||||
|
}
|
||||||
|
if len(out.Queues) > 100 {
|
||||||
|
out.Queues = out.Queues[:100]
|
||||||
|
out.Next = out.Queues[99].ID
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
137
internal/storage/mysql/console_scope_integration_test.go
Normal file
137
internal/storage/mysql/console_scope_integration_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationConsoleScope(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := Open(ctx, dsn, 8, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
username := "scope-" + string(testID(t))
|
||||||
|
password := "test-password-for-scopes-123"
|
||||||
|
var source, target BootstrapResult
|
||||||
|
for i, dest := range []*BootstrapResult{&source, &target} {
|
||||||
|
in := BootstrapInput{Application: string(testID(t)), Namespace: "default", Environment: "test", Queue: "scope-queue"}
|
||||||
|
if i == 0 {
|
||||||
|
in.ConsoleUsername = username
|
||||||
|
in.ConsolePassword = password
|
||||||
|
}
|
||||||
|
if err = Bootstrap(ctx, db, in, func(v BootstrapResult) error { *dest = v; return nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts := NewConsoleAuthStore(db)
|
||||||
|
login, err := accounts.Login(ctx, username, password, "scope-test", "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, _ := auth.Parse(login.Token)
|
||||||
|
sdk, _ := auth.Parse(source.Token)
|
||||||
|
scopes := NewConsoleApplicationStore(db, username)
|
||||||
|
initial, err := scopes.GetScope(ctx, session, "")
|
||||||
|
if err != nil || initial.Entry.NamespaceID != source.NamespaceID || !initial.CanSwitch {
|
||||||
|
t.Fatalf("initial scope: %+v %v", initial, err)
|
||||||
|
}
|
||||||
|
if _, err = scopes.GetScope(ctx, sdk, ""); fault.Code(err) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatalf("SDK scope metadata: %v", err)
|
||||||
|
}
|
||||||
|
// 普通账号的队列白名单使用 BINARY ID 查询,不能以字符串错误比较成空列表。
|
||||||
|
if _, err = db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_OBJECT('actions',JSON_ARRAY('queues:read'),'queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(source.QueueID), source.NamespaceID, source.CredentialID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
limited, err := NewConsoleApplicationStore(db, "another-admin").GetScope(ctx, session, "")
|
||||||
|
if err != nil || limited.CanSwitch || len(limited.Queues) != 1 || limited.Queues[0].ID != source.QueueID {
|
||||||
|
t.Fatalf("limited scope: %+v %v", limited, err)
|
||||||
|
}
|
||||||
|
last, err := scopes.GetScope(ctx, session, source.QueueID)
|
||||||
|
if err != nil || len(last.Queues) != 0 || last.Next != "" {
|
||||||
|
t.Fatalf("queue cursor: %+v %v", last, err)
|
||||||
|
}
|
||||||
|
if _, err = scopes.ResolveScope(ctx, sdk, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("SDK override: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"", "another-admin"} {
|
||||||
|
if _, err = NewConsoleApplicationStore(db, name).ResolveScope(ctx, session, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("unconfigured admin: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scoped, err := scopes.ResolveScope(ctx, session, target.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tasks := NewTaskStore(db)
|
||||||
|
if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = tasks.GetQueue(ctx, scoped, source.QueueID); fault.Code(err) != "NOT_FOUND" {
|
||||||
|
t.Fatalf("foreign queue: %v", err)
|
||||||
|
}
|
||||||
|
if err = tasks.AuthorizeSubmit(ctx, scoped); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("scope must not create work: %v", err)
|
||||||
|
}
|
||||||
|
got, err := scopes.GetScope(ctx, scoped, "")
|
||||||
|
if err != nil || got.Entry.ApplicationID != target.ApplicationID || len(got.Queues) != 1 || got.Queues[0].ID != target.QueueID {
|
||||||
|
t.Fatalf("target metadata: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
// 完整应用/队列名称从数据库读取,包含特殊字符也不改变授权身份。
|
||||||
|
policy := NewRetentionPolicyStore(db)
|
||||||
|
in := retention.UpdateInput{ExpectedRevision: 0, Enabled: false, IdempotencyDays: 120, TaskHistoryDays: 400, WorkflowHistoryDays: 400, EventLogDays: 180, DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555, Reason: "scope policy test"}
|
||||||
|
saved, err := policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in)
|
||||||
|
if err != nil || saved.NamespaceID != target.NamespaceID {
|
||||||
|
t.Fatalf("policy: %+v %v", saved, err)
|
||||||
|
}
|
||||||
|
if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
original, err := policy.GetRetentionPolicy(ctx, source.NamespaceID)
|
||||||
|
if err != nil || original.Revision != 0 {
|
||||||
|
t.Fatalf("changed original scope: %+v %v", original, err)
|
||||||
|
}
|
||||||
|
var actor string
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT actor_id FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'`, target.NamespaceID).Scan(&actor); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if actor != scoped.ActorID() {
|
||||||
|
t.Fatalf("audit actor: %s", actor)
|
||||||
|
}
|
||||||
|
// 解析后停用目标应用/退出会话,旧的内存范围不能继续授权。
|
||||||
|
if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, target.ApplicationID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); fault.Code(err) != "NOT_FOUND" {
|
||||||
|
t.Fatalf("disabled target: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=TRUE WHERE id=?`, target.ApplicationID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accounts.RevokeSession(ctx, session); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.ExpectedRevision = 1
|
||||||
|
if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "after-logout", in); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatalf("stale scope write: %v", err)
|
||||||
|
}
|
||||||
|
var foreignKeys int
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||||
|
t.Fatalf("foreign keys %d %v", foreignKeys, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) {
|
|||||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "commit boundary"})
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, GraceFor: time.Hour, Reason: "commit boundary"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,14 @@ func TestIntegrationCredentialCommitFaults(t *testing.T) {
|
|||||||
if after {
|
if after {
|
||||||
wantRevision = 2
|
wantRevision = 2
|
||||||
}
|
}
|
||||||
|
oldToken, _ := auth.Parse(initial.Token)
|
||||||
|
newToken, _ := auth.Parse(op.Token)
|
||||||
|
if err := NewTaskStore(db).AuthorizeSubmit(ctx, oldToken); err != nil {
|
||||||
|
t.Fatal("commit ambiguity invalidated old token", err)
|
||||||
|
}
|
||||||
|
if accepted := NewTaskStore(db).AuthorizeSubmit(ctx, newToken) == nil; accepted != after {
|
||||||
|
t.Fatal("commit boundary accepted wrong generation")
|
||||||
|
}
|
||||||
if status.Revision != wantRevision {
|
if status.Revision != wantRevision {
|
||||||
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
|
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
|
||||||
}
|
}
|
||||||
|
|||||||
283
internal/storage/mysql/credential_overlap_integration_test.go
Normal file
283
internal/storage/mysql/credential_overlap_integration_test.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func overlapDB(t *testing.T) (context.Context, *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
db, err := Open(ctx, dsn, 12, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ctx, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCredentialOverlap(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
old := f.token
|
||||||
|
// 先使用有限有效期,再原地改长期;在途授权、秘密及绑定均不改变。
|
||||||
|
change := func(action string, revision uint64, grace time.Duration) CredentialOperation {
|
||||||
|
t.Helper()
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: revision, Action: action, GraceFor: grace, Reason: "credential overlap regression"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return op
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 DAY) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
task := f.submit(1)
|
||||||
|
_, ref := f.claim()
|
||||||
|
if _, err := f.a.Start(ctx, old, f.binding, testID(t), ref); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expiry := change(credentialExpiryAction, 1, 0)
|
||||||
|
receipt, err := ApplyCredentialOperation(ctx, db, expiry)
|
||||||
|
if err != nil || receipt.ExpiresAt != nil || expiry.Token != "" {
|
||||||
|
t.Fatalf("expiry update: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, old); err != nil {
|
||||||
|
t.Fatal("original token lost after expiry update", err)
|
||||||
|
}
|
||||||
|
rotation := change(credentialRotateAction, 2, 7*24*time.Hour)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
receipts := make([]CredentialChangeReceipt, 2)
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i := range receipts {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); receipts[i], errs[i] = ApplyCredentialOperation(ctx, db, rotation) }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated {
|
||||||
|
t.Fatalf("concurrent replay: %v", errs)
|
||||||
|
}
|
||||||
|
current, _ := auth.Parse(rotation.Token)
|
||||||
|
for _, token := range []auth.Token{old, current} {
|
||||||
|
if err := f.a.Authorize(ctx, token); err != nil {
|
||||||
|
t.Fatal("overlap auth rejected", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
|
||||||
|
if err != nil || status.ExpiresAt != nil || status.PreviousValidUntil == nil {
|
||||||
|
t.Fatal("missing permanent/overlap status", err)
|
||||||
|
}
|
||||||
|
originalUntil := *status.PreviousValidUntil
|
||||||
|
// 过渡期间第二次轮换必须冲突,而不能挤掉仍在工作的上一代。
|
||||||
|
blocked := change(credentialRotateAction, 3, time.Hour)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, blocked); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatal("active predecessor overwritten", err)
|
||||||
|
}
|
||||||
|
renewed, err := f.a.Renew(ctx, old, f.binding, testID(t), ref)
|
||||||
|
if err != nil || !renewed.Lease.Active {
|
||||||
|
t.Fatal("in-flight old-token renew failed", err)
|
||||||
|
}
|
||||||
|
if _, err := f.a.Finish(ctx, old, f.binding, testID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"ok":true}`)}); err != nil {
|
||||||
|
t.Fatal("in-flight finish rejected", err)
|
||||||
|
}
|
||||||
|
if f.get(task.ID).State != pulse.TaskSucceeded || f.used() != 0 {
|
||||||
|
t.Fatal("task/permit changed by rotation")
|
||||||
|
}
|
||||||
|
final := change(credentialFinalizeAction, 3, 0)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, old); fault.Code(err) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("retired token accepted", err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, current); err != nil {
|
||||||
|
t.Fatal("current token rejected", err)
|
||||||
|
}
|
||||||
|
replay, err := ApplyCredentialOperation(ctx, db, rotation)
|
||||||
|
if err != nil || !replay.Deduplicated || !replay.PreviousValidUntil.Equal(originalUntil) {
|
||||||
|
t.Fatal("old receipt drifted", err)
|
||||||
|
}
|
||||||
|
if err := f.a.Authorize(ctx, old); err == nil {
|
||||||
|
t.Fatal("replay resurrected predecessor")
|
||||||
|
}
|
||||||
|
// 整张凭据撤销必须同时使两代令牌失效。
|
||||||
|
next := change(credentialRotateAction, 4, time.Hour)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newest, _ := auth.Parse(next.Token)
|
||||||
|
revoke := change(credentialRevokeAction, 5, 0)
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, token := range []auth.Token{old, current, newest} {
|
||||||
|
if err := f.a.Authorize(ctx, token); fault.Code(err) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("revocation missed a generation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var audits string
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('before',`+"`before`"+`,'after',`+"`after`"+`)) AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='credential'`, old.NamespaceID).Scan(&audits); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(audits, rotation.Token) || strings.Contains(audits, "secret_digest") {
|
||||||
|
t.Fatal("audit leaked secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCredentialOverlapDeadline(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
old := f.token
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
before, err := GetCredentialStatus(ctx, db, old.NamespaceID, old.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: 24 * time.Hour, Reason: "bounded grace"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := ApplyCredentialOperation(ctx, db, op)
|
||||||
|
if err != nil || !out.PreviousValidUntil.Equal(*before.ExpiresAt) {
|
||||||
|
t.Fatal("grace extended original expiry", err)
|
||||||
|
}
|
||||||
|
current, _ := auth.Parse(op.Token)
|
||||||
|
// 新令牌过期不会改变独立的旧令牌截止;旧令牌也不能越过自己的截止。
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
if f.a.Authorize(ctx, old) != nil || fault.Code(f.a.Authorize(ctx, current)) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("expiry of two generations conflated")
|
||||||
|
}
|
||||||
|
f.exec(`UPDATE orbit_credentials SET expires_at=NULL,previous_valid_until=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 MICROSECOND) WHERE namespace_id=? AND id=?`, old.NamespaceID, old.CredentialID)
|
||||||
|
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("expired old token remained usable")
|
||||||
|
}
|
||||||
|
// 过期的旧令牌不需定时清理才能失效;下一轮可以重用单一旧代槽。
|
||||||
|
next, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: old.NamespaceID, CredentialID: old.CredentialID, ExpectedRevision: 2, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "next rotation"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.a.Authorize(ctx, current) != nil || fault.Code(f.a.Authorize(ctx, old)) != "UNAUTHENTICATED" {
|
||||||
|
t.Fatal("wrong predecessor reused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个独立 HTTP/WS 节点共享真实 MySQL,验证旧长连接命令和跨节点重连。
|
||||||
|
func TestIntegrationCredentialOverlapWebSocket(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
serve := func(service *worker.Service) *httptest.Server {
|
||||||
|
hub := httpserver.NewWorkerHub(ctx, service)
|
||||||
|
server := httpserver.New("", nil, f.tasks)
|
||||||
|
httpserver.RegisterWorkers(server, hub)
|
||||||
|
ts := httptest.NewServer(server.Handler)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := hub.Shutdown(shutdown); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
ts.Close()
|
||||||
|
})
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
a, b := serve(f.a), serve(f.b)
|
||||||
|
hello := f.hello
|
||||||
|
hello.InstanceID = testID(t)
|
||||||
|
hello.Capabilities = []worker.Capability{{Handler: "rotation.idle", HandlerVersion: "v1", PayloadVersion: "v1"}}
|
||||||
|
type frame struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SessionID core.ID `json:"session_id"`
|
||||||
|
Error *fault.Error `json:"error"`
|
||||||
|
}
|
||||||
|
connect := func(url, token string) (*websocket.Conn, core.ID) {
|
||||||
|
t.Helper()
|
||||||
|
c, _, err := websocket.Dial(ctx, url+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + token}}, Subprotocols: []string{"pulse.worker.v1"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { c.CloseNow() })
|
||||||
|
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.hello", "message_id": testID(t), "body": hello}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out frame
|
||||||
|
if err := wsjson.Read(ctx, c, &out); err != nil || out.Type != "worker.welcome" {
|
||||||
|
t.Fatalf("WS welcome: %v, %s", err, out.Type)
|
||||||
|
}
|
||||||
|
return c, out.SessionID
|
||||||
|
}
|
||||||
|
capacity := func(c *websocket.Conn, session core.ID, seq int, want string) {
|
||||||
|
t.Helper()
|
||||||
|
body := worker.CapacityInput{Sequence: uint64(seq), Capacity: 0}
|
||||||
|
if err := wsjson.Write(ctx, c, map[string]any{"protocol_version": "1", "type": "worker.capacity", "message_id": testID(t), "session_id": session, "body": body}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out frame
|
||||||
|
if err := wsjson.Read(ctx, c, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code := ""
|
||||||
|
if out.Error != nil {
|
||||||
|
code = out.Error.Code
|
||||||
|
}
|
||||||
|
if code != want {
|
||||||
|
t.Fatalf("WS auth: %s, want %s", code, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldConn, session := connect(a.URL, f.resources.Token)
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, GraceFor: time.Hour, Reason: "rolling switch"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, op); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
capacity(oldConn, session, 1, "")
|
||||||
|
oldConn.CloseNow()
|
||||||
|
reconnected, oldSession := connect(b.URL, f.resources.Token)
|
||||||
|
capacity(reconnected, oldSession, 1, "")
|
||||||
|
hello.InstanceID = testID(t)
|
||||||
|
current, newSession := connect(a.URL, op.Token)
|
||||||
|
capacity(current, newSession, 1, "")
|
||||||
|
final, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: f.token.NamespaceID, CredentialID: f.token.CredentialID, ExpectedRevision: 2, Action: credentialFinalizeAction, Reason: "all services switched"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, final); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
capacity(reconnected, oldSession, 2, "UNAUTHENTICATED")
|
||||||
|
capacity(current, newSession, 2, "")
|
||||||
|
c, response, err := websocket.Dial(ctx, b.URL+"/v1/worker/connect", &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": []string{"Bearer " + f.resources.Token}}, Subprotocols: []string{"pulse.worker.v1"}})
|
||||||
|
if c != nil {
|
||||||
|
c.CloseNow()
|
||||||
|
}
|
||||||
|
if err == nil || response == nil || response.StatusCode != 401 {
|
||||||
|
t.Fatal("retired WS handshake accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationCredentialVault(t *testing.T) {
|
||||||
|
ctx, db := overlapDB(t)
|
||||||
|
ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) + `"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := "vault-" + string(testID(t))
|
||||||
|
var initial BootstrapResult
|
||||||
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: "vault-test-password-12345"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
login, err := NewConsoleAuthStore(db).Login(ctx, name, "vault-test-password-12345", "test", "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, _ := auth.Parse(login.Token)
|
||||||
|
sdk, _ := auth.Parse(initial.Token)
|
||||||
|
store := NewConsoleApplicationStore(db, name, ring)
|
||||||
|
in := consoleauth.CredentialTokenInput{ExpectedRevision: 1}
|
||||||
|
access := func(input consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
|
||||||
|
return store.AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, input, importCurrent)
|
||||||
|
}
|
||||||
|
for _, unauthorized := range []auth.Token{sdk, {}} {
|
||||||
|
if _, err := store.AccessCredentialToken(ctx, unauthorized, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatal("SDK or missing session accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := NewConsoleApplicationStore(db, "other", ring).AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
|
||||||
|
t.Fatal("wrong administrator accepted")
|
||||||
|
}
|
||||||
|
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatal("legacy token unexpectedly revealed")
|
||||||
|
}
|
||||||
|
in.Token = initial.Token
|
||||||
|
// 审计失败时导入事务回滚,查看也不能把秘密交付给调用方。
|
||||||
|
trigger := "vault_audit_" + string(testID(t))
|
||||||
|
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action LIKE 'console.credential.token.%' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
|
||||||
|
if got, err := access(in, true); err == nil || got.Token != "" {
|
||||||
|
t.Fatal("audit failure returned secret")
|
||||||
|
}
|
||||||
|
var imported bool
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&imported); err != nil || imported {
|
||||||
|
t.Fatal("failed import did not roll back")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := access(in, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := access(in, true); err != nil {
|
||||||
|
t.Fatal("repeat import failed", err)
|
||||||
|
}
|
||||||
|
in.Token = ""
|
||||||
|
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='console.credential.token.view' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := access(in, false); err == nil || got.Token != "" {
|
||||||
|
t.Fatal("read without audit returned secret")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := access(in, false); err != nil || got.Token != initial.Token || got.Revision != 1 {
|
||||||
|
t.Fatal("import changed identity or cannot reveal")
|
||||||
|
}
|
||||||
|
var cipher []byte
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&cipher); err != nil || bytes.Contains(cipher, []byte(initial.Token)) {
|
||||||
|
t.Fatal("plaintext persisted")
|
||||||
|
}
|
||||||
|
detail, err := store.GetApplication(ctx, admin, initial.NamespaceID)
|
||||||
|
raw, _ := json.Marshal(detail)
|
||||||
|
if err != nil || !detail.Credentials[0].TokenStored || bytes.Contains(raw, []byte(initial.Token)) {
|
||||||
|
t.Fatal("metadata leaked or failed")
|
||||||
|
}
|
||||||
|
// 已启用密文保管的 CREATE、ROTATE、过渡期读取以及旧操作重放。
|
||||||
|
create, err := store.PrepareApplication(ctx, admin, consoleauth.ApplicationInput{Kind: "CREATE", Application: "vault-new-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Reason: "vault integration"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.ApplyApplication(ctx, admin, create); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := store.AccessCredentialToken(ctx, admin, create.NamespaceID, create.CredentialID, consoleauth.CredentialTokenInput{ExpectedRevision: 1}, false); err != nil || got.Token != create.Token {
|
||||||
|
t.Fatal("new app not encrypted")
|
||||||
|
}
|
||||||
|
rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: "ROTATE", GraceFor: time.Hour, Reason: "vault rotate"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
var readErr, rotateErr error
|
||||||
|
var read consoleauth.CredentialToken
|
||||||
|
go func() { defer wg.Done(); read, readErr = access(in, false) }()
|
||||||
|
go func() { defer wg.Done(); _, rotateErr = ApplyCredentialOperation(ctx, db, rotate, ring) }()
|
||||||
|
wg.Wait()
|
||||||
|
if rotateErr != nil || (readErr != nil && fault.Code(readErr) != "STATE_CONFLICT") || (readErr == nil && read.Token != initial.Token) {
|
||||||
|
t.Fatal("concurrent read/rotation inconsistent", rotateErr, readErr)
|
||||||
|
}
|
||||||
|
in.ExpectedRevision = 2
|
||||||
|
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
||||||
|
t.Fatal("current token unavailable during grace")
|
||||||
|
}
|
||||||
|
in.Token = initial.Token
|
||||||
|
if _, err := access(in, true); fault.Code(err) != "INVALID_ARGUMENT" {
|
||||||
|
t.Fatal("old generation imported")
|
||||||
|
}
|
||||||
|
in.Token = ""
|
||||||
|
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||||
|
if err != nil || status.PreviousValidUntil == nil {
|
||||||
|
t.Fatal("grace lost")
|
||||||
|
}
|
||||||
|
for _, action := range []string{"SET_EXPIRY", "FINALIZE"} {
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: action, Reason: "preserve vault"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.ExpectedRevision++
|
||||||
|
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
||||||
|
t.Fatal("non-secret change invalidated encryption")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ApplyCredentialOperation(ctx, db, rotate, ring); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
||||||
|
t.Fatal("replay changed ciphertext")
|
||||||
|
}
|
||||||
|
// 撤销会清除密文;历史 CLI 不配置密钥时轮换也不能遗留过期密文。
|
||||||
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "ROTATE", Reason: "legacy CLI"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = ApplyCredentialOperation(ctx, db, op); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.ExpectedRevision++
|
||||||
|
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
|
||||||
|
t.Fatal("stale ciphertext remained after CLI rotation")
|
||||||
|
}
|
||||||
|
in.Token = op.Token
|
||||||
|
if _, err := access(in, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.Token = ""
|
||||||
|
op, err = PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "REVOKE", Reason: "revoke vault"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
in.ExpectedRevision++
|
||||||
|
if _, err := access(in, false); err == nil {
|
||||||
|
t.Fatal("revoked credential revealed")
|
||||||
|
}
|
||||||
|
var stored bool
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&stored); err != nil || stored {
|
||||||
|
t.Fatal("revoked ciphertext remains")
|
||||||
|
}
|
||||||
|
// 审计只包含版本与操作身份,不包含 Token 或密文。
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT CONCAT_WS(' ',action,`before`,`after`) FROM orbit_audit_events WHERE namespace_id=?", initial.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
views := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var text string
|
||||||
|
if err := rows.Scan(&text); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, initial.Token) || strings.Contains(text, rotate.Token) {
|
||||||
|
t.Fatal("secret leaked to audit")
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "console.credential.token.view") {
|
||||||
|
views++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows.Err() != nil || views < 3 {
|
||||||
|
t.Fatal("view audit missing", rows.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,15 +10,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
credentialOperationVersion = 1
|
credentialOperationVersion = 2
|
||||||
credentialRotateAction = "ROTATE"
|
credentialRotateAction = "ROTATE"
|
||||||
credentialRevokeAction = "REVOKE"
|
credentialRevokeAction = "REVOKE"
|
||||||
|
credentialExpiryAction = "SET_EXPIRY"
|
||||||
|
credentialFinalizeAction = "FINALIZE"
|
||||||
credentialOperationName = "credential.change.v1"
|
credentialOperationName = "credential.change.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +36,8 @@ type CredentialStatus struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
|
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
|
||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision"`
|
||||||
|
// PreviousValidUntil 上一代令牌可用的 UTC 截止;空表示无旧令牌。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||||
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
|
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
// RevokedAt 撤销时间;空值表示尚未撤销。
|
// RevokedAt 撤销时间;空值表示尚未撤销。
|
||||||
@@ -52,16 +57,18 @@ type CredentialChangeInput struct {
|
|||||||
CredentialID core.ID
|
CredentialID core.ID
|
||||||
// ExpectedRevision 准备操作时观察到的版本。
|
// ExpectedRevision 准备操作时观察到的版本。
|
||||||
ExpectedRevision uint64
|
ExpectedRevision uint64
|
||||||
// Action 只能是 ROTATE 或 REVOKE。
|
// Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。
|
||||||
Action string
|
Action string
|
||||||
// ValidFor 轮换后的有效时长;撤销时必须为零。
|
// GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。
|
||||||
|
GraceFor time.Duration
|
||||||
|
// ValidFor 新令牌或原令牌的新有效时长;零为长期,撤销/结束过渡时必须为零。
|
||||||
ValidFor time.Duration
|
ValidFor time.Duration
|
||||||
// Reason 必填审计原因。
|
// Reason 必填审计原因。
|
||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CredentialOperation 是可安全重放的私有操作文件内容。
|
// CredentialOperation 是可安全重放的私有操作文件内容。
|
||||||
// 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。
|
// ROTATE 文件包含新令牌;服务端保存摘要、可选密文和不含秘密的操作回执。
|
||||||
type CredentialOperation struct {
|
type CredentialOperation struct {
|
||||||
// Version 操作文件格式版本,升级时不得静默改变语义。
|
// Version 操作文件格式版本,升级时不得静默改变语义。
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
@@ -73,13 +80,15 @@ type CredentialOperation struct {
|
|||||||
CredentialID core.ID `json:"credential_id"`
|
CredentialID core.ID `json:"credential_id"`
|
||||||
// ExpectedRevision 只允许在该版本上首次应用。
|
// ExpectedRevision 只允许在该版本上首次应用。
|
||||||
ExpectedRevision uint64 `json:"expected_revision"`
|
ExpectedRevision uint64 `json:"expected_revision"`
|
||||||
// Action 固定为 ROTATE 或 REVOKE。
|
// Action 固定变更类型;格式 v1 仅支持立即 ROTATE 和 REVOKE。
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// Reason 写入管理审计,不能包含控制字符或秘密。
|
// Reason 写入管理审计,不能包含控制字符或秘密。
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
// Token 轮换产生的一次性新令牌;撤销操作为空。
|
// Token 轮换产生的一次性新令牌;撤销操作为空。
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
// ExpiresAt 轮换后固定的绝对到期时间;重放不能重新计算。
|
// PreviousValidUntil ROTATE 请求的固定过渡截止;实际不超过旧令牌原到期时间。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||||
|
// ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +107,9 @@ type CredentialChangeReceipt struct {
|
|||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision"`
|
||||||
// AppliedAt 操作提交事务使用的数据库时间。
|
// AppliedAt 操作提交事务使用的数据库时间。
|
||||||
AppliedAt time.Time `json:"applied_at"`
|
AppliedAt time.Time `json:"applied_at"`
|
||||||
// ExpiresAt 轮换后的固定到期时间。
|
// PreviousValidUntil 操作后的旧令牌截止;重报返回首次提交时的值。
|
||||||
|
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
|
||||||
|
// ExpiresAt 当前令牌的固定到期时间,空为长期。
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
// RevokedAt 撤销提交时间。
|
// RevokedAt 撤销提交时间。
|
||||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||||
@@ -115,13 +126,13 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
|
|||||||
if err := CheckSchema(ctx, db); err != nil {
|
if err := CheckSchema(ctx, db); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
var expires, revoked sql.NullTime
|
var expires, revoked, previousUntil sql.NullTime
|
||||||
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
|
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
|
||||||
FROM orbit_credentials c
|
FROM orbit_credentials c
|
||||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||||
JOIN orbit_applications a ON a.id=n.application_id
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
|
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
|
||||||
Scan(&out.Name, &out.Revision, &expires, &revoked, &out.NamespaceEnabled, &out.ApplicationEnabled)
|
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return out, fault.NotFound
|
return out, fault.NotFound
|
||||||
}
|
}
|
||||||
@@ -130,6 +141,7 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential
|
|||||||
}
|
}
|
||||||
out.NamespaceID, out.CredentialID = namespace, credential
|
out.NamespaceID, out.CredentialID = namespace, credential
|
||||||
out.ExpiresAt = nullableTime(expires)
|
out.ExpiresAt = nullableTime(expires)
|
||||||
|
out.PreviousValidUntil = nullableTime(previousUntil)
|
||||||
out.RevokedAt = nullableTime(revoked)
|
out.RevokedAt = nullableTime(revoked)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -162,9 +174,12 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Token = secret
|
out.Token = secret
|
||||||
|
if in.GraceFor > 0 {
|
||||||
|
out.PreviousValidUntil = timePtr(now.Add(in.GraceFor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ValidFor > 0 {
|
||||||
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||||||
} else if in.ValidFor != 0 {
|
|
||||||
return out, fault.Invalid("revoke operation cannot set validity")
|
|
||||||
}
|
}
|
||||||
out.Version = credentialOperationVersion
|
out.Version = credentialOperationVersion
|
||||||
out.NamespaceID = in.NamespaceID
|
out.NamespaceID = in.NamespaceID
|
||||||
@@ -182,25 +197,28 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh
|
|||||||
// ValidateCredentialOperation validates the exact private operation shape.
|
// ValidateCredentialOperation validates the exact private operation shape.
|
||||||
// It is exported so the admin CLI can reject tampered files before opening a DB.
|
// It is exported so the admin CLI can reject tampered files before opening a DB.
|
||||||
func ValidateCredentialOperation(op CredentialOperation) error {
|
func ValidateCredentialOperation(op CredentialOperation) error {
|
||||||
if op.Version != credentialOperationVersion || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||||
return fault.Invalid("credential operation identity or version is invalid")
|
return fault.Invalid("credential operation identity or version is invalid")
|
||||||
}
|
}
|
||||||
if op.ExpectedRevision == 0 || (op.Action != credentialRotateAction && op.Action != credentialRevokeAction) {
|
if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) {
|
||||||
return fault.Invalid("credential operation action or revision is invalid")
|
return fault.Invalid("credential operation action or revision is invalid")
|
||||||
}
|
}
|
||||||
if err := validCredentialReason(op.Reason); err != nil {
|
if err := validCredentialReason(op.Reason); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) {
|
||||||
|
return fault.Invalid("legacy operation requires its original expiry and immediate rotation")
|
||||||
|
}
|
||||||
|
if op.ExpiresAt != nil && op.ExpiresAt.IsZero() || op.PreviousValidUntil != nil && op.PreviousValidUntil.IsZero() {
|
||||||
|
return fault.Invalid("fixed times must not be zero")
|
||||||
|
}
|
||||||
if op.Action == credentialRotateAction {
|
if op.Action == credentialRotateAction {
|
||||||
if op.ExpiresAt == nil || op.ExpiresAt.IsZero() || op.Token == "" {
|
|
||||||
return fault.Invalid("rotation operation must contain a token and fixed expiry")
|
|
||||||
}
|
|
||||||
parsed, err := auth.Parse(op.Token)
|
parsed, err := auth.Parse(op.Token)
|
||||||
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
|
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
|
||||||
return fault.Invalid("rotation token does not match credential identity")
|
return fault.Invalid("rotation token does not match credential identity")
|
||||||
}
|
}
|
||||||
} else if op.Token != "" || op.ExpiresAt != nil {
|
} else if op.Token != "" || op.PreviousValidUntil != nil || (op.Action != credentialExpiryAction && op.ExpiresAt != nil) {
|
||||||
return fault.Invalid("revoke operation must not contain a token or expiry")
|
return fault.Invalid("invalid fields for credential change")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -208,7 +226,16 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
|||||||
// ApplyCredentialOperation applies one frozen operation or returns its original
|
// ApplyCredentialOperation applies one frozen operation or returns its original
|
||||||
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
||||||
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
||||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) {
|
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
|
||||||
|
return applyCredentialOperation(ctx, db, op, nil, vault...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
|
||||||
|
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error), vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
|
||||||
|
var ring *credentialvault.Keyring
|
||||||
|
if len(vault) > 0 {
|
||||||
|
ring = vault[0]
|
||||||
|
}
|
||||||
var out CredentialChangeReceipt
|
var out CredentialChangeReceipt
|
||||||
if err := ValidateCredentialOperation(op); err != nil {
|
if err := ValidateCredentialOperation(op); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -217,16 +244,24 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||||
|
actor := ""
|
||||||
|
if authorize != nil {
|
||||||
|
var err error
|
||||||
|
actor, err = authorize(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
var name string
|
var name string
|
||||||
var revision uint64
|
var revision uint64
|
||||||
var expires, revoked sql.NullTime
|
var expires, revoked, previousUntil sql.NullTime
|
||||||
var namespaceEnabled, applicationEnabled bool
|
var namespaceEnabled, applicationEnabled bool
|
||||||
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
|
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
|
||||||
FROM orbit_credentials c
|
FROM orbit_credentials c
|
||||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||||
JOIN orbit_applications a ON a.id=n.application_id
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
|
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
|
||||||
Scan(&name, &revision, &expires, &revoked, &namespaceEnabled, &applicationEnabled)
|
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return fault.NotFound
|
return fault.NotFound
|
||||||
}
|
}
|
||||||
@@ -254,41 +289,88 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "previous_valid_until": nullableTime(previousUntil), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
||||||
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
||||||
if op.Action == credentialRotateAction {
|
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
|
||||||
parsed, err := auth.Parse(op.Token)
|
return fault.Conflict
|
||||||
if err != nil {
|
}
|
||||||
return fault.Invalid("rotation token is invalid")
|
out.ExpiresAt = nullableTime(expires)
|
||||||
}
|
out.PreviousValidUntil = nullableTime(previousUntil)
|
||||||
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
|
switch op.Action {
|
||||||
|
case credentialRotateAction:
|
||||||
|
parsed, _ := auth.Parse(op.Token)
|
||||||
|
// 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。
|
||||||
|
if previousUntil.Valid && previousUntil.Time.After(now) {
|
||||||
return fault.Conflict
|
return fault.Conflict
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
var previousDigest any
|
||||||
SET secret_digest=?,expires_at=?,revoked_at=NULL,revision=revision+1,updated_at=?
|
out.PreviousValidUntil = nil
|
||||||
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt.UTC(), now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
if op.PreviousValidUntil != nil {
|
||||||
|
if !op.PreviousValidUntil.After(now) || op.PreviousValidUntil.After(now.Add(30*24*time.Hour)) {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
until := *op.PreviousValidUntil
|
||||||
|
if expires.Valid && expires.Time.Before(until) {
|
||||||
|
until = expires.Time
|
||||||
|
}
|
||||||
|
if until.After(now) {
|
||||||
|
var old core.Digest
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT secret_digest FROM orbit_credentials WHERE namespace_id=? AND id=?`, op.NamespaceID, op.CredentialID).Scan(&old); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtleCompareDigest(old, parsed.Digest) == 1 {
|
||||||
|
return fault.Invalid("rotation must use a new secret")
|
||||||
|
}
|
||||||
|
previousDigest = old
|
||||||
|
out.PreviousValidUntil = timePtr(until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyID, ciphertext, err := ring.Seal(op.NamespaceID, op.CredentialID, op.Token)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.ExpiresAt = timePtr(op.ExpiresAt.UTC())
|
// 未配置保管密钥的旧 CLI 轮换也必须清除旧密文,不能回看过时代次。
|
||||||
after["expires_at"] = out.ExpiresAt
|
|
||||||
} else {
|
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
||||||
SET revoked_at=?,revision=revision+1,updated_at=?
|
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,secret_key_id=?,secret_ciphertext=?,revision=revision+1,updated_at=?
|
||||||
WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, keyID, ciphertext, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.ExpiresAt = op.ExpiresAt
|
||||||
|
case credentialExpiryAction:
|
||||||
|
// 只变更当前令牌有效期;不能延长上一代截止或改变任何秘密。
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET expires_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, op.ExpiresAt, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.ExpiresAt = op.ExpiresAt
|
||||||
|
case credentialFinalizeAction:
|
||||||
|
if !previousUntil.Valid {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.PreviousValidUntil = nil
|
||||||
|
case credentialRevokeAction:
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,secret_key_id=NULL,secret_ciphertext=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.RevokedAt = timePtr(now)
|
out.RevokedAt = timePtr(now)
|
||||||
after["revoked_at"] = now
|
out.PreviousValidUntil = nil
|
||||||
}
|
}
|
||||||
|
after["expires_at"] = out.ExpiresAt
|
||||||
|
after["previous_valid_until"] = out.PreviousValidUntil
|
||||||
|
after["revoked_at"] = out.RevokedAt
|
||||||
out.OperationID = op.OperationID
|
out.OperationID = op.OperationID
|
||||||
out.NamespaceID = op.NamespaceID
|
out.NamespaceID = op.NamespaceID
|
||||||
out.CredentialID = op.CredentialID
|
out.CredentialID = op.CredentialID
|
||||||
out.Action = op.Action
|
out.Action = op.Action
|
||||||
out.Revision = revision + 1
|
out.Revision = revision + 1
|
||||||
out.AppliedAt = now
|
out.AppliedAt = now
|
||||||
actor, err := databaseActor(ctx, tx)
|
if actor == "" {
|
||||||
if err != nil {
|
actor, err = databaseActor(ctx, tx)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -395,17 +477,20 @@ func validateCredentialChangeInput(in CredentialChangeInput) error {
|
|||||||
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
|
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if in.ExpectedRevision == 0 || (in.Action != credentialRotateAction && in.Action != credentialRevokeAction) {
|
if in.ExpectedRevision == 0 || !validCredentialAction(in.Action) {
|
||||||
return fault.Invalid("credential action and expected revision are required")
|
return fault.Invalid("credential action and expected revision are required")
|
||||||
}
|
}
|
||||||
if err := validCredentialReason(in.Reason); err != nil {
|
if err := validCredentialReason(in.Reason); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if in.Action == credentialRotateAction && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||||
return fault.Invalid("credential validity must be between one hour and one year")
|
return fault.Invalid("validity must be zero (permanent) or between one hour and one year")
|
||||||
}
|
}
|
||||||
if in.Action == credentialRevokeAction && in.ValidFor != 0 {
|
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 {
|
||||||
return fault.Invalid("revoke operation cannot set validity")
|
return fault.Invalid("this operation cannot set validity")
|
||||||
|
}
|
||||||
|
if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) {
|
||||||
|
return fault.Invalid("rotation grace must be between zero and 30 days")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -429,3 +514,7 @@ func timePtr(v time.Time) *time.Time {
|
|||||||
t := v.UTC()
|
t := v.UTC()
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validCredentialAction(action string) bool {
|
||||||
|
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ func (s *EventStore) ListSubscriptions(ctx context.Context, t auth.Token, queue
|
|||||||
}
|
}
|
||||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
|
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
|
||||||
args := []any{t.NamespaceID, t.CredentialID, queue}
|
args := []any{t.NamespaceID, t.CredentialID, queue}
|
||||||
|
if t.Console != nil {
|
||||||
|
q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=?`
|
||||||
|
args = []any{t.NamespaceID, queue}
|
||||||
|
}
|
||||||
if in.Cursor != "" {
|
if in.Cursor != "" {
|
||||||
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
|
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,14 +24,20 @@ func NewEventStore(db *sql.DB) *EventStore { return &EventStore{NewTaskStore(db)
|
|||||||
|
|
||||||
var _ events.Store = (*EventStore)(nil)
|
var _ events.Store = (*EventStore)(nil)
|
||||||
|
|
||||||
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at`
|
const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,filter,cursor_sequence,pending_count,max_pending,max_attempts,created_at,updated_at,retired_at,retired_sequence`
|
||||||
|
|
||||||
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
||||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
||||||
|
args := []any{t.NamespaceID, id, t.CredentialID}
|
||||||
|
if t.Console != nil {
|
||||||
|
// 调用方已在同一事务 authenticate;管理员可管理应用内已有订阅,SDK 仍绑定原凭据。
|
||||||
|
q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND queue_id IS NOT NULL`
|
||||||
|
args = []any{t.NamespaceID, id}
|
||||||
|
}
|
||||||
if lock {
|
if lock {
|
||||||
q += ` FOR UPDATE`
|
q += ` FOR UPDATE`
|
||||||
}
|
}
|
||||||
out, err := scanSubscription(tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID))
|
out, err := scanSubscription(tx.QueryRowContext(ctx, q, args...))
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return out, fault.NotFound
|
return out, fault.NotFound
|
||||||
}
|
}
|
||||||
@@ -46,7 +52,7 @@ func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Perm
|
|||||||
func scanSubscription(row scanner) (events.Subscription, error) {
|
func scanSubscription(row scanner) (events.Subscription, error) {
|
||||||
var out events.Subscription
|
var out events.Subscription
|
||||||
var raw []byte
|
var raw []byte
|
||||||
err := row.Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt)
|
err := row.Scan(&out.ID, &out.QueueID, &out.CredentialID, &out.Name, &out.Revision, &out.Enabled, &raw, &out.CursorSequence, &out.PendingCount, &out.MaxPending, &out.MaxAttempts, &out.CreatedAt, &out.UpdatedAt, &out.RetiredAt, &out.RetiredSequence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -81,6 +87,9 @@ func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key s
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.QueueID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -96,7 +105,7 @@ func (s *EventStore) CreateSubscription(ctx context.Context, t auth.Token, key s
|
|||||||
return events.HistoryExpired
|
return events.HistoryExpired
|
||||||
}
|
}
|
||||||
var count int
|
var count int
|
||||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=?`, t.NamespaceID).Scan(&count); err != nil {
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, t.NamespaceID).Scan(&count); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count >= events.MaxSubscriptions {
|
if count >= events.MaxSubscriptions {
|
||||||
@@ -152,6 +161,9 @@ func (s *EventStore) ControlSubscription(ctx context.Context, t auth.Token, id c
|
|||||||
out.Deduplicated = true
|
out.Deduplicated = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if before.RetiredAt != nil {
|
||||||
|
return fault.Retired
|
||||||
|
}
|
||||||
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||||
return fault.Conflict
|
return fault.Conflict
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
|||||||
//go:embed baseline.sql
|
//go:embed baseline.sql
|
||||||
var freshInstallBaseline string
|
var freshInstallBaseline string
|
||||||
|
|
||||||
const freshInstallBaselineVersion uint64 = 31
|
const freshInstallBaselineVersion uint64 = 35
|
||||||
|
|
||||||
type Migration struct {
|
type Migration struct {
|
||||||
Version uint64
|
Version uint64
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 同一凭据最多保留一代旧秘密;截止时间由轮换事务固定,不依赖节点内存。
|
||||||
|
ALTER TABLE orbit_credentials
|
||||||
|
ADD COLUMN previous_secret_digest BINARY(32) NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
|
||||||
|
ADD COLUMN previous_valid_until DATETIME(6) NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
|
||||||
|
ADD CONSTRAINT ck_credential_overlap CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL));
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 管理员可回看当前 Token;旧记录保留为空,导入只补密文,不轮换秘密。
|
||||||
|
ALTER TABLE orbit_credentials
|
||||||
|
ADD COLUMN secret_key_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
|
||||||
|
ADD COLUMN secret_ciphertext VARBINARY(1024) NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
|
||||||
|
ADD CONSTRAINT ck_credential_ciphertext CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
|
||||||
|
COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 通用资源退役,只关闭未来工作并释放活动订阅名额,不删除历史或审计;禁止外键。
|
||||||
|
ALTER TABLE pulse_queues
|
||||||
|
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示未退役,退役后禁止新工作且不可恢复',
|
||||||
|
ADD CONSTRAINT ck_queue_retired CHECK (retired_at IS NULL OR paused=TRUE);
|
||||||
|
ALTER TABLE orbit_subscriptions
|
||||||
|
ADD retired_at DATETIME(6) NULL COMMENT '永久退役时间,UTC;NULL表示仍占用订阅名额,退役保留历史交付',
|
||||||
|
ADD retired_sequence BIGINT UNSIGNED NULL COMMENT '退役事务锁定的结果流提交水位;NULL表示未退役,之后的事件不再订阅',
|
||||||
|
ADD KEY ix_subscription_active(namespace_id,retired_at),
|
||||||
|
ADD CONSTRAINT ck_subscription_retired CHECK ((retired_at IS NULL AND retired_sequence IS NULL) OR (retired_at IS NOT NULL AND retired_sequence IS NOT NULL AND enabled=FALSE AND pending_count=0));
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 登录保护独立于业务凭据和命名空间;没有外键,不改变已有密码、会话或 Token。
|
||||||
|
CREATE TABLE orbit_console_login_guards (
|
||||||
|
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或 ACCOUNT,固定先 IP 后账号加锁',
|
||||||
|
subject CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'IP 或规范账号身份的 SHA-256 十六进制键,不包含密码或 Token',
|
||||||
|
window_start DATETIME(6) NOT NULL COMMENT '固定请求限流窗口起点,UTC',
|
||||||
|
requests INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前窗口内已受理的尝试数,成功不归零',
|
||||||
|
failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败窗口内的失败数,成功后归零',
|
||||||
|
first_failed_at DATETIME(6) NULL COMMENT '失败窗口起点;空表示无连续失败',
|
||||||
|
locked_until DATETIME(6) NULL COMMENT '临时锁定截止;锁内请求不延长此时间',
|
||||||
|
expires_at DATETIME(6) NOT NULL COMMENT '非活动状态清理截止,晚于所有保护窗口',
|
||||||
|
PRIMARY KEY(kind,subject),
|
||||||
|
KEY ix_console_guard_expiry(expires_at),
|
||||||
|
CONSTRAINT ck_console_guard_kind CHECK(kind IN ('IP','ACCOUNT'))
|
||||||
|
) ENGINE=InnoDB COMMENT='跨节点持久登录限流及临时锁定状态';
|
||||||
|
|
||||||
|
CREATE TABLE orbit_console_login_audit (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审计序号,仅用于有界倒序分页',
|
||||||
|
username VARCHAR(128) NOT NULL COMMENT '请求中的登录名,不证明账号存在',
|
||||||
|
remote_ip VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '实际连接或受信代理解析出的规范 IP',
|
||||||
|
user_agent VARCHAR(512) NOT NULL COMMENT '有界浏览器标识,不保存请求正文或认证头',
|
||||||
|
outcome VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'PENDING/SUCCESS/INVALID/LOCKED/ACCOUNT_LIMIT/IP_LIMIT/ERROR',
|
||||||
|
created_at DATETIME(6) NOT NULL COMMENT '开始时间,UTC',
|
||||||
|
finished_at DATETIME(6) NULL COMMENT '结果确认时间,空为未完成;异常中断由清理器标 ERROR',
|
||||||
|
retry_at DATETIME(6) NULL COMMENT '被锁定或限流时的最早重试时间;否则为空',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
KEY ix_console_login_audit_time(created_at),
|
||||||
|
KEY ix_console_login_audit_pending(outcome,created_at),
|
||||||
|
CONSTRAINT ck_console_login_outcome CHECK(outcome IN ('PENDING','SUCCESS','INVALID','LOCKED','ACCOUNT_LIMIT','IP_LIMIT','ERROR'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='全局登录审计,不含密码、Cookie 或 Token,仅管理员可查询';
|
||||||
@@ -147,7 +147,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
|
|||||||
indexes := func() []string {
|
indexes := func() []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
|
||||||
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_console_login_audit','orbit_console_login_guards','orbit_application_resources','orbit_event_streams','orbit_result_events','orbit_console_users','orbit_console_sessions','orbit_retention_policies','orbit_archive_jobs','orbit_archive_plans','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_subscription_active','ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created','ix_task_observe_state_available','ix_delivery_observe_state_available','ix_workflow_observe_state_updated','ix_event_observe_type_occurred','ix_task_observe_terminal_completed','ix_workflow_observe_terminal_completed') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
175
internal/storage/mysql/resource_retirement.go
Normal file
175
internal/storage/mysql/resource_retirement.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireActiveQueue 对创建/恢复工作取得共享锁,与退役排他锁串行;历史读取不使用此检查。
|
||||||
|
func requireActiveQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) error {
|
||||||
|
var retired *time.Time
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT retired_at FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, ns, id).Scan(&retired)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if retired != nil {
|
||||||
|
return fault.Retired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func retirementBusy(message string) error { return fault.New("RESOURCE_BUSY", message) }
|
||||||
|
|
||||||
|
// RetireSubscription 锁定提交水位后锁订阅,既不丢未物化结果,也不丢过期交付。
|
||||||
|
// 与创建共用流锁串行化活动名额;消费只锁订阅,不反向锁流/队列。
|
||||||
|
func (s *EventStore) RetireSubscription(ctx context.Context, t auth.Token, id core.ID, key string, in events.RetireInput) (events.SubscriptionResult, error) {
|
||||||
|
var out events.SubscriptionResult
|
||||||
|
err := s.transaction(ctx, t, "events:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
if _, err := readSubscription(ctx, tx, t, p, id, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "subscription.retire", eventScope(t.CredentialID, id), key, "subscription", id, in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cached != nil {
|
||||||
|
if err = json.Unmarshal(cached, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Deduplicated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
head, retained, err := lockEventStream(ctx, tx, t.NamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before, err := readSubscription(ctx, tx, t, p, id, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if before.RetiredAt != nil {
|
||||||
|
return fault.Retired
|
||||||
|
}
|
||||||
|
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if before.PendingCount != 0 {
|
||||||
|
return retirementBusy("subscription has unacknowledged deliveries; drain or resolve expired deliveries first")
|
||||||
|
}
|
||||||
|
var pending bool
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM orbit_deliveries WHERE namespace_id=? AND subscription_id=? AND state IN ('PENDING','LEASED','EXPIRED'))`, t.NamespaceID, id).Scan(&pending); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pending {
|
||||||
|
return retirementBusy("subscription has unacknowledged deliveries")
|
||||||
|
}
|
||||||
|
if before.CursorSequence < retained-1 {
|
||||||
|
return events.HistoryExpired
|
||||||
|
}
|
||||||
|
filter, err := json.Marshal(before.Types)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 缺失来源事件也拒绝退役,不能将损坏记录误当成不匹配过滤器。
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM orbit_result_events r LEFT JOIN orbit_events e ON e.namespace_id=r.namespace_id AND e.id=r.id WHERE r.namespace_id=? AND r.queue_id=? AND r.sequence>? AND r.sequence<=? AND (e.id IS NULL OR JSON_CONTAINS(?,JSON_QUOTE(e.type))))`, t.NamespaceID, before.QueueID, before.CursorSequence, head, string(filter)).Scan(&pending); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pending {
|
||||||
|
return retirementBusy("subscription has unconsumed results; resume consumption and acknowledge them before retirement")
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE orbit_subscriptions SET enabled=FALSE,retired_at=UTC_TIMESTAMP(6),retired_sequence=?,cursor_sequence=?,revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, head, head, t.NamespaceID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Subscription, err = readSubscription(ctx, tx, t, p, id, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, t, identity, id, "subscription", "subscription.retire", in.Reason, before, out.Subscription); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetireQueue 按应用额度→身份→队列持锁,拒绝尚未结清的工作和未来工作来源。
|
||||||
|
// 暂停计划可以保留;其恢复/发布与本操作共用队列锁,退役后无法重新激活。
|
||||||
|
func (s *TaskStore) RetireQueue(ctx context.Context, t auth.Token, id core.ID, key string, in service.RetireQueueInput) (service.QueueResult, error) {
|
||||||
|
var out service.QueueResult
|
||||||
|
err := s.transaction(ctx, t, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||||
|
if !p.AllowsQueue(id) {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "queue.retire", string(id), key, "queue", id, in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cached != nil {
|
||||||
|
if err = json.Unmarshal(cached, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Deduplicated = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, _, err = lockQueueAfterApplication(ctx, tx, t.NamespaceID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before, err := readQueue(ctx, tx, t.NamespaceID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if before.Revision != in.ExpectedRevision || before.Revision == math.MaxUint64 {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
if before.UnfinishedTasks != 0 {
|
||||||
|
return retirementBusy("queue has unfinished tasks, including tasks needing review")
|
||||||
|
}
|
||||||
|
checks := []struct {
|
||||||
|
query, message string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{`SELECT EXISTS(SELECT 1 FROM pulse_tasks WHERE namespace_id=? AND queue_id=? AND state IN ('WAITING','LEASED','RUNNING','NEEDS_REVIEW'))`, "queue has unfinished or uncertain tasks", []any{t.NamespaceID, id}},
|
||||||
|
{`SELECT EXISTS(SELECT 1 FROM pulse_attempts a JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id WHERE t.namespace_id=? AND t.queue_id=? AND a.state IN ('LEASED','RUNNING'))`, "queue has active execution attempts", []any{t.NamespaceID, id}},
|
||||||
|
{`SELECT EXISTS(SELECT 1 FROM pulse_permits p JOIN pulse_attempts a ON a.namespace_id=p.namespace_id AND a.id=p.attempt_id JOIN pulse_tasks t ON t.namespace_id=a.namespace_id AND t.id=a.task_id WHERE t.namespace_id=? AND t.queue_id=? AND p.released_at IS NULL)`, "queue has unreleased execution permits", []any{t.NamespaceID, id}},
|
||||||
|
{`SELECT EXISTS(SELECT 1 FROM pulse_schedules WHERE namespace_id=? AND queue_id=? AND paused=FALSE)`, "queue has enabled schedules; pause them first", []any{t.NamespaceID, id}},
|
||||||
|
{`SELECT EXISTS(SELECT 1 FROM weave_instances i LEFT JOIN weave_version_scopes v ON v.namespace_id=i.namespace_id AND v.definition_version_id=i.definition_version_id WHERE i.namespace_id=? AND i.state IN ('RUNNING','WAITING','CANCELLING','NEEDS_REVIEW') AND (v.definition_version_id IS NULL OR JSON_CONTAINS(v.queue_ids,JSON_QUOTE(?))))`, "queue is referenced by unfinished workflows", []any{t.NamespaceID, string(id)}},
|
||||||
|
{`SELECT EXISTS(SELECT 1 FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=? AND retired_at IS NULL)`, "queue has subscriptions; retire them first", []any{t.NamespaceID, id}},
|
||||||
|
}
|
||||||
|
for _, check := range checks {
|
||||||
|
var busy bool
|
||||||
|
if err = tx.QueryRowContext(ctx, check.query, check.args...).Scan(&busy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if busy {
|
||||||
|
return retirementBusy(check.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE pulse_queues SET paused=TRUE,retired_at=UTC_TIMESTAMP(6),revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, t.NamespaceID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Queue, err = readQueue(ctx, tx, t.NamespaceID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, t, identity, id, "queue", "queue.retire", in.Reason, before, out.Queue); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
241
internal/storage/mysql/resource_retirement_integration_test.go
Normal file
241
internal/storage/mysql/resource_retirement_integration_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/events"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addRetirementPermission(t *testing.T, f *executionFixture) {
|
||||||
|
t.Helper()
|
||||||
|
var raw []byte
|
||||||
|
var p auth.Permissions
|
||||||
|
if err := f.db.QueryRowContext(f.ctx, `SELECT permissions FROM orbit_credentials WHERE namespace_id=? AND id=?`, f.token.NamespaceID, f.token.CredentialID).Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.Actions = append(p.Actions, "queues:manage", "queues:read")
|
||||||
|
raw, _ = json.Marshal(p)
|
||||||
|
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), f.token.NamespaceID, f.token.CredentialID)
|
||||||
|
}
|
||||||
|
func TestIntegrationResourceRetirement(t *testing.T) {
|
||||||
|
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("requires disposable MySQL")
|
||||||
|
}
|
||||||
|
ctx, stop := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer stop()
|
||||||
|
db, err := Open(ctx, dsn, 32, 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err = Migrate(ctx, db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Run("no_lost_backlog_leases_or_history", func(t *testing.T) {
|
||||||
|
f := newEventFixture(t, ctx, db)
|
||||||
|
sub := f.create(events.CreateInput{MaxAttempts: 1})
|
||||||
|
task := f.cancelResult()
|
||||||
|
in := events.RetireInput{ExpectedRevision: sub.Revision, Reason: "completed temporary resources"}
|
||||||
|
_, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
g := f.grant(sub.ID)
|
||||||
|
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
f.expireDelivery(g)
|
||||||
|
noEventGrant(t, f, sub.ID)
|
||||||
|
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
f.ack(sub.ID, g)
|
||||||
|
done, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||||
|
if err != nil || done.Subscription.RetiredAt == nil || done.Subscription.RetiredSequence == nil || done.Subscription.Enabled {
|
||||||
|
t.Fatalf("retire: %+v %v", done, err)
|
||||||
|
}
|
||||||
|
again, err := f.events.Retire(ctx, f.token, sub.ID, "retire", in)
|
||||||
|
if err != nil || !again.Deduplicated || again.Subscription.Revision != done.Subscription.Revision {
|
||||||
|
t.Fatal(again, err)
|
||||||
|
}
|
||||||
|
changed := in
|
||||||
|
changed.Reason = "other"
|
||||||
|
_, err = f.events.Retire(ctx, f.token, sub.ID, "retire", changed)
|
||||||
|
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
|
||||||
|
_, err = f.events.Control(ctx, f.token, sub.ID, "enable", events.ControlInput{ExpectedRevision: done.Subscription.Revision, Enabled: true, Reason: "attempt resume"})
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||||
|
detail, err := f.events.Detail(ctx, f.token, sub.ID, g.Delivery.ID)
|
||||||
|
if err != nil || detail.Event.AggregateID != task.ID {
|
||||||
|
t.Fatal("history unavailable", err)
|
||||||
|
}
|
||||||
|
f.cancelResult()
|
||||||
|
noEventGrant(t, f, sub.ID)
|
||||||
|
if f.count("orbit_audit_events", "action='subscription.retire'") != 1 {
|
||||||
|
t.Fatal("retirement audit missing or duplicated")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("paused_counts_retired_releases_capacity", func(t *testing.T) {
|
||||||
|
f := newEventFixture(t, ctx, db)
|
||||||
|
sub := f.create(events.CreateInput{})
|
||||||
|
paused, err := f.events.Control(ctx, f.token, sub.ID, "pause", events.ControlInput{ExpectedRevision: sub.Revision, Enabled: false, Reason: "pause"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 1; i < events.MaxSubscriptions; i++ {
|
||||||
|
f.create(events.CreateInput{Name: fmt.Sprintf("capacity-%d", i)})
|
||||||
|
}
|
||||||
|
_, err = f.events.Create(ctx, f.token, "next", events.CreateInput{Name: "next", QueueID: f.resources.QueueID})
|
||||||
|
expectWorkerCode(t, err, "SUBSCRIPTION_LIMIT")
|
||||||
|
if _, err = f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: paused.Subscription.Revision, Reason: "retire paused"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = f.events.Create(ctx, f.token, "next", events.CreateInput{Name: "next", QueueID: f.resources.QueueID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.count("orbit_subscriptions", "1=1") != 65 {
|
||||||
|
t.Fatal("history was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("queue_protects_tasks_subscriptions_and_original_receipts", func(t *testing.T) {
|
||||||
|
f := newEventFixture(t, ctx, db)
|
||||||
|
q := f.resources.QueueID
|
||||||
|
sub := f.create(events.CreateInput{})
|
||||||
|
task := f.submit(1)
|
||||||
|
in := service.RetireQueueInput{ExpectedRevision: 1, Reason: "queue lifecycle"}
|
||||||
|
_, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
if _, err = f.tasks.CancelTask(ctx, f.token, task.ID, "cancel", service.CancelInput{ExpectedRevision: task.Revision, Reason: "finish test"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
g := f.grant(sub.ID)
|
||||||
|
f.ack(sub.ID, g)
|
||||||
|
if _, err = f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: sub.Revision, Reason: "retire"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
retired, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||||
|
if err != nil || retired.Queue.RetiredAt == nil || !retired.Queue.Paused {
|
||||||
|
t.Fatal(retired, err)
|
||||||
|
}
|
||||||
|
replay, err := f.tasks.RetireQueue(ctx, f.token, q, "retire", in)
|
||||||
|
if err != nil || !replay.Deduplicated {
|
||||||
|
t.Fatal(replay, err)
|
||||||
|
}
|
||||||
|
_, err = f.tasks.ConfigureQueue(ctx, f.token, q, "resume", service.QueueConfigInput{ExpectedRevision: retired.Queue.Revision, Reason: "resume", MaxConcurrency: 1, RatePerSecond: 1, MaxUnfinishedTasks: 1})
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||||
|
_, err = f.tasks.Submit(ctx, f.token, "new-task", service.SubmitInput{QueueID: q, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||||
|
_, err = f.events.Create(ctx, f.token, "new-sub", events.CreateInput{Name: "new", QueueID: q})
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||||
|
if _, err = f.tasks.GetTask(ctx, f.token, task.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other := newEventFixture(t, ctx, db)
|
||||||
|
_, err = other.events.Retire(ctx, other.token, sub.ID, "foreign", events.RetireInput{ExpectedRevision: 1, Reason: "foreign"})
|
||||||
|
expectWorkerCode(t, err, "NOT_FOUND")
|
||||||
|
})
|
||||||
|
t.Run("schedule_resume_cannot_revive_retired_queue", func(t *testing.T) {
|
||||||
|
f := newScheduleFixture(t, newExecutionFixture(t, ctx, db))
|
||||||
|
addRetirementPermission(t, f.executionFixture)
|
||||||
|
v := f.create(f.definition())
|
||||||
|
_, err := f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"})
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
pause, err := f.api.Control(ctx, f.token, v.ID, "pause", schedule.ControlInput{ExpectedRevision: v.Revision, Reason: "pause before retire"}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = f.api.Control(ctx, f.token, v.ID, "resume", schedule.ControlInput{ExpectedRevision: pause.Schedule.Revision, Reason: "resume"}, false)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||||
|
if _, err = f.api.Get(ctx, f.token, v.ID); err != nil {
|
||||||
|
t.Fatal("schedule history", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("waiting_workflow_with_future_queue_reference_blocks_retirement", func(t *testing.T) {
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
spec := runtimeSpec(f.resources.QueueID, 1)
|
||||||
|
spec.Body.Steps = append([]definition.Step{{Key: "wait", Kind: "TIMER", Timer: &definition.Timer{DelayMS: 60000}}}, spec.Body.Steps...)
|
||||||
|
runtimeStart(t, f, spec)
|
||||||
|
addRetirementPermission(t, f)
|
||||||
|
_, err := f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "retire"})
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_BUSY")
|
||||||
|
})
|
||||||
|
t.Run("completed_workflow_receipt_survives_queue_retirement", func(t *testing.T) {
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
grantWorkflowInstance(t, f)
|
||||||
|
input := publishInstanceSpec(t, f, runtimeSpec(f.resources.QueueID, 1))
|
||||||
|
api := instance.New(NewWorkflowInstanceStore(db))
|
||||||
|
key := string(executionID(t))
|
||||||
|
first, err := api.Start(ctx, f.token, key, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref := retryStart(t, f)
|
||||||
|
retryFinish(t, f, ref, "SUCCEEDED", false)
|
||||||
|
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
|
||||||
|
runtimeView(t, f, first.Instance.ID, "SUCCEEDED")
|
||||||
|
addRetirementPermission(t, f)
|
||||||
|
if _, err = f.tasks.RetireQueue(ctx, f.token, f.resources.QueueID, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "completed workflow"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replay, err := api.Start(ctx, f.token, key, input)
|
||||||
|
if err != nil || !replay.Deduplicated || replay.Instance.ID != first.Instance.ID {
|
||||||
|
t.Fatal("receipt after retirement", err)
|
||||||
|
}
|
||||||
|
if _, err = api.Get(ctx, f.token, first.Instance.ID); err != nil {
|
||||||
|
t.Fatal("workflow history", err)
|
||||||
|
}
|
||||||
|
_, err = api.Start(ctx, f.token, "new-instance", input)
|
||||||
|
expectWorkerCode(t, err, "RESOURCE_RETIRED")
|
||||||
|
})
|
||||||
|
t.Run("submit_and_retire_serialize_under_real_database_contention", func(t *testing.T) {
|
||||||
|
f := newExecutionFixture(t, ctx, db)
|
||||||
|
key := string(executionID(t))
|
||||||
|
q := f.resources.QueueID
|
||||||
|
results := competeWorkflow(t, f, nil, func() error {
|
||||||
|
_, e := f.tasks.Submit(ctx, f.token, key, service.SubmitInput{QueueID: q, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
|
||||||
|
return e
|
||||||
|
}, func() error {
|
||||||
|
_, e := f.tasks.RetireQueue(ctx, f.token, q, "retire", service.RetireQueueInput{ExpectedRevision: 1, Reason: "concurrent retirement"})
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if results[0] == nil {
|
||||||
|
expectWorkerCode(t, results[1], "RESOURCE_BUSY")
|
||||||
|
} else {
|
||||||
|
expectWorkerCode(t, results[0], "RESOURCE_RETIRED")
|
||||||
|
if results[1] != nil {
|
||||||
|
t.Fatal(results[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("audit_failure_rolls_back_retirement_and_quota", func(t *testing.T) {
|
||||||
|
f := newEventFixture(t, ctx, db)
|
||||||
|
sub := f.create(events.CreateInput{})
|
||||||
|
f.exec(`CREATE TRIGGER retirement_audit_reject BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='retirement audit unavailable'`)
|
||||||
|
defer db.ExecContext(ctx, `DROP TRIGGER IF EXISTS retirement_audit_reject`)
|
||||||
|
_, err := f.events.Retire(ctx, f.token, sub.ID, "retire", events.RetireInput{ExpectedRevision: sub.Revision, Reason: "audit"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("audit failure accepted")
|
||||||
|
}
|
||||||
|
if f.subscription(sub.ID).RetiredAt != nil {
|
||||||
|
t.Fatal("partial retirement committed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var foreignKeys int
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
|
||||||
|
t.Fatal("foreign keys", foreignKeys, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,13 @@ func TestIntegrationResultSubscriptionsUpgrade(t *testing.T) {
|
|||||||
if err = CheckSchema(ctx, db); err != nil {
|
if err = CheckSchema(ctx, db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
var retired *time.Time
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT retired_at FROM pulse_queues WHERE namespace_id=? AND id=?`, ns, queue).Scan(&retired); err != nil || retired != nil {
|
||||||
|
t.Fatal("migration unexpectedly retired legacy queue", err)
|
||||||
|
}
|
||||||
|
if err = db.QueryRowContext(ctx, `SELECT retired_at FROM orbit_subscriptions WHERE namespace_id=? AND id=?`, ns, sub).Scan(&retired); err != nil || retired != nil {
|
||||||
|
t.Fatal("migration unexpectedly retired legacy subscription", err)
|
||||||
|
}
|
||||||
var enabled bool
|
var enabled bool
|
||||||
var queueID *core.ID
|
var queueID *core.ID
|
||||||
var filter string
|
var filter string
|
||||||
|
|||||||
@@ -71,8 +71,11 @@ func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
|
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
|
||||||
// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。
|
// 普通会话要求全部队列管理范围;部署指定管理员的显式范围在同一事务重新核验。
|
||||||
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
|
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
|
||||||
|
if token.Console != nil {
|
||||||
|
return consoleScopeActor(ctx, tx, token)
|
||||||
|
}
|
||||||
p, err := authenticate(ctx, tx, token)
|
p, err := authenticate(ctx, tx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func (s *ScheduleStore) Create(ctx context.Context, t auth.Token, key string, in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := requireActiveQueue(ctx, tx, t.NamespaceID, in.Definition.Task.QueueID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
now, err := dbNow(ctx, tx)
|
now, err := dbNow(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -252,6 +255,13 @@ func (s *ScheduleStore) mutate(ctx context.Context, t auth.Token, id core.ID, ke
|
|||||||
out.Deduplicated = true
|
out.Deduplicated = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
current, err := readSchedule(ctx, tx, t.NamespaceID, id, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = requireActiveQueue(ctx, tx, t.NamespaceID, current.QueueID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
v, err := readSchedule(ctx, tx, t.NamespaceID, id, true)
|
v, err := readSchedule(ctx, tx, t.NamespaceID, id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -45,19 +45,28 @@ func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action st
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
|
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
|
||||||
|
if token.Console != nil {
|
||||||
|
_, err := consoleScopeActor(ctx, tx, token)
|
||||||
|
return consoleScopePermissions(), err
|
||||||
|
}
|
||||||
var digest core.Digest
|
var digest core.Digest
|
||||||
var raw []byte
|
var raw, previous []byte
|
||||||
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions FROM orbit_credentials c
|
var currentValid, previousValid bool
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions,c.previous_secret_digest,
|
||||||
|
(c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)),
|
||||||
|
COALESCE(c.previous_valid_until>UTC_TIMESTAMP(6),FALSE) FROM orbit_credentials c
|
||||||
JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
||||||
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
|
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
|
||||||
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw)
|
AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw, &previous, ¤tValid, &previousValid)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return auth.Permissions{}, fault.Unauthorized
|
return auth.Permissions{}, fault.Unauthorized
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.Permissions{}, err
|
return auth.Permissions{}, err
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
|
currentMatch := subtle.ConstantTimeCompare(digest[:], token.Digest[:])
|
||||||
|
previousMatch := subtle.ConstantTimeCompare(previous, token.Digest[:])
|
||||||
|
if !(currentValid && currentMatch == 1) && !(previousValid && previousMatch == 1) {
|
||||||
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
|
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
|
||||||
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
||||||
// 才允许沿用该业务凭据的权限快照。
|
// 才允许沿用该业务凭据的权限快照。
|
||||||
@@ -173,7 +182,7 @@ func appendAuditWithEvidence(ctx context.Context, tx *sql.Tx, token auth.Token,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, evidence, string(old), string(current))
|
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, token.ActorID(), action, kind, resource, reason, evidence, string(old), string(current))
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||||
@@ -365,7 +374,7 @@ func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key strin
|
|||||||
}
|
}
|
||||||
func readQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (service.QueueView, error) {
|
func readQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (service.QueueView, error) {
|
||||||
var q service.QueueView
|
var q service.QueueView
|
||||||
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,max_unfinished_tasks,unfinished_tasks,revision FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.MaxUnfinishedTasks, &q.UnfinishedTasks, &q.Revision)
|
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,max_unfinished_tasks,unfinished_tasks,revision,retired_at FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.MaxUnfinishedTasks, &q.UnfinishedTasks, &q.Revision, &q.RetiredAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
err = fault.NotFound
|
err = fault.NotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ func lockQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, ma
|
|||||||
|
|
||||||
// lockQueueAfterApplication 仅在同一事务已持有应用额度排他锁时调用。
|
// lockQueueAfterApplication 仅在同一事务已持有应用额度排他锁时调用。
|
||||||
func lockQueueAfterApplication(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, max uint32, err error) {
|
func lockQueueAfterApplication(ctx context.Context, tx *sql.Tx, ns, id core.ID) (paused bool, max uint32, err error) {
|
||||||
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max)
|
var retired *time.Time
|
||||||
|
err = tx.QueryRowContext(ctx, "SELECT paused,max_concurrency,retired_at FROM pulse_queues WHERE namespace_id=? AND id=? FOR UPDATE", ns, id).Scan(&paused, &max, &retired)
|
||||||
|
if err == nil && retired != nil {
|
||||||
|
err = fault.Retired
|
||||||
|
}
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
err = fault.NotFound
|
err = fault.NotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ func (s *ExecutionStore) Connect(ctx context.Context, token auth.Token, in worke
|
|||||||
// 不依赖数据库外键:创建会话前验证队列存在且属于当前命名空间。
|
// 不依赖数据库外键:创建会话前验证队列存在且属于当前命名空间。
|
||||||
// 进程头在队列之前加锁,与执行命令保持一致;失败时回滚新建的进程头。
|
// 进程头在队列之前加锁,与执行命令保持一致;失败时回滚新建的进程头。
|
||||||
var queue core.ID
|
var queue core.ID
|
||||||
|
if err := requireActiveQueue(ctx, tx, token.NamespaceID, in.QueueID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, token.NamespaceID, in.QueueID).Scan(&queue); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, token.NamespaceID, in.QueueID).Scan(&queue); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return fault.NotFound
|
return fault.NotFound
|
||||||
|
|||||||
@@ -129,10 +129,11 @@ func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key str
|
|||||||
if len(version.Analysis.Queues) > 0 && !p.Allows("tasks:submit") {
|
if len(version.Analysis.Queues) > 0 && !p.Allows("tasks:submit") {
|
||||||
return fault.Forbidden
|
return fault.Forbidden
|
||||||
}
|
}
|
||||||
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cached != nil {
|
if cached != nil {
|
||||||
|
// 已完成流程所用队列可以退役;原受理回执仍核对当前读取范围,但不重新申请执行权。
|
||||||
|
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := json.Unmarshal(cached, &out); err != nil {
|
if err := json.Unmarshal(cached, &out); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -146,6 +147,9 @@ func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key str
|
|||||||
out.Deduplicated = true
|
out.Deduplicated = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := definition.ValidateData(version.Specification.InputType, in.Input); err != nil {
|
if err := definition.ValidateData(version.Specification.InputType, in.Input); err != nil {
|
||||||
return fault.Invalid("workflow input does not match fixed version")
|
return fault.Invalid("workflow input does not match fixed version")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func RegisterConsoleArchiveJobs(server *http.Server, consoleAuth RetentionConsol
|
|||||||
writeError(w, "", fault.Unauthorized)
|
writeError(w, "", fault.Unauthorized)
|
||||||
return auth.Token{}, false
|
return auth.Token{}, false
|
||||||
}
|
}
|
||||||
return t, true
|
return scopedConsoleToken(r, t), true
|
||||||
}
|
}
|
||||||
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
|
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
keys := r.Header.Values("Idempotency-Key")
|
keys := r.Header.Values("Idempotency-Key")
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +67,12 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
|||||||
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
|
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
|
||||||
})
|
})
|
||||||
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := http.NewCrossOriginProtection().Check(r); err != nil {
|
||||||
|
writeConsoleJSON(w, http.StatusForbidden, map[string]any{"error": map[string]string{"code": "PERMISSION_DENIED", "message": "不允许跨站登录请求"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
|
||||||
|
|
||||||
if consoleAuth == nil {
|
if consoleAuth == nil {
|
||||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
|
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
|
||||||
return
|
return
|
||||||
@@ -76,10 +87,23 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), remoteAddress(r))
|
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), loginRemoteAddress(r, consoleAuth))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
|
var blocked *consoleauth.LoginBlocked
|
||||||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
if errors.As(err, &blocked) {
|
||||||
|
seconds := max(1, int(math.Ceil(blocked.RetryAfter.Seconds())))
|
||||||
|
code, message := "LOGIN_RATE_LIMITED", "登录尝试过于频繁,请稍后重试"
|
||||||
|
if blocked.Locked {
|
||||||
|
code, message = "LOGIN_LOCKED", "连续登录失败,已临时锁定,请稍后重试"
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(seconds))
|
||||||
|
writeConsoleJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"code": code, "message": message, "retry_after_seconds": seconds}})
|
||||||
|
} else if errors.Is(err, fault.Unauthorized) {
|
||||||
|
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||||
|
} else {
|
||||||
|
// 存储或审计不可用时不发 Cookie,不将基础设施故障伪装成密码错误。
|
||||||
|
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "登录服务暂不可用,请稍后重试"}})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})
|
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})
|
||||||
@@ -119,11 +143,32 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
|||||||
|
|
||||||
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
|
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
|
||||||
// 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。
|
// 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。
|
||||||
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store) http.Handler {
|
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) http.Handler {
|
||||||
if next == nil || consoleAuth == nil {
|
if next == nil || consoleAuth == nil {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if values := r.Header.Values("X-Orbit-Namespace"); len(values) > 0 {
|
||||||
|
if len(values) != 1 || len(scopes) == 0 || scopes[0] == nil || len(r.Header.Values("Authorization")) > 0 || r.Header.Get("X-Orbit-Console") != "1" || !consoleScopePath(r) {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := http.NewCrossOriginProtection().Check(r); err != nil {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, ok := consoleCookieToken(r)
|
||||||
|
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
|
writeError(w, "", fault.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scoped, err := scopes[0].ResolveScope(r.Context(), token, core.ID(values[0]))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), consoleScopeKey{}, scoped))
|
||||||
|
}
|
||||||
if len(r.Header.Values("Authorization")) == 0 {
|
if len(r.Header.Values("Authorization")) == 0 {
|
||||||
if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
|
if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
|
||||||
clone := r.Clone(r.Context())
|
clone := r.Clone(r.Context())
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, retention: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, scope: null, scopeReady: false, scopeNamespace: '', mutations: 0, retention: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业' };
|
const core = window.OrbitConsole;
|
||||||
|
const label = core.label;
|
||||||
|
const lists = Object.fromEntries(['tasks', 'schedules', 'workflows', 'subscriptions'].map(kind => [kind, new core.ListPage()]));
|
||||||
|
let overviewSequence = 0, healthPromise = null;
|
||||||
|
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||||
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
|
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
|
||||||
}
|
}
|
||||||
|
let loginAuditCursor = '', loginAuditPage = 1, loginAuditLoading = false;
|
||||||
|
async function loadLoginAudit(first = false) {
|
||||||
|
if (loginAuditLoading) return;
|
||||||
|
loginAuditLoading = true;
|
||||||
|
$('login-audit-first').disabled = true; $('login-audit-next').disabled = true;
|
||||||
|
text('login-audit-status', '正在读取…');
|
||||||
|
try {
|
||||||
|
const cursor = first ? '' : loginAuditCursor;
|
||||||
|
const page = extractData(await request(`/console/auth/audit${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`));
|
||||||
|
const labels = { SUCCESS: '登录成功', INVALID: '账号或密码错误', LOCKED: '临时锁定', ACCOUNT_LIMIT: '账号限流', IP_LIMIT: 'IP 限流', PENDING: '校验中', ERROR: '未完成 / 服务异常' };
|
||||||
|
$('login-audit-rows').innerHTML = (page.items || []).map(row => `<tr><td>${escapeHTML(formatTime(row.created_at))}</td><td>${escapeHTML(row.username)}</td><td>${escapeHTML(row.remote_ip)}</td><td>${escapeHTML(labels[row.outcome] || row.outcome)}</td><td>${escapeHTML(formatTime(row.retry_at))}</td></tr>`).join('');
|
||||||
|
loginAuditPage = first ? 1 : loginAuditPage + 1; loginAuditCursor = page.next || '';
|
||||||
|
text('login-audit-page', `第 ${loginAuditPage} 页`); text('login-audit-status', page.items?.length ? '' : '暂无登录记录');
|
||||||
|
} catch (error) { text('login-audit-status', `读取失败:${error.message}`); }
|
||||||
|
finally { loginAuditLoading = false; $('login-audit-first').disabled = false; $('login-audit-next').disabled = !loginAuditCursor; }
|
||||||
|
}
|
||||||
|
$('login-audit-open').addEventListener('click', () => { $('login-audit-dialog').showModal(); loadLoginAudit(true); });
|
||||||
|
$('login-audit-close').addEventListener('click', () => $('login-audit-dialog').close());
|
||||||
|
$('login-audit-first').addEventListener('click', () => loadLoginAudit(true));
|
||||||
|
$('login-audit-next').addEventListener('click', () => loadLoginAudit());
|
||||||
function formatTime(value) {
|
function formatTime(value) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
|
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
|
||||||
@@ -23,13 +47,24 @@
|
|||||||
}
|
}
|
||||||
function setView(view, updateHash = true) {
|
function setView(view, updateHash = true) {
|
||||||
const next = pageNames[view] ? view : 'overview';
|
const next = pageNames[view] ? view : 'overview';
|
||||||
|
if (updateHash && location.hash !== `#/${next}`) { location.hash = `/${next}`; return; }
|
||||||
|
const leavingApplications = state.scopeReady && $('application-scope').hidden && next !== 'applications';
|
||||||
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
|
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
|
||||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next));
|
document.querySelectorAll('.nav-item').forEach(item => { item.classList.toggle('active', item.dataset.view === next); if (item.dataset.view === next) item.setAttribute('aria-current','page'); else item.removeAttribute('aria-current'); });
|
||||||
text('page-title', pageNames[next]);
|
text('page-title', pageNames[next]); document.title = `${pageNames[next]} · Orbit 控制台`;
|
||||||
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
document.querySelector('.scope-queue').hidden = ['workflows','retention','archives'].includes(next);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
$('application-scope').hidden = next === 'applications';
|
||||||
if (next === 'retention' && state.authenticated) loadRetention();
|
if (leavingApplications && state.scope?.can_switch) {
|
||||||
if (next === 'archives' && state.authenticated) loadArchives();
|
scopeUI.nextApplication = '';
|
||||||
|
loadScopeApplications().catch(error => showToast(`应用目录读取失败:${error.message}`));
|
||||||
|
}
|
||||||
|
text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
|
||||||
|
$('main-content').scrollTo({ top: 0, behavior: 'instant' });
|
||||||
|
if (matchMedia('(max-width:640px)').matches) document.querySelector('.nav-item.active')?.scrollIntoView({inline:'nearest',block:'nearest'});
|
||||||
|
if (next === 'retention' && state.scopeReady) loadRetention();
|
||||||
|
if (next === 'archives' && state.scopeReady) loadArchives();
|
||||||
|
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
|
||||||
|
if (state.authenticated && state.scopeReady && ['overview', ...Object.keys(lists)].includes(next)) refresh();
|
||||||
}
|
}
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||||
@@ -42,13 +77,18 @@
|
|||||||
state.authenticated = true; return true;
|
state.authenticated = true; return true;
|
||||||
}
|
}
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
const scoped = path.startsWith('/v1/') || path === '/console/scope' || path.startsWith('/console/scope?') || path.startsWith('/console/retention') || path.startsWith('/console/archive/');
|
||||||
|
const headers = { Accept: 'application/json', ...(scoped && state.scopeNamespace ? { 'X-Orbit-Namespace': state.scopeNamespace, 'X-Orbit-Console': '1' } : {}), ...(options.headers || {}) };
|
||||||
|
const mutation = options.method && options.method !== 'GET';
|
||||||
|
if (mutation) state.mutations++;
|
||||||
|
try {
|
||||||
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
|
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||||
const raw = await response.text(); let body = {};
|
const raw = await response.text(); let body = {};
|
||||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||||
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||||
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
if (!response.ok) { const error = new Error(window.orbitResourceMessage(body?.error?.message || body?.message || `HTTP ${response.status}`)); error.status = response.status; throw error; }
|
||||||
return body;
|
return body;
|
||||||
|
} finally { if (mutation) state.mutations--; }
|
||||||
}
|
}
|
||||||
async function loadConsoleConfig() {
|
async function loadConsoleConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +104,107 @@
|
|||||||
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const scopeUI = { applications: [], nextApplication: '', nextQueue: '', busy: false };
|
||||||
|
function scopeBadge() {
|
||||||
|
const e = state.scope?.entry;
|
||||||
|
if (!e) return '';
|
||||||
|
return `<span class="application-badge" title="${escapeHTML(e.application + ' / ' + e.environment + ' / ' + e.namespace)}"><i></i><b>${escapeHTML(e.application)}</b><em>${escapeHTML(e.environment)}</em></span>`;
|
||||||
|
}
|
||||||
|
function appendScopeOptions(select, items, selected, more, kind) {
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const item of items) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = kind === 'application' ? item.namespace_id : item.id;
|
||||||
|
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : `${item.name}${item.retired_at ? ' · 已退役' : item.paused ? ' · 已暂停' : ''}`;
|
||||||
|
if (kind === 'application') {
|
||||||
|
option.dataset.title = item.application;
|
||||||
|
option.dataset.subtitle = `${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}`;
|
||||||
|
}
|
||||||
|
option.disabled = kind === 'application' && !item.enabled;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
if (!items.length) select.append(new Option('暂无队列', ''));
|
||||||
|
if (more) select.append(new Option('加载更多…', '__more__'));
|
||||||
|
select.value = selected;
|
||||||
|
}
|
||||||
|
async function loadScopeApplications() {
|
||||||
|
const page = extractData(await request(`/console/applications${scopeUI.nextApplication ? `?after=${encodeURIComponent(scopeUI.nextApplication)}` : ''}`));
|
||||||
|
for (const item of page.items || []) if (!scopeUI.applications.some(e => e.namespace_id === item.namespace_id)) scopeUI.applications.push(item);
|
||||||
|
scopeUI.nextApplication = page.next || '';
|
||||||
|
appendScopeOptions($('scope-application'), scopeUI.applications, state.scope.entry.namespace_id, scopeUI.nextApplication, 'application');
|
||||||
|
}
|
||||||
|
async function loadScope() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
state.scopeNamespace = params.get('application') || '';
|
||||||
|
try {
|
||||||
|
const scope = extractData(await request('/console/scope'));
|
||||||
|
state.scope = scope;
|
||||||
|
// 指定管理员在默认应用内也使用显式管理范围,普通账号继续沿用原有队列权限。
|
||||||
|
if (scope.can_switch && !state.scopeNamespace) {
|
||||||
|
state.scopeNamespace = scope.entry.namespace_id;
|
||||||
|
state.scope = extractData(await request('/console/scope'));
|
||||||
|
}
|
||||||
|
const queues = state.scope.queues;
|
||||||
|
const requested = params.get('queue');
|
||||||
|
const preferred = requested || state.consoleConfig?.default_queue_id;
|
||||||
|
state.queue = queues.some(q => q.id === preferred) ? preferred : (queues[0]?.id || '');
|
||||||
|
if (requested && !queues.some(q => q.id === requested)) {
|
||||||
|
const queue = extractData(await request(`/v1/queues/${encodeURIComponent(requested)}`));
|
||||||
|
queues.push({ id: queue.id, name: queue.name, paused: queue.paused, retired_at: queue.retired_at }); state.queue = requested;
|
||||||
|
}
|
||||||
|
scopeUI.nextQueue = state.scope.next || '';
|
||||||
|
scopeUI.applications = [state.scope.entry];
|
||||||
|
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
|
||||||
|
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
|
||||||
|
$('scope-application').disabled = !scope.can_switch;
|
||||||
|
$('login-audit-open').hidden = !scope.can_switch;
|
||||||
|
$('scope-queue').disabled = !queues.length;
|
||||||
|
if (scope.can_switch) {
|
||||||
|
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.detail-scope').forEach(el => { el.innerHTML = scopeBadge(); });
|
||||||
|
$('retention-scope').innerHTML = scopeBadge();
|
||||||
|
text('scope-summary', `${scope.entry.application} / ${scope.entry.environment} / ${scope.entry.namespace}`);
|
||||||
|
state.scopeReady = true; renderConnectionSummary();
|
||||||
|
} catch (error) {
|
||||||
|
state.queue = ''; state.scopeReady = false;
|
||||||
|
text('scope-summary', '范围不可用');
|
||||||
|
$('scope-application').replaceChildren(new Option('范围不可用', ''));
|
||||||
|
$('scope-queue').replaceChildren(new Option('未加载', ''));
|
||||||
|
$('scope-application').disabled = true; $('scope-queue').disabled = true;
|
||||||
|
$('task-table').innerHTML = '<tr><td colspan="7" class="empty-row"><span class="empty-icon">◇</span><b>当前范围未加载</b><small>请检查应用范围,或点击“重置范围”</small></td></tr>';
|
||||||
|
text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function navigateScope(namespace, queue = '') {
|
||||||
|
if (state.mutations || retentionUI.busy || archiveUI.busy || retentionUI.pendingSave || archiveUI.pending) { showToast('有操作尚未确认,请先核对完成后再切换应用'); return false; }
|
||||||
|
if (retentionUI.dirty || archiveUI.dirty || document.querySelector('.drawer-backdrop:not([hidden])')) { showToast('请先保存配置并关闭详情,再切换查看范围'); return false; }
|
||||||
|
const url = new URL(location.href); url.searchParams.delete('application'); url.searchParams.delete('queue');
|
||||||
|
if (namespace) url.searchParams.set('application', namespace);
|
||||||
|
if (queue) url.searchParams.set('queue', queue);
|
||||||
|
// 完整导航隔离不同范围的请求、详情及幂等状态;后退和刷新都恢复 URL 中的范围。
|
||||||
|
location.assign(url.href); return true;
|
||||||
|
}
|
||||||
|
$('scope-application').addEventListener('change', async event => {
|
||||||
|
const value = event.target.value; event.target.value = state.scope?.entry.namespace_id || '';
|
||||||
|
if (scopeUI.busy) return;
|
||||||
|
if (value !== '__more__') { navigateScope(value); return; }
|
||||||
|
scopeUI.busy = true;
|
||||||
|
try { await loadScopeApplications(); } catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
|
||||||
|
});
|
||||||
|
$('scope-queue').addEventListener('change', async event => {
|
||||||
|
const value = event.target.value; event.target.value = state.queue;
|
||||||
|
if (scopeUI.busy) return;
|
||||||
|
if (value !== '__more__') { navigateScope(state.scopeNamespace, value); return; }
|
||||||
|
scopeUI.busy = true;
|
||||||
|
try {
|
||||||
|
const page = extractData(await request(`/console/scope?after=${encodeURIComponent(scopeUI.nextQueue)}`));
|
||||||
|
for (const queue of page.queues) if (!state.scope.queues.some(q => q.id === queue.id)) state.scope.queues.push(queue);
|
||||||
|
scopeUI.nextQueue = page.next || '';
|
||||||
|
appendScopeOptions($('scope-queue'), state.scope.queues, state.queue, scopeUI.nextQueue, 'queue');
|
||||||
|
} catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
|
||||||
|
});
|
||||||
|
$('scope-reset').addEventListener('click', () => navigateScope(''));
|
||||||
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
|
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
|
||||||
function syncRetentionControls() {
|
function syncRetentionControls() {
|
||||||
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
|
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
|
||||||
@@ -220,7 +361,7 @@
|
|||||||
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
|
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
|
||||||
$('archive-executor').classList.toggle('ready', archiveUI.configured);
|
$('archive-executor').classList.toggle('ready', archiveUI.configured);
|
||||||
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
|
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
|
||||||
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div><code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
|
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div>${scopeBadge()}<code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
|
||||||
syncArchiveControls();
|
syncArchiveControls();
|
||||||
}
|
}
|
||||||
async function loadArchives() {
|
async function loadArchives() {
|
||||||
@@ -292,65 +433,103 @@
|
|||||||
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
|
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
|
||||||
$('side-endpoint').textContent = connected ? state.base : 'API 未连接';
|
$('side-endpoint').textContent = connected ? state.base : 'API 未连接';
|
||||||
$('side-progress').style.width = connected ? '100%' : '0%';
|
$('side-progress').style.width = connected ? '100%' : '0%';
|
||||||
document.querySelector('.status-dot').classList.toggle('ready', connected);
|
document.querySelectorAll('.status-dot').forEach(dot => dot.classList.toggle('ready', connected));
|
||||||
document.querySelector('.live-chip').classList.toggle('ready', connected);
|
document.querySelector('.live-chip').classList.toggle('ready', connected);
|
||||||
$('live-label').textContent = connected ? '环境在线' : '本地环境';
|
$('live-label').textContent = connected ? '环境在线' : '本地环境';
|
||||||
renderConnectionSummary();
|
renderConnectionSummary();
|
||||||
if (detail) showToast(detail);
|
if (detail) showToast(detail);
|
||||||
}
|
}
|
||||||
async function loadHealth() {
|
async function loadHealth() {
|
||||||
|
if (healthPromise) return healthPromise;
|
||||||
|
healthPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const [ready, version] = await Promise.all([request('/health/ready'), request('/version')]);
|
||||||
|
if (ready?.status !== 'ready') throw new Error('服务尚未就绪');
|
||||||
|
text('metric-health', '运行正常');
|
||||||
|
text('metric-health-sub', `${version?.version || 'Orbit'} · 数据库已就绪`);
|
||||||
|
setConnection(true); return version;
|
||||||
|
} catch (error) { text('metric-health', '暂不可用'); text('metric-health-sub', '请检查服务与数据库连接'); setConnection(false); throw error; }
|
||||||
|
finally { healthPromise = null; }
|
||||||
|
})();
|
||||||
|
return healthPromise;
|
||||||
|
}
|
||||||
|
function badge(value) { return `<span class="state-pill ${stateClass(value)}" title="${escapeHTML(value)}">${escapeHTML(label(value))}</span>`; }
|
||||||
|
const listBodies = { tasks:'task-table', schedules:'schedule-list', workflows:'workflow-list', subscriptions:'subscription-list' };
|
||||||
|
const listColumns = { tasks:7, schedules:6, workflows:6, subscriptions:6 };
|
||||||
|
const detailOpeners = { tasks:openTaskDetail, schedules:openScheduleDetail, workflows:openWorkflowDetail, subscriptions:openSubscriptionDetail };
|
||||||
|
function resourceButton(kind, item, name) { return `<button class="text-button resource-name" data-resource-kind="${kind}" data-resource-id="${escapeHTML(item.id)}" title="${escapeHTML(item.id)}">${escapeHTML(name || shortID(item.id))}</button>`; }
|
||||||
|
function detailButton(kind, item) { return resourceButton(kind, item, '详情 →'); }
|
||||||
|
function renderList(kind, items) {
|
||||||
|
const body = $(listBodies[kind]);
|
||||||
|
if (!items.length) { body.innerHTML = `<tr><td colspan="${listColumns[kind]}" class="empty-row"><span class="empty-icon">○</span><b>没有符合条件的记录</b><small>检查应用与队列,或调整筛选条件。</small></td></tr>`; return; }
|
||||||
|
body.innerHTML = items.map(item => {
|
||||||
|
let cells;
|
||||||
|
if (kind === 'tasks') cells = `<td><div class="task-id">${resourceButton(kind,item,shortID(item.id))}<small title="${escapeHTML(item.business_key || '')}">${escapeHTML(item.business_key || '未设置业务键')}</small></div></td><td>${scopeBadge()}</td><td class="task-processor"><span title="${escapeHTML(item.handler || '')}">${escapeHTML(item.handler || '—')}</span><small>${escapeHTML(item.handler_version || '')}</small></td><td>${badge(item.state)}</td><td>${escapeHTML(String(item.attempt_count ?? 0))} 次</td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
|
||||||
|
if (kind === 'schedules') cells = `<td>${resourceButton(kind,item,item.name)}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.paused ? 'PAUSED' : 'ENABLED')}</td><td>${escapeHTML(String(item.current_version || '—'))}</td><td>${escapeHTML(item.paused ? '已暂停' : formatTime(item.next_fire_at))}</td>`;
|
||||||
|
if (kind === 'workflows') cells = `<td>${resourceButton(kind,item,item.business_key || shortID(item.id))}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.state)}${item.paused ? ' '+badge('PAUSED') : ''}</td><td>${escapeHTML(item.version || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
|
||||||
|
if (kind === 'subscriptions') cells = `<td>${resourceButton(kind,item,item.name)}<small>${escapeHTML(shortID(item.id))}</small></td><td>${scopeBadge()}</td><td>${badge(item.retired_at ? 'RETIRED' : item.enabled === false ? 'PAUSED' : 'ENABLED')}</td><td>${escapeHTML(String(item.pending_count ?? 0))} <span class="muted">/ ${escapeHTML(String(item.max_pending ?? '—'))}</span></td><td>${escapeHTML(formatTime(item.created_at))}</td>`;
|
||||||
|
return `<tr class="resource-row">${cells}<td>${detailButton(kind,item)}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
function syncPager(kind) {
|
||||||
|
const page = lists[kind];
|
||||||
|
$(`${kind}-next`).disabled = page.loading || !page.next;
|
||||||
|
$(`${kind}-prev`).disabled = page.loading || !page.history.length;
|
||||||
|
$(`${kind}-first`).disabled = page.loading || !page.history.length;
|
||||||
|
text(`${kind}-page`, `第 ${page.history.length+1} 页`);
|
||||||
|
$(listBodies[kind]).closest('table').setAttribute('aria-busy',String(page.loading));
|
||||||
|
}
|
||||||
|
async function loadList(kind, background = false) {
|
||||||
|
const page = lists[kind], ticket = page.begin(); syncPager(kind);
|
||||||
|
$(`${kind}-error`).hidden = true;
|
||||||
|
if (!background) $(listBodies[kind]).innerHTML = `<tr><td colspan="${listColumns[kind]}" class="empty-row">正在读取…</td></tr>`;
|
||||||
try {
|
try {
|
||||||
const [live, version] = await Promise.all([request('/health/live'), request('/version')]);
|
const data = extractData(await request(core.listURL(kind,state.queue,page.filters,page.cursor,page.limit)));
|
||||||
text('metric-health', live?.status === 'alive' ? '在线' : '异常');
|
if (!page.accept(ticket,data)) return;
|
||||||
text('metric-health-sub', `Go ${version?.go_version || '—'} · API v${version?.http_protocol_version || '—'}`);
|
renderList(kind,data.items || []);
|
||||||
text('side-status', '环境已连接');
|
text(`${kind}-summary`, `本页 ${data.items?.length || 0} 条${Object.keys(page.filters).length ? ' · 已应用筛选' : ''} · ${formatTime(new Date())}`);
|
||||||
return version;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
text('metric-health', '离线'); text('metric-health-sub', error.message); setConnection(false); throw error;
|
if (!page.fail(ticket)) return;
|
||||||
}
|
const note = error.status === 403 ? '当前账号没有读取权限,请联系管理员。' : `${error.message}。原筛选与页码已保留,请刷新重试。`;
|
||||||
|
text(`${kind}-error`,note); $(`${kind}-error`).hidden = false;
|
||||||
|
$(listBodies[kind]).innerHTML = `<tr><td colspan="${listColumns[kind]}" class="empty-row">本页读取失败</td></tr>`;
|
||||||
|
text(`${kind}-summary`,'读取失败');
|
||||||
|
} finally { if (ticket === page.sequence) syncPager(kind); }
|
||||||
}
|
}
|
||||||
function renderTasks(items) {
|
function renderOverviewLists(tasks, workflows, subscriptions) {
|
||||||
text('task-count', `${items.length} 项`); text('metric-tasks', String(items.length));
|
text('metric-tasks', tasks ? String(tasks.length) : '—');
|
||||||
const counts = items.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {});
|
text('metric-tasks-sub','当前队列 · 最近 20 条');
|
||||||
const total = Math.max(items.length, 1); const width = (name) => `${(counts[name] || 0) / total * 100}%`;
|
text('metric-workflows',workflows ? String(workflows.length) : '—');
|
||||||
$('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`;
|
text('metric-workflows-sub','当前应用 · 最近 20 条');
|
||||||
text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`);
|
text('metric-subscriptions',subscriptions ? String(subscriptions.length) : '—');
|
||||||
const body = $('task-table');
|
text('metric-subscriptions-sub','当前队列 · 最近 20 条');
|
||||||
if (!items.length) { body.innerHTML = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
const total = tasks?.length || 0;
|
||||||
body.innerHTML = items.slice(0, 8).map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
['succeeded','running','waiting','other'].forEach(name => {
|
||||||
body.querySelectorAll('.task-row').forEach(row => {
|
const values = name === 'running' ? ['RUNNING','LEASED'] : name === 'other' ? ['FAILED','NEEDS_REVIEW','CANCELLED'] : [name.toUpperCase()];
|
||||||
row.addEventListener('click', () => openTaskDetail(row.dataset.taskId));
|
$(`task-bar-${name}`).style.width = `${total ? tasks.filter(t=>values.includes(t.state)).length/total*100 : 0}%`;
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } });
|
|
||||||
});
|
});
|
||||||
|
$('subscription-meter-fill').style.width = `${subscriptions?.length ? subscriptions.filter(s=>s.enabled&&!s.retired_at).length/subscriptions.length*100 : 0}%`;
|
||||||
|
$('overview-task-list').innerHTML = tasks?.length ? tasks.slice(0,6).map(t=>`<tr><td>${resourceButton('tasks',t,shortID(t.id))}<small>${escapeHTML(t.business_key || '未设置业务键')}</small></td><td class="task-processor"><span title="${escapeHTML(t.handler)}">${escapeHTML(t.handler)}</span></td><td>${badge(t.state)}</td><td>${escapeHTML(formatTime(t.created_at))}</td><td>${detailButton('tasks',t)}</td></tr>`).join('') : `<tr><td colspan="5" class="empty-row">${tasks ? '当前队列还没有任务' : '任务暂时无法读取,请检查权限或稍后刷新'}</td></tr>`;
|
||||||
|
const attention = [];
|
||||||
|
const count = (items, value) => items?.filter(i=>i.state===value).length || 0;
|
||||||
|
const taskReview = count(tasks,'NEEDS_REVIEW'), failed = count(tasks,'FAILED'), flowReview = count(workflows,'NEEDS_REVIEW');
|
||||||
|
const pending = subscriptions?.reduce((n,item)=>n+(Number(item.pending_count)||0),0) || 0;
|
||||||
|
const add = (number,kind,title,note,state='') => { if(number) attention.push(`<button class="attention-item" data-attention="${kind}" data-filter-state="${state}"><span class="attention-number">${number}</span><span><strong>${title}</strong><small>${note}</small></span><span>→</span></button>`); };
|
||||||
|
add(taskReview,'tasks','任务等待核查','执行结果不明确,需要先核对证据','NEEDS_REVIEW');
|
||||||
|
add(failed,'tasks','任务执行失败','查看失败原因和执行历史','FAILED');
|
||||||
|
add(flowReview,'workflows','流程等待核查','查看流程步骤和恢复记录','NEEDS_REVIEW');
|
||||||
|
add(pending,'subscriptions','结果等待确认','检查消费者状态和交付历史');
|
||||||
|
$('attention-list').innerHTML = attention.join('') || `<div class="attention-clear"><strong>${tasks && workflows && subscriptions ? '当前快照暂无异常' : '部分数据暂不可用'}</strong>${tasks && workflows && subscriptions ? '最近记录中没有待核查任务、失败任务或未确认结果。' : '请根据页面提示检查连接和读取权限。'}</div>`;
|
||||||
}
|
}
|
||||||
function renderSchedules(items) {
|
async function loadOverview() {
|
||||||
text('schedule-page-hint', `${items.length} 项 · 点击计划查看详情`);
|
const ticket = ++overviewSequence;
|
||||||
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>需要 schedules:read 权限</small></div></div>'; return; }
|
const kinds = ['tasks','schedules','workflows','subscriptions'];
|
||||||
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
|
const results = await Promise.allSettled(kinds.map(kind => !state.queue && kind!=='workflows' ? Promise.resolve({data:{items:[]}}) : request(core.listURL(kind,state.queue))));
|
||||||
el.querySelectorAll('.schedule-row').forEach(row => {
|
if (ticket !== overviewSequence) return;
|
||||||
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
|
const items = results.map(result=>result.status==='fulfilled' ? extractItems(result.value) : null);
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
|
renderOverviewLists(items[0],items[2],items[3]); renderRuntimeStats(...items);
|
||||||
});
|
const failed = kinds.filter((_,i)=>results[i].status==='rejected');
|
||||||
}
|
if (currentView()==='overview') { $('refresh-error').hidden = !failed.length; text('refresh-error',failed.length ? `${failed.map(k=>pageNames[k]).join('、')}读取失败,其他统计仅基于已成功读取的数据。请检查权限或刷新重试。` : ''); }
|
||||||
function renderWorkflows(items) {
|
|
||||||
text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
|
|
||||||
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; }
|
|
||||||
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
|
|
||||||
el.querySelectorAll('.workflow-row').forEach(row => {
|
|
||||||
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
|
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function renderSubscriptions(items) {
|
|
||||||
text('metric-subscriptions', String(items.length));
|
|
||||||
const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
|
|
||||||
text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
|
|
||||||
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; }
|
|
||||||
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
|
|
||||||
el.querySelectorAll('.subscription-row').forEach(row => {
|
|
||||||
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
|
|
||||||
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function renderRuntimeStats(tasks, schedules, workflows, subscriptions) {
|
function renderRuntimeStats(tasks, schedules, workflows, subscriptions) {
|
||||||
if (Array.isArray(tasks)) {
|
if (Array.isArray(tasks)) {
|
||||||
@@ -360,25 +539,25 @@
|
|||||||
text('stat-success-rate-sub', total ? `${counts.succeeded || 0} 成功 / ${total} 项` : '当前没有任务');
|
text('stat-success-rate-sub', total ? `${counts.succeeded || 0} 成功 / ${total} 项` : '当前没有任务');
|
||||||
text('stat-task-total', `${total} 项`);
|
text('stat-task-total', `${total} 项`);
|
||||||
const waiting = (counts.waiting || 0) + (counts.pending || 0);
|
const waiting = (counts.waiting || 0) + (counts.pending || 0);
|
||||||
const other = (counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0);
|
const other = (counts.failed || 0) + (counts.needs_review || 0);
|
||||||
const width = value => `${total ? value / total * 100 : 0}%`;
|
const width = value => `${total ? value / total * 100 : 0}%`;
|
||||||
[['succeeded', counts.succeeded || 0], ['running', counts.running || 0], ['waiting', waiting], ['other', other]].forEach(([name, value]) => { $(`stat-bar-${name}`).style.width = width(value); text(`stat-count-${name}`, String(value)); });
|
[['succeeded', counts.succeeded || 0], ['running', (counts.running || 0) + (counts.leased || 0)], ['waiting', waiting], ['other', other], ['cancelled',counts.cancelled || 0]].forEach(([name, value]) => { $(`stat-bar-${name}`).style.width = width(value); text(`stat-count-${name}`, String(value)); });
|
||||||
} else {
|
} else {
|
||||||
text('stat-success-rate', '—'); text('stat-success-rate-sub', '暂无任务权限'); text('stat-task-total', '暂无数据');
|
text('stat-success-rate', '—'); text('stat-success-rate-sub', '任务读取失败'); text('stat-task-total', '暂无数据');
|
||||||
['succeeded', 'running', 'waiting', 'other'].forEach(name => { $(`stat-bar-${name}`).style.width = '0%'; text(`stat-count-${name}`, '—'); });
|
['succeeded', 'running', 'waiting', 'other', 'cancelled'].forEach(name => { $(`stat-bar-${name}`).style.width = '0%'; text(`stat-count-${name}`, '—'); });
|
||||||
}
|
}
|
||||||
if (Array.isArray(schedules)) {
|
if (Array.isArray(schedules)) {
|
||||||
const active = schedules.filter(item => item.paused !== true).length;
|
const active = schedules.filter(item => item.paused !== true).length;
|
||||||
text('stat-active-schedules', String(active)); text('stat-active-schedules-sub', `${schedules.length} 项计划 · ${schedules.length - active} 项暂停`);
|
text('stat-active-schedules', String(active)); text('stat-active-schedules-sub', `最近 ${schedules.length} 项计划 · ${schedules.length - active} 项暂停`);
|
||||||
} else { text('stat-active-schedules', '—'); text('stat-active-schedules-sub', '暂无计划权限'); }
|
} else { text('stat-active-schedules', '—'); text('stat-active-schedules-sub', '计划读取失败'); }
|
||||||
if (Array.isArray(workflows)) {
|
if (Array.isArray(workflows)) {
|
||||||
const review = workflows.filter(item => stateClass(item.state) === 'needs_review').length;
|
const review = workflows.filter(item => stateClass(item.state) === 'needs_review').length;
|
||||||
text('stat-review-workflows', String(review)); text('stat-review-workflows-sub', `${workflows.length} 项流程实例`);
|
text('stat-review-workflows', String(review)); text('stat-review-workflows-sub', `最近 ${workflows.length} 项流程实例`);
|
||||||
} else { text('stat-review-workflows', '—'); text('stat-review-workflows-sub', '暂无流程权限'); }
|
} else { text('stat-review-workflows', '—'); text('stat-review-workflows-sub', '流程读取失败'); }
|
||||||
if (Array.isArray(subscriptions)) {
|
if (Array.isArray(subscriptions)) {
|
||||||
const pending = subscriptions.reduce((sum, item) => sum + (Number(item.pending_count) || 0), 0);
|
const pending = subscriptions.reduce((sum, item) => sum + (Number(item.pending_count) || 0), 0);
|
||||||
text('stat-pending-deliveries', String(pending)); text('stat-pending-deliveries-sub', `${subscriptions.length} 个订阅 · 待确认结果`);
|
text('stat-pending-deliveries', String(pending)); text('stat-pending-deliveries-sub', `${subscriptions.length} 个订阅 · 待确认结果`);
|
||||||
} else { text('stat-pending-deliveries', '—'); text('stat-pending-deliveries-sub', '暂无事件权限'); }
|
} else { text('stat-pending-deliveries', '—'); text('stat-pending-deliveries-sub', '订阅读取失败'); }
|
||||||
text('stats-snapshot', state.connected ? `更新于 ${formatTime(new Date())}` : '等待连接');
|
text('stats-snapshot', state.connected ? `更新于 ${formatTime(new Date())}` : '等待连接');
|
||||||
}
|
}
|
||||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||||
@@ -391,8 +570,32 @@
|
|||||||
return `console-${prefix}-${id}`.slice(0, 128);
|
return `console-${prefix}-${id}`.slice(0, 128);
|
||||||
}
|
}
|
||||||
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
||||||
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
let drawerSequence = 0, drawerOpener = null;
|
||||||
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
|
function showDrawer(kind) {
|
||||||
|
const backdrop = $(`${kind}-drawer-backdrop`);
|
||||||
|
if (backdrop.hidden) drawerOpener = document.activeElement;
|
||||||
|
backdrop.hidden = false; document.body.classList.add('drawer-open');
|
||||||
|
document.querySelector('.app-shell').inert = true;
|
||||||
|
backdrop.querySelectorAll('.detail-grid b').forEach(el => { el.textContent = '—'; });
|
||||||
|
$(`close-${kind}-drawer`).focus();
|
||||||
|
return ++drawerSequence;
|
||||||
|
}
|
||||||
|
function hideDrawer(kind) {
|
||||||
|
const backdrop = $(`${kind}-drawer-backdrop`); if (backdrop.hidden) return;
|
||||||
|
drawerSequence++; backdrop.hidden = true; document.body.classList.remove('drawer-open');
|
||||||
|
document.querySelector('.app-shell').inert = false;
|
||||||
|
if (drawerOpener?.isConnected) drawerOpener.focus();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
const drawer = document.querySelector('.drawer-backdrop:not([hidden])');
|
||||||
|
if (event.key !== 'Tab' || !drawer) return;
|
||||||
|
const focusable = [...drawer.querySelectorAll('button,input,select,textarea,a[href],summary')].filter(el=>!el.disabled && el.getClientRects().length);
|
||||||
|
const first = focusable[0], last = focusable.at(-1);
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||||
|
});
|
||||||
|
function showTaskDrawer() { return showDrawer('task'); }
|
||||||
|
function closeTaskDrawer() { hideDrawer('task'); closeActionForm(); }
|
||||||
function renderDetailList(id, items, emptyText, renderItem) {
|
function renderDetailList(id, items, emptyText, renderItem) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
if (!items.length) { el.innerHTML = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
|
if (!items.length) { el.innerHTML = `<div class="detail-empty">${escapeHTML(emptyText)}</div>`; return; }
|
||||||
@@ -400,12 +603,12 @@
|
|||||||
}
|
}
|
||||||
function renderTaskDetail(task) {
|
function renderTaskDetail(task) {
|
||||||
state.selectedTask = task;
|
state.selectedTask = task;
|
||||||
text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN');
|
text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', label(task.state));
|
||||||
$('detail-state').className = `state-pill ${stateClass(task.state)}`;
|
$('detail-state').className = `state-pill ${stateClass(task.state)}`;
|
||||||
text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`);
|
text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`);
|
||||||
text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '待核查' : '已确认');
|
text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '存在未知效果,待核查' : '未发现未知效果');
|
||||||
text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at));
|
text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at));
|
||||||
const resultBlock = $('detail-result-block'); resultBlock.hidden = !task.result; if (task.result) $('detail-result').textContent = formatJSON(task.result);
|
const resultBlock = $('detail-result-block'); resultBlock.hidden = task.result === undefined; if (task.result !== undefined) $('detail-result').textContent = formatJSON(task.result);
|
||||||
const canCancel = !terminalState(task.state) && !task.cancel_requested_at;
|
const canCancel = !terminalState(task.state) && !task.cancel_requested_at;
|
||||||
const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain;
|
const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain;
|
||||||
$('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry;
|
$('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry;
|
||||||
@@ -413,19 +616,20 @@
|
|||||||
}
|
}
|
||||||
function renderAttempts(items, error) {
|
function renderAttempts(items, error) {
|
||||||
text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
|
text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`);
|
||||||
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
|
renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(label(item.state))}</b><small>${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${item.retryable ? '可重试' : '不可重试'}</em></div>`);
|
||||||
}
|
}
|
||||||
function renderOperations(items, error) {
|
function renderOperations(items, error) {
|
||||||
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
|
text('detail-operations-count', error ? '权限不足' : `${items.length} 条`);
|
||||||
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.action || '操作')}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
|
renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(label(item.action || '操作'))}</b><small>${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}</small></div><em>${escapeHTML(shortID(item.id))}</em></div>`);
|
||||||
}
|
}
|
||||||
async function openTaskDetail(id) {
|
async function openTaskDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.authenticated) { redirectToLogin(); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
const ticket = showTaskDrawer(); state.selectedTask = null; closeActionForm(); $('detail-result-block').hidden = true; text('detail-title', '正在读取任务…'); text('detail-id', id); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
||||||
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
||||||
if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); return; }
|
if (ticket !== drawerSequence) return;
|
||||||
|
if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); text('detail-state','不可用'); renderAttempts([],false); renderOperations([],false); return; }
|
||||||
const task = extractData(taskResult.value); renderTaskDetail(task);
|
const task = extractData(taskResult.value); renderTaskDetail(task);
|
||||||
renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected');
|
renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected');
|
||||||
renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected');
|
renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected');
|
||||||
@@ -453,27 +657,28 @@
|
|||||||
catch (error) { $('action-error').textContent = error.message || '操作失败'; }
|
catch (error) { $('action-error').textContent = error.message || '操作失败'; }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
}
|
}
|
||||||
function showScheduleDrawer() { $('schedule-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
function showScheduleDrawer() { return showDrawer('schedule'); }
|
||||||
function closeScheduleDrawer() { $('schedule-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeScheduleAction(); }
|
function closeScheduleDrawer() { hideDrawer('schedule'); closeScheduleAction(); }
|
||||||
function closeScheduleAction() { $('schedule-action-form').hidden = true; $('schedule-action-error').textContent = ''; }
|
function closeScheduleAction() { $('schedule-action-form').hidden = true; $('schedule-action-error').textContent = ''; }
|
||||||
function scheduleRuleLabel(rule) { if (!rule) return '—'; return rule.kind === 'CRON' ? `Cron · ${rule.cron || '—'}` : `固定间隔 · ${rule.interval_ms || '—'} ms`; }
|
function scheduleRuleLabel(rule) { if (!rule) return '—'; return rule.kind === 'CRON' ? `Cron · ${rule.cron || '—'}` : `固定间隔 · ${rule.interval_ms || '—'} ms`; }
|
||||||
function renderScheduleOccurrences(items, error) {
|
function renderScheduleOccurrences(items, error) {
|
||||||
text('schedule-occurrences-count', error ? '权限不足' : `${items.length} 条`);
|
text('schedule-occurrences-count', error ? '权限不足' : `${items.length} 条`);
|
||||||
renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${String(item.decision || '').toLowerCase()}"></span><div><b>${escapeHTML(item.decision || 'UNKNOWN')} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(item.reason || '—')} · 版本 ${escapeHTML(item.schedule_version || '—')}</small></div><em>${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}</em></div>`);
|
renderDetailList('schedule-occurrences', items, error ? '当前凭据没有 schedules:read 触发历史权限。' : '还没有触发记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${String(item.decision || '').toLowerCase()}"></span><div><b>${escapeHTML(label(item.decision || 'UNKNOWN'))} · ${escapeHTML(formatTime(item.scheduled_at))}</b><small>${escapeHTML(label(item.reason || '—'))} · 版本 ${escapeHTML(item.schedule_version || '—')}</small></div><em>${item.task_id ? escapeHTML(shortID(item.task_id)) : '无任务'}</em></div>`);
|
||||||
}
|
}
|
||||||
function renderScheduleDetail(schedule) {
|
function renderScheduleDetail(schedule) {
|
||||||
state.selectedSchedule = schedule;
|
state.selectedSchedule = schedule;
|
||||||
text('schedule-detail-title', schedule.name || '定时计划'); text('schedule-detail-id', schedule.id || '—'); text('schedule-detail-state', schedule.paused ? '已暂停' : '运行中'); $('schedule-detail-state').className = `state-pill ${schedule.paused ? 'waiting' : 'succeeded'}`;
|
text('schedule-detail-title', schedule.name || '定时计划'); text('schedule-detail-id', schedule.id || '—'); text('schedule-detail-state', schedule.paused ? '已暂停' : '运行中'); $('schedule-detail-state').className = `state-pill ${schedule.paused ? 'waiting' : 'succeeded'}`;
|
||||||
text('schedule-detail-updated', `最后更新 ${formatTime(schedule.updated_at)}`); text('schedule-detail-name', schedule.name || '—'); text('schedule-detail-queue', shortID(schedule.queue_id)); text('schedule-detail-revision', schedule.revision || '—'); text('schedule-detail-version', schedule.current_version || '—'); text('schedule-detail-next', formatTime(schedule.next_fire_at));
|
text('schedule-detail-updated', `最后更新 ${formatTime(schedule.updated_at)}`); text('schedule-detail-name', schedule.name || '—'); text('schedule-detail-queue', shortID(schedule.queue_id)); text('schedule-detail-revision', schedule.revision || '—'); text('schedule-detail-version', schedule.current_version || '—'); text('schedule-detail-next', schedule.paused ? `已暂停(原触发点 ${formatTime(schedule.next_fire_at)})` : formatTime(schedule.next_fire_at));
|
||||||
const definition = schedule.current?.definition || {}; const rule = definition.rule || {}; text('schedule-detail-timezone', rule.timezone || '—'); text('schedule-detail-rule-kind', rule.kind || '—'); $('schedule-detail-rule').innerHTML = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(definition.policy?.misfire || '—')} · 重叠 ${escapeHTML(definition.policy?.overlap || '—')} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}</small>`;
|
const definition = schedule.current?.definition || {}; const rule = definition.rule || {}; text('schedule-detail-timezone', rule.timezone || '—'); text('schedule-detail-rule-kind', label(rule.kind)); $('schedule-detail-rule').innerHTML = `<b>${escapeHTML(scheduleRuleLabel(rule))}</b><small>漏跑 ${escapeHTML(label(definition.policy?.misfire || '—'))} · 重叠 ${escapeHTML(label(definition.policy?.overlap || '—'))} · 补偿上限 ${escapeHTML(definition.policy?.catchup_limit || '—')}</small>`;
|
||||||
$('schedule-actions').hidden = false; text('schedule-control-button', schedule.paused ? '恢复计划' : '暂停计划'); text('schedule-action-note', schedule.paused ? '恢复会按冻结的漏跑策略继续处理暂停期间的触发点。' : '暂停只停止未来触发,已经创建的任务继续执行。');
|
$('schedule-actions').hidden = false; text('schedule-control-button', schedule.paused ? '恢复计划' : '暂停计划'); text('schedule-action-note', schedule.paused ? '恢复会按冻结的漏跑策略继续处理暂停期间的触发点。' : '暂停只停止未来触发,已经创建的任务继续执行。');
|
||||||
}
|
}
|
||||||
async function openScheduleDetail(id) {
|
async function openScheduleDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.authenticated) { redirectToLogin(); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
const ticket = showScheduleDrawer(); state.selectedSchedule = null; closeScheduleAction(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', id); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
||||||
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; }
|
if (ticket !== drawerSequence) return;
|
||||||
|
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); text('schedule-occurrences','无法读取触发记录'); return; }
|
||||||
renderScheduleDetail(extractData(scheduleResult.value)); renderScheduleOccurrences(occurrencesResult.status === 'fulfilled' ? extractItems(occurrencesResult.value) : [], occurrencesResult.status === 'rejected');
|
renderScheduleDetail(extractData(scheduleResult.value)); renderScheduleOccurrences(occurrencesResult.status === 'fulfilled' ? extractItems(occurrencesResult.value) : [], occurrencesResult.status === 'rejected');
|
||||||
}
|
}
|
||||||
function openScheduleAction() {
|
function openScheduleAction() {
|
||||||
@@ -488,14 +693,14 @@
|
|||||||
catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; }
|
catch (error) { $('schedule-action-error').textContent = error.message || '计划控制失败'; }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
}
|
}
|
||||||
function showWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
function showWorkflowDrawer() { return showDrawer('workflow'); }
|
||||||
function closeWorkflowAction() { state.workflowActionMode = ''; $('workflow-action-form').hidden = true; $('workflow-action-error').textContent = ''; }
|
function closeWorkflowAction() { state.workflowActionMode = ''; $('workflow-action-form').hidden = true; $('workflow-action-error').textContent = ''; }
|
||||||
function closeWorkflowRecovery() { state.workflowRecoveryMode = ''; state.workflowRecoveryContext = null; $('workflow-recovery-form').hidden = true; $('workflow-recovery-error').textContent = ''; }
|
function closeWorkflowRecovery() { state.workflowRecoveryMode = ''; state.workflowRecoveryContext = null; $('workflow-recovery-form').hidden = true; $('workflow-recovery-error').textContent = ''; }
|
||||||
function closeWorkflowDrawer() { $('workflow-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeWorkflowAction(); closeWorkflowRecovery(); }
|
function closeWorkflowDrawer() { hideDrawer('workflow'); closeWorkflowAction(); closeWorkflowRecovery(); }
|
||||||
function showSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
function showSubscriptionDrawer() { return showDrawer('subscription'); }
|
||||||
function closeSubscriptionAction() { state.subscriptionActionMode = ''; $('subscription-action-form').hidden = true; $('subscription-action-error').textContent = ''; }
|
function closeSubscriptionAction() { state.subscriptionActionMode = ''; $('subscription-action-form').hidden = true; $('subscription-action-error').textContent = ''; }
|
||||||
function closeSubscriptionRetry() { state.selectedDelivery = null; $('subscription-retry-form').hidden = true; $('subscription-retry-error').textContent = ''; }
|
function closeSubscriptionRetry() { state.selectedDelivery = null; $('subscription-retry-form').hidden = true; $('subscription-retry-error').textContent = ''; }
|
||||||
function closeSubscriptionDrawer() { $('subscription-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeSubscriptionAction(); closeSubscriptionRetry(); }
|
function closeSubscriptionDrawer() { hideDrawer('subscription'); closeSubscriptionAction(); closeSubscriptionRetry(); }
|
||||||
function openWorkflowAction(mode) {
|
function openWorkflowAction(mode) {
|
||||||
if (!state.selectedWorkflow) return;
|
if (!state.selectedWorkflow) return;
|
||||||
state.workflowActionMode = mode; $('workflow-action-form').hidden = false; $('workflow-action-error').textContent = ''; $('workflow-action-reason').value = '';
|
state.workflowActionMode = mode; $('workflow-action-form').hidden = false; $('workflow-action-error').textContent = ''; $('workflow-action-reason').value = '';
|
||||||
@@ -590,7 +795,7 @@
|
|||||||
}
|
}
|
||||||
function renderWorkflowDetail(workflow) {
|
function renderWorkflowDetail(workflow) {
|
||||||
state.selectedWorkflow = workflow;
|
state.selectedWorkflow = workflow;
|
||||||
text('workflow-detail-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', workflow.state || 'UNKNOWN'); $('workflow-detail-state').className = `state-pill ${stateClass(workflow.state)}`;
|
text('workflow-detail-title', workflow.business_key || '流程实例'); text('workflow-detail-id', workflow.id || '—'); text('workflow-detail-state', label(workflow.state)); $('workflow-detail-state').className = `state-pill ${stateClass(workflow.state)}`;
|
||||||
text('workflow-detail-updated', `最后更新 ${formatTime(workflow.updated_at)}`); text('workflow-detail-definition', workflow.definition_id || '—'); text('workflow-detail-version', workflow.version || '—'); text('workflow-detail-revision', workflow.revision || '—'); text('workflow-detail-control-revision', workflow.control_revision || '—'); text('workflow-detail-business-key', workflow.business_key || '—'); text('workflow-detail-deadline', formatTime(workflow.deadline_at));
|
text('workflow-detail-updated', `最后更新 ${formatTime(workflow.updated_at)}`); text('workflow-detail-definition', workflow.definition_id || '—'); text('workflow-detail-version', workflow.version || '—'); text('workflow-detail-revision', workflow.revision || '—'); text('workflow-detail-control-revision', workflow.control_revision || '—'); text('workflow-detail-business-key', workflow.business_key || '—'); text('workflow-detail-deadline', formatTime(workflow.deadline_at));
|
||||||
const outputBlock = $('workflow-output-block'); const hasOutput = Object.prototype.hasOwnProperty.call(workflow, 'output'); outputBlock.hidden = !hasOutput; if (hasOutput) $('workflow-output').textContent = formatJSON(workflow.output) || 'null';
|
const outputBlock = $('workflow-output-block'); const hasOutput = Object.prototype.hasOwnProperty.call(workflow, 'output'); outputBlock.hidden = !hasOutput; if (hasOutput) $('workflow-output').textContent = formatJSON(workflow.output) || 'null';
|
||||||
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(String(workflow.state || '')); const canCancel = !terminal && !workflow.cancel_requested_at && !workflow.stop_requested_at; $('workflow-actions').hidden = terminal || (!canCancel && !workflow.paused); $('workflow-control-button').hidden = terminal; $('workflow-cancel-button').hidden = !canCancel; text('workflow-control-button', workflow.paused ? '恢复流程' : '暂停流程'); text('workflow-detail-note', workflow.state === 'NEEDS_REVIEW' ? '流程处于人工核查状态;暂停/恢复/取消不会替代带证据的恢复 API。控制写入仍由服务端校验权限、控制修订和审计。' : '流程控制需要 workflows:control 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。');
|
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(String(workflow.state || '')); const canCancel = !terminal && !workflow.cancel_requested_at && !workflow.stop_requested_at; $('workflow-actions').hidden = terminal || (!canCancel && !workflow.paused); $('workflow-control-button').hidden = terminal; $('workflow-cancel-button').hidden = !canCancel; text('workflow-control-button', workflow.paused ? '恢复流程' : '暂停流程'); text('workflow-detail-note', workflow.state === 'NEEDS_REVIEW' ? '流程处于人工核查状态;暂停/恢复/取消不会替代带证据的恢复 API。控制写入仍由服务端校验权限、控制修订和审计。' : '流程控制需要 workflows:control 权限;服务端会再次校验控制修订、状态、幂等键和操作原因。');
|
||||||
@@ -600,19 +805,20 @@
|
|||||||
text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`);
|
text('workflow-steps-count', error ? '权限不足' : `${items.length} 个`);
|
||||||
renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => {
|
renderDetailList('workflow-steps', items, error ? '当前凭据没有 workflows:read 步骤权限。' : '该实例还没有可展示的步骤。', item => {
|
||||||
const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活');
|
const link = item.task_id ? `任务 ${shortID(item.task_id)}` : (item.activation_id ? `激活 ${shortID(item.activation_id)}` : '未激活');
|
||||||
return `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(item.state || 'UNKNOWN')} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
|
return `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.key || item.id || '未命名步骤')} · ${escapeHTML(item.kind || 'STEP')}</b><small>${escapeHTML(label(item.state))} · ${escapeHTML(link)} · 修订 ${escapeHTML(item.revision || '—')}</small></div><em>${item.activation_sequence ? `第 ${escapeHTML(item.activation_sequence)} 代` : '—'}</em></div>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderWorkflowEvents(payload, error) {
|
function renderWorkflowEvents(payload, error) {
|
||||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
text('workflow-events-count', error ? '权限不足' : `${items.length} 条`);
|
text('workflow-events-count', error ? '权限不足' : `${items.length} 条`);
|
||||||
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(item.type || '状态事件')} · ${escapeHTML(item.state || '—')}</b><small>${escapeHTML(item.state_scope || item.aggregate_kind || 'WORKFLOW')} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
|
renderDetailList('workflow-events', items, error ? '当前凭据没有 workflows:audit:read 事件审计权限。' : '还没有保存的状态事件。', item => `<div class="detail-list-row"><span class="timeline-dot audit"></span><div><b>${escapeHTML(label(item.type || '状态事件'))} · ${escapeHTML(label(item.state || '—'))}</b><small>${escapeHTML(label(item.state_scope || item.aggregate_kind || 'WORKFLOW'))} ${item.step_key ? `· ${escapeHTML(item.step_key)}` : ''} · ${escapeHTML(formatTime(item.occurred_at))}</small></div><em>修订 ${escapeHTML(item.aggregate_revision || '—')}</em></div>`);
|
||||||
}
|
}
|
||||||
async function openWorkflowDetail(id) {
|
async function openWorkflowDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.authenticated) { redirectToLogin(); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
const ticket = showWorkflowDrawer(); state.selectedWorkflow = null; closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', id); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
|
||||||
|
if (ticket !== drawerSequence) return;
|
||||||
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
|
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
|
||||||
const workflow = extractData(workflowResult.value); const stepsPayload = stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {};
|
const workflow = extractData(workflowResult.value); const stepsPayload = stepsResult.status === 'fulfilled' ? extractData(stepsResult.value) : {};
|
||||||
renderWorkflowDetail(workflow);
|
renderWorkflowDetail(workflow);
|
||||||
@@ -622,26 +828,28 @@
|
|||||||
}
|
}
|
||||||
function renderSubscriptionDetail(subscription) {
|
function renderSubscriptionDetail(subscription) {
|
||||||
state.selectedSubscription = subscription;
|
state.selectedSubscription = subscription;
|
||||||
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
text('subscription-detail-title', subscription.name || '结果订阅'); text('subscription-detail-id', subscription.id || '—'); text('subscription-detail-state', subscription.retired_at ? '已退役' : subscription.enabled === false ? '已暂停' : '已启用'); $('subscription-detail-state').className = `state-pill ${subscription.enabled === false ? 'waiting' : 'succeeded'}`;
|
||||||
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.join(', ') : '全部结果类型');
|
text('subscription-detail-updated', `最后更新 ${formatTime(subscription.updated_at)}`); text('subscription-detail-name', subscription.name || '—'); text('subscription-detail-queue', shortID(subscription.queue_id)); text('subscription-detail-revision', subscription.revision || '—'); text('subscription-detail-pending', subscription.pending_count ?? '0'); text('subscription-detail-max-pending', subscription.max_pending ?? '—'); text('subscription-detail-max-attempts', subscription.max_attempts ?? '—'); text('subscription-detail-cursor', subscription.cursor_sequence ?? '0'); text('subscription-detail-types', Array.isArray(subscription.types) && subscription.types.length ? subscription.types.map(label).join('、') : '全部结果类型');
|
||||||
$('subscription-actions').hidden = false; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
$('subscription-actions').hidden = !!subscription.retired_at; text('subscription-control-button', subscription.enabled === false ? '启用订阅' : '暂停订阅'); text('subscription-detail-note', subscription.retired_at ? `退役于 ${formatTime(subscription.retired_at)},截止水位 ${subscription.retired_sequence};未来结果不再订阅,历史交付仍可查询。` : '订阅控制需要 events:manage 权限;暂停只停止新交付,已有租约仍可确认。过期交付可追加预算重试,但不会重置原次数和事件身份。');
|
||||||
}
|
}
|
||||||
function renderSubscriptionDeliveries(payload, error) {
|
function renderSubscriptionDeliveries(payload, error) {
|
||||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
text('subscription-deliveries-count', error ? '权限不足' : `${items.length} 条`);
|
text('subscription-deliveries-count', error ? '权限不足' : `${items.length} 条`);
|
||||||
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(item.state || 'UNKNOWN')} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
renderDetailList('subscription-deliveries', items, error ? '当前凭据没有 events:read 交付历史权限。' : '还没有交付记录。', item => `<div class="detail-list-row"><span class="timeline-dot ${stateClass(item.state)}"></span><div><b>${escapeHTML(label(item.state))} · 序号 ${escapeHTML(item.sequence || '—')}</b><small>${escapeHTML(formatTime(item.created_at))} · 尝试 ${escapeHTML(item.attempts ?? 0)}/${escapeHTML(item.attempt_budget ?? '—')} · 可用 ${escapeHTML(formatTime(item.available_at))}</small></div>${String(item.state || '') === 'EXPIRED' ? `<button class="detail-row-action" type="button" data-delivery-id="${escapeHTML(item.id)}">重试</button>` : `<em>${escapeHTML(shortID(item.event_id))}</em>`}</div>`);
|
||||||
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
|
$('subscription-deliveries').querySelectorAll('.detail-row-action').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); openSubscriptionRetry(button.dataset.deliveryId, items.find(item => item.id === button.dataset.deliveryId)); }));
|
||||||
}
|
}
|
||||||
function openSubscriptionAction() {
|
let subscriptionRetirement = null;
|
||||||
|
function openSubscriptionAction(mode) {
|
||||||
if (!state.selectedSubscription) return;
|
if (!state.selectedSubscription) return;
|
||||||
|
subscriptionRetirement = null;
|
||||||
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
|
state.subscriptionActionMode = state.selectedSubscription.enabled === false ? 'ENABLE' : 'DISABLE'; $('subscription-action-form').hidden = false; $('subscription-action-error').textContent = ''; $('subscription-action-reason').value = '';
|
||||||
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); $('subscription-action-reason').focus();
|
const enabled = state.subscriptionActionMode === 'ENABLE'; text('subscription-action-title', enabled ? '启用订阅' : '暂停订阅'); text('subscription-action-description', enabled ? '启用后会继续物化和领取新的结果交付。' : '暂停只停止新交付,已有租约和待确认结果仍可由接收方处理。'); text('subscription-action-submit', enabled ? '提交启用 →' : '提交暂停 →'); if (mode === 'RETIRE') { state.subscriptionActionMode = 'RETIRE'; text('subscription-action-title','永久退役订阅'); text('subscription-action-description','仅在历史结果全部确认后成功。退役不可恢复,后续结果不再订阅;历史交付和审计保留,活动名额释放。'); text('subscription-action-submit','确认退役 →'); } $('subscription-action-reason').focus();
|
||||||
}
|
}
|
||||||
async function submitSubscriptionAction(event) {
|
async function submitSubscriptionAction(event) {
|
||||||
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
|
event.preventDefault(); if (!state.selectedSubscription || !state.subscriptionActionMode) return;
|
||||||
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
|
const reason = $('subscription-action-reason').value.trim(); if (!reason) { $('subscription-action-error').textContent = '请填写操作原因。'; return; }
|
||||||
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
|
const subscription = state.selectedSubscription; const enabled = state.subscriptionActionMode === 'ENABLE'; const button = $('subscription-action-submit'); button.disabled = true; $('subscription-action-error').textContent = '';
|
||||||
try { await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:configure`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify({ expected_revision: String(subscription.revision), enabled, reason }) }); showToast(enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
try { const retire = state.subscriptionActionMode === 'RETIRE'; if (retire) subscriptionRetirement ||= {key:operationKey('subscription-retire'),body:{expected_revision:String(subscription.revision),reason}}; await request(`/v1/subscriptions/${encodeURIComponent(subscription.id)}:${retire ? 'retire' : 'configure'}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': retire ? subscriptionRetirement.key : operationKey(enabled ? 'subscription-enable' : 'subscription-disable') }, body: JSON.stringify(retire ? subscriptionRetirement.body : { expected_revision: String(subscription.revision), enabled, reason }) }); showToast(retire ? '订阅已退役,历史保留' : enabled ? '订阅已启用' : '订阅已暂停'); closeSubscriptionAction(); await refresh(); await openSubscriptionDetail(subscription.id); }
|
||||||
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
|
catch (error) { $('subscription-action-error').textContent = error.message || '订阅控制失败'; }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
}
|
}
|
||||||
@@ -661,32 +869,50 @@
|
|||||||
async function openSubscriptionDetail(id) {
|
async function openSubscriptionDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.authenticated) { redirectToLogin(); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
const ticket = showSubscriptionDrawer(); state.selectedSubscription = null; closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', id); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
|
||||||
|
if (ticket !== drawerSequence) return;
|
||||||
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
|
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
|
||||||
renderSubscriptionDetail(extractData(subscriptionResult.value)); renderSubscriptionDeliveries(deliveriesResult.status === 'fulfilled' ? extractData(deliveriesResult.value) : {}, deliveriesResult.status === 'rejected');
|
renderSubscriptionDetail(extractData(subscriptionResult.value)); renderSubscriptionDeliveries(deliveriesResult.status === 'fulfilled' ? extractData(deliveriesResult.value) : {}, deliveriesResult.status === 'rejected');
|
||||||
}
|
}
|
||||||
async function refresh() {
|
async function refresh(background = false) {
|
||||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
const view = currentView(); $('refresh-button').classList.add('spinning');
|
||||||
try {
|
try {
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
if (view !== currentView()) return;
|
||||||
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
$('refresh-error').hidden = true;
|
||||||
const optional = await Promise.allSettled([
|
if (!state.authenticated || !state.scopeReady) return;
|
||||||
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
if (lists[view]) await loadList(view, background === true);
|
||||||
request('/v1/workflows?limit=20'),
|
else if (view === 'overview') await loadOverview();
|
||||||
request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
else if (view === 'applications') await window.orbitApplications.load({ background: background === true });
|
||||||
]);
|
else if (view === 'retention') await loadRetention();
|
||||||
const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null;
|
else if (view === 'archives') await loadArchives();
|
||||||
const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
|
text('last-updated', `更新于 ${formatTime(new Date())}`);
|
||||||
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
|
} catch(error) { text('refresh-error',`服务暂不可用:${error.message}。请刷新重试。`); $('refresh-error').hidden=false; text('last-updated','刷新失败'); }
|
||||||
if (scheduleItems) renderSchedules(scheduleItems);
|
finally { $('refresh-button').classList.remove('spinning'); }
|
||||||
if (workflowItems) renderWorkflows(workflowItems);
|
|
||||||
if (subscriptionItems) renderSubscriptions(subscriptionItems);
|
|
||||||
setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常');
|
|
||||||
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
|
||||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
|
||||||
}
|
}
|
||||||
|
for (const [kind,page] of Object.entries(lists)) {
|
||||||
|
for (const direction of ['first','prev','next']) $(`${kind}-${direction}`).addEventListener('click',()=>{ if(page.move(direction)) loadList(kind); });
|
||||||
|
$(`${kind}-limit`).addEventListener('change',event=>{ page.limit=Number(event.target.value); page.reset(); loadList(kind); });
|
||||||
|
const form = $(`${kind}-filters`);
|
||||||
|
if(form) {
|
||||||
|
const more = form.querySelector('.filter-more');
|
||||||
|
more.addEventListener('click', () => { const dates = $(more.getAttribute('aria-controls')); dates.hidden = !dates.hidden; more.setAttribute('aria-expanded', String(!dates.hidden)); });
|
||||||
|
form.addEventListener('submit',event=>{ event.preventDefault(); try { page.reset(core.readFilters(Object.fromEntries(new FormData(form)))); loadList(kind); } catch(error) { text(`${kind}-error`,error.message); $(`${kind}-error`).hidden=false; } });
|
||||||
|
form.addEventListener('reset',()=>{ page.reset({}); loadList(kind); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-lookup]').forEach(form=>form.addEventListener('submit',event=>{event.preventDefault(); const kind=form.dataset.lookup; detailOpeners[kind]($(`${kind}-lookup`).value.trim());}));
|
||||||
|
document.addEventListener('click',event=>{
|
||||||
|
const resource=event.target.closest('[data-resource-id]');
|
||||||
|
if(resource) detailOpeners[resource.dataset.resourceKind]?.(resource.dataset.resourceId);
|
||||||
|
const attention=event.target.closest('[data-attention]');
|
||||||
|
if(attention) { const kind=attention.dataset.attention, value=attention.dataset.filterState; lists[kind].reset(value ? {state:value} : {}); const form=$(`${kind}-filters`); if(form) { for(const input of form.querySelectorAll('input,select')) input.value=''; if(value) form.elements.state.value=value; window.OrbitSelect?.sync(form.elements.state); } setView(kind); }
|
||||||
|
const copy=event.target.closest('[data-copy-id]');
|
||||||
|
if(copy) { const value=$(copy.dataset.copyId)?.textContent; if(value && value!=='—') navigator.clipboard.writeText(value).then(()=>showToast('资源 ID 已复制')).catch(()=>showToast('复制失败,请选择 ID 手动复制')); }
|
||||||
|
});
|
||||||
|
document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); $('main-content').focus(); });
|
||||||
|
$('auto-refresh').addEventListener('change',()=>{ text('refresh-mode-label',$('auto-refresh').checked ? '每 10 秒自动刷新' : '自动刷新已暂停'); if($('auto-refresh').checked) refresh(); });
|
||||||
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
||||||
$('archive-plan-form').addEventListener('submit', saveArchivePlan);
|
$('archive-plan-form').addEventListener('submit', saveArchivePlan);
|
||||||
$('archive-plan-form').addEventListener('input', () => { archiveUI.dirty = true; });
|
$('archive-plan-form').addEventListener('input', () => { archiveUI.dirty = true; });
|
||||||
@@ -700,13 +926,13 @@
|
|||||||
$('retention-kind').addEventListener('change', resetRetentionExport);
|
$('retention-kind').addEventListener('change', resetRetentionExport);
|
||||||
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
$('retention-restart-button').addEventListener('click', resetRetentionExport);
|
||||||
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
$('retention-form').addEventListener('input', event => { if (event.target.matches('input, textarea')) { retentionUI.dirty = true; syncRetentionControls(); } });
|
||||||
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
$('logout-button').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh); $('retention-form').addEventListener('submit', submitRetention); $('retention-preview-button').addEventListener('click', previewRetention); $('retention-export-button').addEventListener('click', exportRetention);
|
||||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-retire-button').addEventListener('click', () => openSubscriptionAction('RETIRE')); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
||||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||||
window.addEventListener('hashchange', () => setView(currentView(), false));
|
window.addEventListener('hashchange', () => setView(currentView(), false));
|
||||||
setView(currentView(), false);
|
setView(currentView(), false);
|
||||||
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => Promise.all([loadRetention(), refresh(), currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
|
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(loadScope).then(() => refresh()); }).catch(() => redirectToLogin());
|
||||||
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (currentView() === 'archives') loadArchives(); } }, 10000);
|
state.timer = setInterval(() => { if (state.authenticated && $('auto-refresh').checked && !document.hidden && !document.querySelector('.drawer-backdrop:not([hidden]), dialog[open]') && !lists[currentView()]?.loading && !window.OrbitSelect?.isOpen() && !document.activeElement?.matches('input,select,textarea') && !(currentView()==='applications' && document.querySelector('#access-detail details[open]'))) refresh(true); }, 10000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
288
internal/transport/httpserver/console/applications.js
Normal file
288
internal/transport/httpserver/console/applications.js
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
(() => {
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' };
|
||||||
|
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||||
|
let tokenRequest = 0, tokenTarget = null, selectionSequence = 0;
|
||||||
|
const resourceMessages = {
|
||||||
|
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
|
||||||
|
'queue has unfinished tasks, including tasks needing review':'队列还有未完成或待核查任务,请先结清。',
|
||||||
|
'queue has unfinished or uncertain tasks':'队列还有未完成或结果不确定的任务,请先核查。',
|
||||||
|
'queue has active execution attempts':'队列仍有在途执行,请等待结束。',
|
||||||
|
'queue has unreleased execution permits':'队列仍有未释放的执行许可,请先核查在途工作。',
|
||||||
|
'queue has enabled schedules; pause them first':'队列还有启用中的定时计划,请先暂停计划。',
|
||||||
|
'queue is referenced by unfinished workflows':'队列仍被未结束的流程引用,请先结清流程。',
|
||||||
|
'queue has subscriptions; retire them first':'队列还有未退役订阅,请先结清并退役订阅。',
|
||||||
|
'subscription has unacknowledged deliveries; drain or resolve expired deliveries first':'订阅还有未确认结果,请先消费并确认;过期交付需要核查后重试。',
|
||||||
|
'subscription has unacknowledged deliveries':'订阅还有未确认结果,请先结清。',
|
||||||
|
'subscription has unconsumed results; resume consumption and acknowledge them before retirement':'还有尚未投递的结果,请恢复消费并全部确认后再退役。'
|
||||||
|
};
|
||||||
|
window.orbitResourceMessage = text => resourceMessages[text] || text;
|
||||||
|
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : window.orbitResourceMessage(error.message); }
|
||||||
|
async function api(path = '', body) {
|
||||||
|
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||||
|
const value = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) location.replace(`/console/login?return=${encodeURIComponent('/console/#/applications')}`);
|
||||||
|
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
|
||||||
|
return value.data;
|
||||||
|
}
|
||||||
|
const queueMemory = new Map();
|
||||||
|
function filterApplications() { const term=$('access-search').value.trim().toLocaleLowerCase(); const items=[...document.querySelectorAll('#access-list [data-access-id]')]; items.forEach(item=>{item.hidden=!!term&&!item.textContent.toLocaleLowerCase().includes(term);}); $('access-search-empty').hidden=!term||items.some(item=>!item.hidden); }
|
||||||
|
$('access-search').addEventListener('input',filterApplications);
|
||||||
|
function rememberedQueue(namespace) { try { return localStorage.getItem(`orbit.access.queue.${namespace}`) || queueMemory.get(namespace) || ''; } catch { return queueMemory.get(namespace) || ''; } }
|
||||||
|
function rememberQueue() { const value = $('access-queue').value; queueMemory.set(ui.selected, value); try { localStorage.setItem(`orbit.access.queue.${ui.selected}`, value); } catch {} renderConfig(); }
|
||||||
|
function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) {
|
||||||
|
if (!queue) throw new Error('请先选择接入队列');
|
||||||
|
const url = $('access-url').value.trim() || location.origin;
|
||||||
|
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\;&|<>(){}]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||||
|
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\n`;
|
||||||
|
}
|
||||||
|
let lastScrollAt = -Infinity, detailSignature = '', previousListHTML = '';
|
||||||
|
document.addEventListener('scroll', () => { lastScrollAt = performance.now(); }, true);
|
||||||
|
function editingOrScrolling() {
|
||||||
|
return performance.now() - lastScrollAt < 1500 || document.activeElement?.matches('input,select,textarea') ||
|
||||||
|
!!document.querySelector('dialog[open], #access-detail details[open], #access-retire-form:not([hidden])');
|
||||||
|
}
|
||||||
|
async function load({ background = false } = {}) {
|
||||||
|
if (ui.loading || ui.busy) return;
|
||||||
|
if (background && editingOrScrolling()) return;
|
||||||
|
ui.loading = true; $('access-error').textContent = '';
|
||||||
|
try {
|
||||||
|
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : '');
|
||||||
|
if (background && editingOrScrolling()) return;
|
||||||
|
ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||||
|
const listHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||||
|
if (previousListHTML !== listHTML) { $('access-list').innerHTML = listHTML; previousListHTML = listHTML; }
|
||||||
|
filterApplications();
|
||||||
|
if (!ui.selected && page.items.length) await select(page.items[0].namespace_id);
|
||||||
|
else if (ui.selected && !$('access-dialog').open && !$('access-token-dialog').open) await select(ui.selected, { background });
|
||||||
|
} catch (error) { $('access-error').textContent = message(error); }
|
||||||
|
finally { ui.loading = false; }
|
||||||
|
}
|
||||||
|
async function select(namespace, { background = false } = {}) {
|
||||||
|
if (namespace !== ui.selected) {
|
||||||
|
detailSignature = '';
|
||||||
|
retirement = null; $('access-retire-form').hidden = true;
|
||||||
|
$('access-detail').hidden = true; $('access-empty').hidden = false;
|
||||||
|
$('access-empty').textContent = '正在读取应用配置…';
|
||||||
|
}
|
||||||
|
const requested = namespace, ticket = ++selectionSequence; ui.selected = namespace;
|
||||||
|
try {
|
||||||
|
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested || ticket !== selectionSequence) return;
|
||||||
|
if (background && editingOrScrolling()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
// 相同快照不重建详情,保留滚动锚点、焦点和折叠状态;到期状态仍按当前时间更新。
|
||||||
|
const signature = JSON.stringify([detail, detail.credentials.map(c => [!!c.expires_at && Date.parse(c.expires_at) <= now, Date.parse(c.previous_valid_until) > now])]);
|
||||||
|
ui.detail = detail;
|
||||||
|
if (signature === detailSignature && !$('access-detail').hidden) return;
|
||||||
|
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||||
|
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||||
|
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||||
|
const previous = rememberedQueue(namespace);
|
||||||
|
const usable = detail.queues.filter(q => !q.retired_at);
|
||||||
|
$('access-queue').innerHTML = '<option value="">请选择接入队列</option>' + detail.queues.map(q => `<option value="${escape(q.id)}" ${q.retired_at ? 'disabled' : ''}>${escape(q.name)} · ${q.retired_at ? '已退役' : q.paused ? '已暂停' : '可用'} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||||
|
$('access-queue').value = usable.some(q => q.id === previous) ? previous : (!detail.truncated && usable.length === 1 ? usable[0].id : '');
|
||||||
|
$('access-capacity').textContent = `订阅名额 ${detail.active_subscriptions ?? '—'} / ${detail.subscription_limit ?? '—'} · 暂停仍占名额,结清后退役释放名额。`;
|
||||||
|
const availableCredential = detail.credentials.find(c => !c.revoked_at);
|
||||||
|
$('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗';
|
||||||
|
$('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。';
|
||||||
|
$('access-truncated').hidden = !detail.truncated;
|
||||||
|
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||||
|
const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效';
|
||||||
|
const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now;
|
||||||
|
const blocked = !!c.revoked_at || transitioning;
|
||||||
|
const helpID = `access-rotation-help-${c.id}`;
|
||||||
|
const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。';
|
||||||
|
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>${c.token_stored ? '请在上方复制当前接入配置,更新服务、Worker 和结果消费者。' : '当前新 Token 尚未补存,请先从已保存的新配置导入;过渡期旧 Token 不能替代当前新 Token。'}确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
|
||||||
|
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||||
|
return `<section class="access-credential">
|
||||||
|
<div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
|
||||||
|
<p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 凭据版本 ${escape(c.revision)}</p>
|
||||||
|
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
|
||||||
|
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
|
||||||
|
${rotationHelp}
|
||||||
|
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||||
|
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></details>
|
||||||
|
</section>`;
|
||||||
|
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||||
|
if ($('access-help-dialog').open) renderCredentialHelp();
|
||||||
|
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||||
|
detailSignature = signature;
|
||||||
|
} catch (error) {
|
||||||
|
if (ticket !== selectionSequence) return;
|
||||||
|
$('access-error').textContent = message(error);
|
||||||
|
$('access-detail').hidden = true; $('access-empty').hidden = false;
|
||||||
|
$('access-empty').textContent = '应用配置读取失败,请刷新重试。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderConfig() { const q = ui.detail?.queues.find(q => q.id === $('access-queue').value); $('access-queue-note').textContent = !q ? '请明确选择接入队列,避免复制到错误的目标。' : `当前目标:${q.name} · ${q.paused ? '已暂停,任务暂不执行' : '可用'}。选择只影响复制配置,不修改服务端路由。`; $('access-retire-queue').disabled = !q || !!q.retired_at; try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||||
|
function closeToken() {
|
||||||
|
tokenRequest++; tokenTarget = null;
|
||||||
|
$('access-token-import-submit').disabled = false;
|
||||||
|
$('access-token-config').value = ''; $('access-token-input').value = '';
|
||||||
|
$('access-token-dialog').close();
|
||||||
|
}
|
||||||
|
async function showToken(credential, copyImmediately = false) {
|
||||||
|
closeToken();
|
||||||
|
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
|
||||||
|
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
|
||||||
|
if (!tokenTarget.queue) { $('access-error').textContent = '请先选择要接入的队列。'; return; }
|
||||||
|
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) { $('access-error').textContent = '当前凭据没有所选队列权限,请选择已授权队列。'; return; }
|
||||||
|
$('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`;
|
||||||
|
$('access-token-error').textContent = '';
|
||||||
|
$('access-token-import').hidden = credential.token_stored;
|
||||||
|
$('access-token-output').hidden = true;
|
||||||
|
$('access-token-dialog').showModal();
|
||||||
|
if (!ui.detail.token_vault_enabled) { $('access-token-error').textContent = '服务端尚未配置 Token 保管密钥,请联系部署管理员。'; return; }
|
||||||
|
if (credential.token_stored) await readToken(false, copyImmediately);
|
||||||
|
else $('access-token-input').focus();
|
||||||
|
}
|
||||||
|
async function readToken(importCurrent, copyImmediately = false) {
|
||||||
|
const target = tokenTarget; if (!target) return;
|
||||||
|
const request = ++tokenRequest;
|
||||||
|
$('access-token-error').textContent = '正在读取接入配置…'; $('access-token-import-submit').disabled = true;
|
||||||
|
try {
|
||||||
|
if (!target.queue) throw new Error('没有可用的授权队列,请先配置队列权限。');
|
||||||
|
const value = await api(`/${encodeURIComponent(target.namespace)}/credentials/${encodeURIComponent(target.credential.id)}/token/${importCurrent ? 'import' : 'reveal'}`, { expected_revision: String(target.credential.revision), ...(importCurrent ? { token: $('access-token-input').value.trim() } : {}) });
|
||||||
|
if (request !== tokenRequest || ui.selected !== target.namespace || !$('access-token-dialog').open) return;
|
||||||
|
if (value.namespace_id !== target.namespace || value.credential_id !== target.credential.id || String(value.revision) !== String(target.credential.revision)) throw new Error('凭据已更新,请刷新后重试。');
|
||||||
|
$('access-token-config').value = config(value.token, target.queue, target.namespace);
|
||||||
|
$('access-token-import').hidden = true; $('access-token-input').value = '';
|
||||||
|
$('access-token-output').hidden = false; $('access-token-error').textContent = '';
|
||||||
|
if (importCurrent) target.credential.token_stored = true;
|
||||||
|
if (copyImmediately) await copy($('access-token-config').value, $('access-token-copy'));
|
||||||
|
} catch (error) { if (request === tokenRequest) $('access-token-error').textContent = message(error); }
|
||||||
|
finally { if (request === tokenRequest) $('access-token-import-submit').disabled = false; }
|
||||||
|
}
|
||||||
|
function renderCredentialHelp() {
|
||||||
|
const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential);
|
||||||
|
if (!credential) { $('access-help-dialog').close(); return; }
|
||||||
|
const transitioning = !credential.revoked_at && Date.parse(credential.previous_valid_until) > Date.now();
|
||||||
|
$('access-help-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · ${credential.name} · 凭据版本 ${credential.revision}`;
|
||||||
|
$('access-help-state').textContent = credential.revoked_at ? '凭据已撤销,不能再用于接入或换证。' : transitioning ? `上一轮换证已生效。旧 Token 过渡截止:${time(credential.previous_valid_until)}。期间暂不能再次换证,以免中断还在用旧 Token 的服务。` : '当前没有有效的旧 Token 过渡期,可从凭据卡片发起换证。';
|
||||||
|
$('access-help-transition').hidden = !transitioning;
|
||||||
|
}
|
||||||
|
function showCredentialHelp(credential) {
|
||||||
|
ui.helpCredential = credential.id; renderCredentialHelp(); $('access-help-dialog').showModal();
|
||||||
|
}
|
||||||
|
function open(credential = null, mode = 'ROTATE') {
|
||||||
|
if (ui.busy) return;
|
||||||
|
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
|
||||||
|
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||||
|
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||||
|
$('access-create-fields').hidden = !!credential;
|
||||||
|
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||||
|
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
|
||||||
|
$('access-form-title').textContent = titles[ui.mode];
|
||||||
|
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
|
||||||
|
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
|
||||||
|
$('access-form-note').textContent = {
|
||||||
|
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
|
||||||
|
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
|
||||||
|
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
|
||||||
|
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
|
||||||
|
}[ui.mode];
|
||||||
|
$('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode);
|
||||||
|
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
|
||||||
|
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (ui.busy) return;
|
||||||
|
if (ui.pending && !window.confirm('操作结果尚未确认,请先保存待核对操作。仍要关闭吗?')) return;
|
||||||
|
$('access-dialog').close(); ui.pending = null; ui.result = null; ui.saved = true; $('access-result-config').value = ''; load();
|
||||||
|
}
|
||||||
|
function download(name, content, type = 'text/plain') {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
async function copy(value, button) {
|
||||||
|
try { await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = '已复制 ✓'; setTimeout(() => { button.textContent = old; }, 2000); return true; }
|
||||||
|
catch { ($('access-token-dialog').open ? $('access-token-error') : $('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
|
||||||
|
}
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
|
||||||
|
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||||
|
try {
|
||||||
|
if (!ui.pending) {
|
||||||
|
const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||||
|
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
|
||||||
|
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||||
|
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||||
|
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||||
|
ui.pending = await api('/prepare', input);
|
||||||
|
}
|
||||||
|
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||||
|
const op = ui.pending;
|
||||||
|
await api('/apply', op);
|
||||||
|
ui.saved = true;
|
||||||
|
let queue = op.queue_id || $('access-queue').value;
|
||||||
|
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||||
|
await select(op.namespace_id);
|
||||||
|
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
|
||||||
|
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||||
|
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||||
|
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||||
|
const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : '';
|
||||||
|
ui.result = op; ui.pending = null;
|
||||||
|
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||||
|
$('access-token-once-note').hidden = true;
|
||||||
|
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
|
||||||
|
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
|
||||||
|
$('access-result-config').value = preparedConfig;
|
||||||
|
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
|
||||||
|
} catch (error) {
|
||||||
|
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
|
||||||
|
} finally {
|
||||||
|
ui.busy = false; disabled.forEach(([el, value]) => { el.disabled = value; });
|
||||||
|
$('access-backup').hidden = !ui.pending;
|
||||||
|
if (ui.pending) { form.querySelectorAll('input,select').forEach(el => { el.disabled = true; }); $('access-submit').textContent = '重试确认 →'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('access-url').value = location.origin;
|
||||||
|
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } });
|
||||||
|
$('access-credentials').addEventListener('click', e => {
|
||||||
|
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
|
||||||
|
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig;
|
||||||
|
const credential = ui.detail?.credentials.find(c => c.id === id);
|
||||||
|
if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; }
|
||||||
|
if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; }
|
||||||
|
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
|
||||||
|
});
|
||||||
|
$('access-help-close').addEventListener('click', () => $('access-help-dialog').close());
|
||||||
|
$('access-help-done').addEventListener('click', () => $('access-help-dialog').close());
|
||||||
|
$('access-token-close').addEventListener('click', () => { closeToken(); load(); });
|
||||||
|
$('access-token-dialog').addEventListener('cancel', e => { e.preventDefault(); closeToken(); load(); });
|
||||||
|
$('access-token-import').addEventListener('submit', e => { e.preventDefault(); readToken(true); });
|
||||||
|
$('access-token-copy').addEventListener('click', () => copy($('access-token-config').value, $('access-token-copy')));
|
||||||
|
$('access-token-download').addEventListener('click', () => download('orbit.env', $('access-token-config').value));
|
||||||
|
window.addEventListener('hashchange', closeToken);
|
||||||
|
window.addEventListener('pagehide', closeToken);
|
||||||
|
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||||
|
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||||
|
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||||
|
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', rememberQueue);
|
||||||
|
$('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); });
|
||||||
|
$('access-form').addEventListener('submit', submit);
|
||||||
|
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
|
||||||
|
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });
|
||||||
|
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
|
||||||
|
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
|
||||||
|
let retirement = null;
|
||||||
|
async function queueAPI(path, body, key, namespace = ui.selected) {
|
||||||
|
const response = await fetch(path, {method: body ? 'POST' : 'GET', credentials:'same-origin', cache:'no-store', headers:{'Content-Type':'application/json', 'X-Orbit-Console':'1','X-Orbit-Namespace':namespace,...(key ? {'Idempotency-Key':key} : {})}, ...(body ? {body:JSON.stringify(body)} : {})});
|
||||||
|
const value = await response.json(); if (!response.ok) throw new Error(value.error?.message || '操作失败'); return value.data;
|
||||||
|
}
|
||||||
|
$('access-retire-queue').addEventListener('click', async () => {
|
||||||
|
const id = $('access-queue').value, namespace = ui.selected; if (!id) return;
|
||||||
|
$('access-retire-form').hidden=false; $('access-retire-error').textContent='正在核对队列…'; retirement=null;
|
||||||
|
try { const q=await queueAPI(`/v1/queues/${id}`,null,null,namespace); if (namespace!==ui.selected || id!==$('access-queue').value) return; retirement={id,namespace,revision:q.revision,key:crypto.randomUUID(),body:null}; $('access-retire-reason').value=''; $('access-retire-error').textContent=''; } catch(error) { $('access-retire-error').textContent=message(error); }
|
||||||
|
});
|
||||||
|
$('access-retire-cancel').addEventListener('click',()=>{retirement=null;$('access-retire-form').hidden=true;});
|
||||||
|
$('access-retire-form').addEventListener('submit', async event=>{
|
||||||
|
event.preventDefault(); const target=retirement; if (!target || target.namespace!==ui.selected || target.id!==$('access-queue').value) { $('access-retire-error').textContent='队列已变化,请重新打开退役操作。'; return; }
|
||||||
|
target.body ||= {expected_revision:String(target.revision),reason:$('access-retire-reason').value.trim()};
|
||||||
|
$('access-retire-submit').disabled=true;
|
||||||
|
try { await queueAPI(`/v1/queues/${target.id}:retire`,target.body,target.key,target.namespace); $('access-retire-form').hidden=true; retirement=null; await load(); } catch(error) { $('access-retire-error').textContent=message(error); } finally { $('access-retire-submit').disabled=false; }
|
||||||
|
});
|
||||||
|
window.orbitApplications = { load };
|
||||||
|
})();
|
||||||
98
internal/transport/httpserver/console/console-core.js
Normal file
98
internal/transport/httpserver/console/console-core.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/* 查询状态独立于视图;不缓存凭据,也不对结果进行全量客户端筛选。 */
|
||||||
|
((root) => {
|
||||||
|
const labels = {
|
||||||
|
WAITING: '等待中',
|
||||||
|
LEASED: '已分配',
|
||||||
|
RUNNING: '执行中',
|
||||||
|
NEEDS_REVIEW: '待核查',
|
||||||
|
SUCCEEDED: '已成功',
|
||||||
|
FAILED: '已失败',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
CANCELLING: '取消中',
|
||||||
|
PENDING: '待处理',
|
||||||
|
ACKED: '已确认',
|
||||||
|
EXPIRED: '次数耗尽',
|
||||||
|
UNKNOWN: '结果不明',
|
||||||
|
ENABLED: '已启用',
|
||||||
|
PAUSED: '已暂停',
|
||||||
|
RETIRED: '已退役',
|
||||||
|
ACTIVE: '已激活',
|
||||||
|
CONSUMED: '已消费',
|
||||||
|
REJECTED: '已拒绝',
|
||||||
|
TIMED_OUT: '已超时',
|
||||||
|
FIRED: '已触发',
|
||||||
|
COALESCED: '合并触发',
|
||||||
|
SKIPPED: '已跳过',
|
||||||
|
MISFIRE_COALESCED: '漏跑合并',
|
||||||
|
SCHEDULED: '按计划触发',
|
||||||
|
CRON: 'Cron 表达式',
|
||||||
|
FIXED_RATE: '固定间隔',
|
||||||
|
COALESCE: '合并补偿',
|
||||||
|
SKIP: '跳过',
|
||||||
|
CATCH_UP: '逐次补偿',
|
||||||
|
SERIAL: '串行',
|
||||||
|
ALLOW: '允许并行',
|
||||||
|
TASK: '任务',
|
||||||
|
TIMER: '定时等待',
|
||||||
|
SIGNAL: '外部信号',
|
||||||
|
POLL: '有界查询',
|
||||||
|
PARALLEL: '并行',
|
||||||
|
SEQUENCE: '顺序',
|
||||||
|
SWITCH: '条件分支',
|
||||||
|
WORKFLOW: '流程',
|
||||||
|
STEP: '步骤',
|
||||||
|
'task.submit': '提交任务',
|
||||||
|
'task.cancel': '取消任务',
|
||||||
|
'task.retry': '人工重试',
|
||||||
|
'workflow.started': '流程启动',
|
||||||
|
'workflow.advanced': '流程推进',
|
||||||
|
'workflow.step.changed': '步骤状态变更',
|
||||||
|
'workflow.signal.changed': '信号状态变更',
|
||||||
|
'workflow.signal.accepted': '收到信号',
|
||||||
|
'task.succeeded': '任务成功',
|
||||||
|
'task.failed': '任务失败',
|
||||||
|
'task.cancelled': '任务取消'
|
||||||
|
};
|
||||||
|
function listURL(kind, queue, filters = {}, cursor = '', limit = 10) {
|
||||||
|
if (!['tasks', 'schedules', 'workflows', 'subscriptions'].includes(kind)) throw new Error('不支持的资源类型');
|
||||||
|
if (kind !== 'workflows' && !queue) throw new Error('请先选择队列');
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (kind !== 'workflows') params.set('queue_id', queue);
|
||||||
|
if (['tasks', 'workflows'].includes(kind)) {
|
||||||
|
for (const key of ['state', 'business_key', 'created_from', 'created_before']) {
|
||||||
|
// 精确业务键不能 trim,否则尾部空格对应的任务无法被查询。
|
||||||
|
if (filters[key] !== undefined && filters[key] !== '') params.set(key, filters[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cursor) params.set('cursor', cursor);
|
||||||
|
return `/v1/${kind}?${params}`;
|
||||||
|
}
|
||||||
|
function readFilters(values) {
|
||||||
|
const out = {};
|
||||||
|
for (const key of ['state', 'business_key']) if (values[key] !== undefined && values[key] !== '') out[key] = values[key];
|
||||||
|
for (const key of ['created_from', 'created_before']) {
|
||||||
|
if (!values[key]) continue;
|
||||||
|
const date = new Date(values[key]);
|
||||||
|
if (!Number.isFinite(date.getTime())) throw new Error('请填写有效的创建时间');
|
||||||
|
out[key] = date.toISOString();
|
||||||
|
}
|
||||||
|
if (out.created_from && out.created_before && out.created_from >= out.created_before) throw new Error('结束时间必须晚于开始时间');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
class ListPage {
|
||||||
|
constructor() { this.filters = {}; this.limit = 10; this.cursor = ''; this.history = []; this.next = ''; this.sequence = 0; this.loading = false; }
|
||||||
|
reset(filters = this.filters) { this.sequence++; this.filters = { ...filters }; this.cursor = ''; this.history = []; this.next = ''; this.loading = false; }
|
||||||
|
begin() { this.loading = true; return ++this.sequence; }
|
||||||
|
accept(sequence, page) { if (sequence !== this.sequence) return false; this.next = page.next_cursor || ''; this.loading = false; return true; }
|
||||||
|
fail(sequence) { if (sequence !== this.sequence) return false; this.loading = false; this.next = ''; return true; }
|
||||||
|
move(direction) {
|
||||||
|
if (this.loading) return false;
|
||||||
|
if (direction === 'next' && this.next) { this.history.push(this.cursor); this.cursor = this.next; }
|
||||||
|
else if (direction === 'prev' && this.history.length) this.cursor = this.history.pop();
|
||||||
|
else if (direction === 'first') { this.cursor = ''; this.history = []; }
|
||||||
|
else return false;
|
||||||
|
this.next = ''; return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.OrbitConsole = Object.freeze({ labels, label: value => labels[value] || value || '未知', listURL, readFilters, ListPage });
|
||||||
|
})(globalThis);
|
||||||
147
internal/transport/httpserver/console/console-select.js
Normal file
147
internal/transport/httpserver/console/console-select.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/* 渐进增强原生 select:表单值和 change 事件仍由原控件提供。 */
|
||||||
|
(() => {
|
||||||
|
const controls = new Map();
|
||||||
|
let opened = null;
|
||||||
|
let serial = 0;
|
||||||
|
function enhance(select) {
|
||||||
|
const searchable = select.hasAttribute('data-search');
|
||||||
|
const name = select.getAttribute('aria-label') || '选择选项';
|
||||||
|
const id = `orbit-select-${++serial}`;
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button'; button.className = 'select-trigger';
|
||||||
|
button.setAttribute('aria-label', name);
|
||||||
|
button.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
button.setAttribute('aria-controls', id);
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
const caption = document.createElement('span'); caption.className = 'select-caption';
|
||||||
|
const chevron = document.createElement('span'); chevron.className = 'select-chevron'; chevron.setAttribute('aria-hidden', 'true');
|
||||||
|
button.append(caption, chevron);
|
||||||
|
const popup = document.createElement('div'); popup.className = 'select-popover'; popup.hidden = true;
|
||||||
|
const search = document.createElement('input');
|
||||||
|
search.type = 'search'; search.placeholder = select.dataset.search || '搜索已加载选项';
|
||||||
|
search.setAttribute('aria-label', search.placeholder);
|
||||||
|
search.setAttribute('role', 'combobox'); search.setAttribute('aria-autocomplete', 'list');
|
||||||
|
search.setAttribute('aria-controls', id); search.setAttribute('aria-expanded', 'false');
|
||||||
|
if (searchable) {
|
||||||
|
const head = document.createElement('div'); head.className = 'select-search'; head.append(search); popup.append(head);
|
||||||
|
}
|
||||||
|
const list = document.createElement('div'); list.id = id; list.className = 'select-options';
|
||||||
|
list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', name); list.tabIndex = -1;
|
||||||
|
const empty = document.createElement('p'); empty.className = 'select-empty'; empty.setAttribute('role', 'status');
|
||||||
|
empty.textContent = '没有匹配项,请更换关键词'; empty.hidden = true;
|
||||||
|
popup.append(list, empty); document.body.append(popup);
|
||||||
|
select.after(button); select.hidden = true;
|
||||||
|
let rows = [], active = -1;
|
||||||
|
const focusOwner = () => searchable ? search : list;
|
||||||
|
function position() {
|
||||||
|
if (popup.hidden) return;
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
if (!rect.width || rect.bottom < 0 || rect.top > innerHeight) { close(); return; }
|
||||||
|
const width = Math.min(Math.max(rect.width, searchable ? 280 : 150), innerWidth - 24);
|
||||||
|
popup.style.width = `${width}px`;
|
||||||
|
popup.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - width - 12))}px`;
|
||||||
|
const below = innerHeight - rect.bottom - 16, above = rect.top - 16;
|
||||||
|
const up = below < Math.min(popup.scrollHeight, 300) && above > below;
|
||||||
|
const room = Math.max(80, up ? above : below);
|
||||||
|
list.style.maxHeight = `${Math.min(280, room - (searchable ? 61 : 12))}px`;
|
||||||
|
popup.style.top = `${up ? Math.max(8, rect.top - popup.getBoundingClientRect().height - 6) : rect.bottom + 6}px`;
|
||||||
|
}
|
||||||
|
function highlight(index, scroll = false) {
|
||||||
|
active = index;
|
||||||
|
rows.forEach((row, i) => row.node.classList.toggle('is-active', i === index));
|
||||||
|
if (index < 0) focusOwner().removeAttribute('aria-activedescendant');
|
||||||
|
else {
|
||||||
|
focusOwner().setAttribute('aria-activedescendant', rows[index].node.id);
|
||||||
|
if (scroll) rows[index].node.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function choose(option) {
|
||||||
|
if (option.disabled || select.disabled) return;
|
||||||
|
const changed = select.value !== option.value;
|
||||||
|
select.value = option.value;
|
||||||
|
close(true);
|
||||||
|
if (changed) select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
// 范围切换可能被未提交表单拦截,以业务监听器确认后的值为准。
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
const query = search.value.toLocaleLowerCase();
|
||||||
|
list.replaceChildren(); rows = [];
|
||||||
|
[...select.options].forEach((option, index) => {
|
||||||
|
if (option.hidden || (query && option.value !== '__more__' && !option.textContent.toLocaleLowerCase().includes(query))) return;
|
||||||
|
const node = document.createElement('div'); node.className = 'select-option'; node.id = `${id}-${index}`;
|
||||||
|
node.setAttribute('role', 'option'); node.setAttribute('aria-selected', String(option.selected));
|
||||||
|
node.setAttribute('aria-disabled', String(option.disabled));
|
||||||
|
const copy = document.createElement('span'); copy.className = 'select-option-copy';
|
||||||
|
const title = document.createElement('b'); title.textContent = option.dataset.title || option.textContent; copy.append(title);
|
||||||
|
if (option.dataset.subtitle) { const sub = document.createElement('small'); sub.textContent = option.dataset.subtitle; copy.append(sub); }
|
||||||
|
const check = document.createElement('span'); check.className = 'select-check'; check.textContent = option.selected ? '✓' : ''; check.setAttribute('aria-hidden', 'true');
|
||||||
|
node.append(copy, check);
|
||||||
|
const rowIndex = rows.length;
|
||||||
|
node.addEventListener('pointermove', () => { if (!option.disabled) highlight(rowIndex); });
|
||||||
|
node.addEventListener('mousedown', event => event.preventDefault());
|
||||||
|
node.addEventListener('click', () => choose(option));
|
||||||
|
rows.push({ option, node }); list.append(node);
|
||||||
|
});
|
||||||
|
empty.hidden = rows.length > 0;
|
||||||
|
const selected = rows.findIndex(row => row.option.selected && !row.option.disabled);
|
||||||
|
highlight(selected >= 0 ? selected : rows.findIndex(row => !row.option.disabled));
|
||||||
|
position();
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
caption.textContent = select.selectedOptions[0]?.textContent || '请选择';
|
||||||
|
button.title = caption.textContent; button.disabled = select.disabled;
|
||||||
|
if (select.disabled) close();
|
||||||
|
else if (!popup.hidden) render();
|
||||||
|
}
|
||||||
|
function close(focus = false) {
|
||||||
|
if (popup.hidden) return;
|
||||||
|
popup.hidden = true; button.setAttribute('aria-expanded', 'false'); search.setAttribute('aria-expanded', 'false');
|
||||||
|
list.removeAttribute('aria-activedescendant'); search.removeAttribute('aria-activedescendant');
|
||||||
|
if (opened?.button === button) opened = null;
|
||||||
|
if (focus && !button.disabled) button.focus();
|
||||||
|
}
|
||||||
|
function open() {
|
||||||
|
if (select.disabled) return;
|
||||||
|
opened?.close(); sync(); search.value = ''; popup.hidden = false;
|
||||||
|
opened = { button, popup, close, position };
|
||||||
|
button.setAttribute('aria-expanded', 'true'); search.setAttribute('aria-expanded', 'true');
|
||||||
|
render(); focusOwner().focus(); highlight(active, true);
|
||||||
|
}
|
||||||
|
function keydown(event) {
|
||||||
|
if (event.key === 'Escape' && !popup.hidden) { event.preventDefault(); event.stopPropagation(); close(true); return; }
|
||||||
|
if (event.key === 'Tab' && !popup.hidden) { close(true); return; }
|
||||||
|
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||||
|
// 搜索输入的 Home/End 保留文本编辑语义。
|
||||||
|
if (event.target === search && ['Home', 'End'].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (popup.hidden) { open(); return; }
|
||||||
|
const enabled = rows.map((row, index) => row.option.disabled ? -1 : index).filter(index => index >= 0);
|
||||||
|
if (!enabled.length) return;
|
||||||
|
const current = enabled.indexOf(active), last = enabled.length - 1;
|
||||||
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? last : Math.max(0, Math.min(last, current + (event.key === 'ArrowDown' ? 1 : -1)));
|
||||||
|
highlight(enabled[next], true);
|
||||||
|
} else if (event.key === 'Enter' || (event.key === ' ' && event.target !== search)) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (popup.hidden) open(); else if (active >= 0) choose(rows[active].option);
|
||||||
|
} else if (event.key.length === 1 && searchable && event.target === button && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
|
event.preventDefault(); open(); search.value = event.key; render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button.addEventListener('click', event => { event.preventDefault(); popup.hidden ? open() : close(); });
|
||||||
|
button.addEventListener('keydown', keydown); search.addEventListener('keydown', keydown); list.addEventListener('keydown', keydown);
|
||||||
|
search.addEventListener('input', render);
|
||||||
|
select.addEventListener('change', () => queueMicrotask(sync));
|
||||||
|
// reset 事件先于浏览器恢复默认值;下一个任务再同步显示。
|
||||||
|
select.form?.addEventListener('reset', () => setTimeout(sync, 0));
|
||||||
|
new MutationObserver(sync).observe(select, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||||
|
controls.set(select, sync); sync();
|
||||||
|
}
|
||||||
|
document.querySelectorAll('select[data-select]').forEach(enhance);
|
||||||
|
document.addEventListener('pointerdown', event => { if (opened && !opened.popup.contains(event.target) && !opened.button.contains(event.target)) opened.close(); });
|
||||||
|
document.addEventListener('focusin', event => { if (opened && !opened.popup.contains(event.target) && !opened.button.contains(event.target)) opened.close(); });
|
||||||
|
document.addEventListener('scroll', event => { if (opened && !opened.popup.contains(event.target)) opened.position(); }, true);
|
||||||
|
window.addEventListener('resize', () => opened?.position());
|
||||||
|
window.addEventListener('hashchange', () => opened?.close());
|
||||||
|
window.OrbitSelect = Object.freeze({ sync: select => controls.get(select)?.(), isOpen: () => !!opened });
|
||||||
|
})();
|
||||||
BIN
internal/transport/httpserver/console/favicon.ico
Normal file
BIN
internal/transport/httpserver/console/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
8
internal/transport/httpserver/console/favicon.svg
Normal file
8
internal/transport/httpserver/console/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 35">
|
||||||
|
<rect width="35" height="35" rx="10" fill="#087f75"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<rect x="8.5" y="12" width="4" height="11" rx="2"/>
|
||||||
|
<rect x="15.5" y="7.5" width="4" height="20" rx="2"/>
|
||||||
|
<rect x="22.5" y="10" width="4" height="15" rx="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 320 B |
@@ -1,30 +1,32 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" class="console-document">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="light">
|
||||||
<title>Orbit Console · 星轨控制台</title>
|
<title>Orbit Console · 星轨控制台</title>
|
||||||
|
<link rel="icon" href="/console/favicon.ico" sizes="16x16 32x32 48x48">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/console/favicon.svg" sizes="any">
|
||||||
<link rel="stylesheet" href="/console/styles.css">
|
<link rel="stylesheet" href="/console/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="console-body">
|
||||||
<div class="ambient ambient-one"></div>
|
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||||
<div class="ambient ambient-two"></div>
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-mark"><span></span><span></span><span></span></div>
|
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||||
<div><strong>Orbit</strong><small>星轨控制台</small></div>
|
<div><strong>Orbit</strong><small>星轨控制台</small></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="workspace-label">CONTROL PLANE</div>
|
<div class="workspace-label">工作台</div>
|
||||||
<nav class="nav-list" aria-label="控制台导航">
|
<nav class="nav-list" aria-label="控制台导航">
|
||||||
<button class="nav-item active" data-view="overview"><span class="nav-icon">⌂</span><span>总览</span><em>01</em></button>
|
<button class="nav-item active" data-view="overview"><span class="nav-icon">⌂</span><span>总览</span></button>
|
||||||
<button class="nav-item" data-view="tasks"><span class="nav-icon">◇</span><span>任务执行</span><em>02</em></button>
|
<span class="nav-group-label">运行管理</span><button class="nav-item" data-view="tasks"><span class="nav-icon">◇</span><span>任务执行</span></button>
|
||||||
<button class="nav-item" data-view="schedules"><span class="nav-icon">◷</span><span>定时计划</span><em>03</em></button>
|
<button class="nav-item" data-view="schedules"><span class="nav-icon">◷</span><span>定时计划</span></button>
|
||||||
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span><em>04</em></button>
|
<button class="nav-item" data-view="workflows"><span class="nav-icon">⌘</span><span>流程实例</span></button>
|
||||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span></button>
|
||||||
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
|
<span class="nav-group-label">数据管理</span><button class="nav-item" data-view="retention"><span class="nav-icon">◴</span><span>保留策略</span></button>
|
||||||
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
|
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span></button>
|
||||||
|
<span class="nav-group-label">接入管理</span><button class="nav-item" data-view="applications"><span class="nav-icon">⊞</span><span>应用接入</span></button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
<div class="rail-card">
|
<div class="rail-card">
|
||||||
@@ -36,108 +38,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content" id="main-content" tabindex="-1">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="crumbs"><span>Orbit</span><b>/</b><strong id="page-title">总览</strong></div>
|
<div class="crumbs"><span>Orbit</span><b>/</b><strong id="page-title">总览</strong></div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions"><label class="auto-refresh"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
|
||||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||||
<span class="last-updated" id="last-updated">尚未刷新</span>
|
<span class="last-updated" id="last-updated" aria-live="polite">尚未刷新</span>
|
||||||
|
<button class="secondary-button compact" id="login-audit-open" hidden>登录审计</button>
|
||||||
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
||||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<p id="refresh-error" class="page-error" role="alert" hidden></p>
|
||||||
|
<section class="application-scope" id="application-scope" aria-label="应用筛选">
|
||||||
|
<div class="scope-heading"><span class="scope-symbol">⊞</span><div><b>应用范围</b><small id="scope-summary">正在读取…</small></div></div>
|
||||||
|
<label class="scope-field"><span>应用 / 环境</span><select id="scope-application" data-select data-search="搜索应用 / 环境" disabled aria-label="选择应用与环境"><option>正在读取…</option></select></label>
|
||||||
|
<label class="scope-field scope-queue"><span>队列</span><select id="scope-queue" data-select data-search="搜索队列" disabled aria-label="选择队列"><option>正在读取…</option></select></label>
|
||||||
|
<div class="scope-note"><small id="scope-hint">任务、计划和订阅按所选队列显示</small><button id="scope-reset" type="button" class="text-button">重置范围 ↗</button></div>
|
||||||
|
<p id="scope-error" class="scope-error" role="status"></p>
|
||||||
|
</section>
|
||||||
<div class="page-view active" id="view-overview" data-page="overview">
|
<div class="page-view active" id="view-overview" data-page="overview">
|
||||||
<section class="hero" id="overview">
|
<div class="page-heading"><div><span class="page-eyebrow">WORKSPACE / OVERVIEW</span><h1>运行概览</h1><p>先看运行状态,再处理需要关注的工作。</p></div><button class="secondary-button" data-view="applications">管理应用 <span>↗</span></button></div>
|
||||||
<div class="hero-copy">
|
|
||||||
<div class="eyebrow"><span class="eyebrow-line"></span> ORBIT CONTROL PLANE</div>
|
|
||||||
<h1>让每一次执行<br><span>都清晰可见。</span></h1>
|
|
||||||
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
|
||||||
<div class="hero-actions">
|
|
||||||
<button class="primary-button" data-view="tasks">查看任务 <span>↗</span></button>
|
|
||||||
<button class="quiet-button" id="logout-hero">退出登录</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hero-visual" aria-hidden="true">
|
|
||||||
<div class="orbit-ring ring-one"></div>
|
|
||||||
<div class="orbit-ring ring-two"></div>
|
|
||||||
<div class="orbit-ring ring-three"></div>
|
|
||||||
<div class="orbit-core"><span>O</span></div>
|
|
||||||
<div class="orbit-node node-a"></div><div class="orbit-node node-b"></div><div class="orbit-node node-c"></div>
|
|
||||||
<div class="hero-visual-label"><b>LIVE</b><span>任务流持续观测</span></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="metric-grid" aria-label="运行概览">
|
<section class="metric-grid" aria-label="运行概览">
|
||||||
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="status-dot"></span></div><strong id="metric-health">—</strong><small id="metric-health-sub">正在检查服务就绪状态</small></article>
|
||||||
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">当前会话加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
<button class="metric-card" data-view="tasks"><div class="metric-top"><span>最近任务</span><span>↗</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">当前队列 · 最近 20 条</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></button>
|
||||||
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
<button class="metric-card" data-view="workflows"><div class="metric-top"><span>最近流程</span><span>↗</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">当前应用 · 最近 20 条</small></button>
|
||||||
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
<button class="metric-card" data-view="subscriptions"><div class="metric-top"><span>结果订阅</span><span>↗</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">当前队列 · 最近 20 条</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></button>
|
||||||
</section>
|
</section>
|
||||||
|
<div class="snapshot-note"><span class="note-dot"></span>以下为各类最近 20 条记录的快照,不代表全部数据。任务、计划、订阅按队列统计,流程按应用统计。<span id="stats-snapshot">等待数据</span></div>
|
||||||
<section class="content-grid overview-grid" aria-label="运行统计与连接状态">
|
<section class="overview-grid">
|
||||||
<article class="panel stats-panel" id="runtime-stats">
|
<article class="panel stats-panel" id="runtime-stats"><div class="panel-heading"><div><h2>任务状态分布</h2><p>当前队列的最近任务</p></div><span class="table-count" id="stat-task-total">—</span></div>
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">RUNTIME / SNAPSHOT</span><h2>运行统计</h2></div><span class="table-count" id="stats-snapshot">等待数据</span></div>
|
<div class="distribution-list">
|
||||||
<div class="stat-grid">
|
<div class="distribution-row"><span>已成功</span><div class="status-track"><i id="stat-bar-succeeded"></i></div><b id="stat-count-succeeded">—</b></div>
|
||||||
<div class="stat-tile"><span>当前窗口成功率</span><strong id="stat-success-rate">—</strong><small id="stat-success-rate-sub">等待任务数据</small></div>
|
<div class="distribution-row"><span>执行中</span><div class="status-track"><i id="stat-bar-running"></i></div><b id="stat-count-running">—</b></div>
|
||||||
<div class="stat-tile"><span>活跃计划</span><strong id="stat-active-schedules">—</strong><small id="stat-active-schedules-sub">等待计划数据</small></div>
|
<div class="distribution-row"><span>等待执行</span><div class="status-track"><i id="stat-bar-waiting"></i></div><b id="stat-count-waiting">—</b></div>
|
||||||
<div class="stat-tile"><span>待核查流程</span><strong id="stat-review-workflows">—</strong><small id="stat-review-workflows-sub">等待流程数据</small></div>
|
<div class="distribution-row"><span>失败 / 待核查</span><div class="status-track"><i id="stat-bar-other"></i></div><b id="stat-count-other">—</b></div>
|
||||||
<div class="stat-tile"><span>待交付结果</span><strong id="stat-pending-deliveries">—</strong><small id="stat-pending-deliveries-sub">等待订阅数据</small></div>
|
<div class="distribution-row"><span>已取消</span><div class="status-track"><i id="stat-bar-cancelled"></i></div><b id="stat-count-cancelled">—</b></div>
|
||||||
</div>
|
</div><div class="stat-grid">
|
||||||
<div class="distribution-block">
|
<div class="stat-tile"><span>窗口成功占比</span><strong id="stat-success-rate">—</strong><small id="stat-success-rate-sub">等待数据</small></div>
|
||||||
<div class="distribution-heading"><span>任务状态分布</span><small id="stat-task-total">—</small></div>
|
<div class="stat-tile"><span>启用中的计划</span><strong id="stat-active-schedules">—</strong><small id="stat-active-schedules-sub">等待数据</small></div>
|
||||||
<div class="distribution-list">
|
<div class="stat-tile"><span>待核查流程</span><strong id="stat-review-workflows">—</strong><small id="stat-review-workflows-sub">等待数据</small></div>
|
||||||
<div class="distribution-row"><span>成功</span><div class="status-track"><i id="stat-bar-succeeded"></i></div><b id="stat-count-succeeded">0</b></div>
|
<div class="stat-tile"><span>待确认结果</span><strong id="stat-pending-deliveries">—</strong><small id="stat-pending-deliveries-sub">等待数据</small></div>
|
||||||
<div class="distribution-row"><span>执行中</span><div class="status-track"><i id="stat-bar-running"></i></div><b id="stat-count-running">0</b></div>
|
|
||||||
<div class="distribution-row"><span>等待</span><div class="status-track"><i id="stat-bar-waiting"></i></div><b id="stat-count-waiting">0</b></div>
|
|
||||||
<div class="distribution-row"><span>异常</span><div class="status-track"><i id="stat-bar-other"></i></div><b id="stat-count-other">0</b></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="panel connection-summary-panel">
|
<article class="panel attention-panel"><div class="panel-heading"><div><h2>需要关注</h2><p>来自当前快照的异常与积压</p></div><span class="attention-mark">!</span></div><div id="attention-list" class="attention-list">正在检查…</div><div class="quick-links"><button data-view="applications">应用接入 <span>↗</span></button><button data-view="archives">归档作业 <span>↗</span></button></div></article>
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>登录状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
|
||||||
<div class="connection-summary-copy"><span class="connection-summary-icon">↗</span><div><b id="summary-api">当前页面</b><small id="summary-api-hint">API 地址自动跟随当前页面</small></div></div>
|
|
||||||
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>登录会话</span><b id="summary-credential">未登录</b></div></div>
|
|
||||||
<p class="connection-summary-note" id="summary-note">服务状态会自动检查;需要业务数据时再加载凭据。</p>
|
|
||||||
<button class="quiet-button" id="logout-inline" type="button">退出当前账号 <span>→</span></button>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
<article class="panel recent-panel"><div class="panel-heading"><div><h2>最近任务</h2><p>点击任务查看结果与执行历史</p></div><button class="text-button" data-view="tasks">查看全部 →</button></div><div class="table-wrap"><table><thead><tr><th>任务 / 业务键</th><th>处理器</th><th>状态</th><th>创建时间</th><th><span class="sr-only">操作</span></th></tr></thead><tbody id="overview-task-list"></tbody></table></div></article>
|
||||||
|
<details class="service-details"><summary>服务与会话信息 <span id="connection-state">正在检查</span></summary><div class="service-detail-grid"><div><span>服务地址</span><b id="summary-api">—</b><small id="summary-api-hint"></small></div><div><span>队列</span><b id="summary-queue">—</b></div><div><span>登录方式</span><b id="summary-credential">—</b></div></div><p id="summary-note"></p></details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-view" id="view-tasks" data-page="tasks">
|
<div class="page-view" id="view-tasks" data-page="tasks">
|
||||||
<section class="content-grid single-column">
|
<div class="page-heading"><div><h1>任务执行</h1><p>查询任务状态、查看执行历史,按证据处理失败和重试。</p></div><form class="id-lookup" data-lookup="tasks"><label class="sr-only" for="tasks-lookup">任务 ID</label><input id="tasks-lookup" placeholder="输入任务 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||||
<article class="panel wide-panel" id="tasks">
|
<article class="panel data-panel"><form class="list-filters" id="tasks-filters"><label>状态<select name="state" data-select aria-label="状态"><option value="">全部状态</option><option value="WAITING">等待执行</option><option value="LEASED">已分配</option><option value="RUNNING">执行中</option><option value="NEEDS_REVIEW">待核查</option><option value="SUCCEEDED">已成功</option><option value="FAILED">已失败</option><option value="CANCELLED">已取消</option></select></label><label class="filter-key">业务键<input name="business_key" placeholder="输入完整业务键,精确匹配" maxlength="191" autocomplete="off"></label><div class="filter-actions"><button class="primary-button compact" type="submit">查询</button><button class="secondary-button compact" type="reset">重置</button></div><button type="button" class="filter-more" aria-expanded="false" aria-controls="tasks-dates">创建时间<span aria-hidden="true">⌄</span></button><div class="filter-dates" id="tasks-dates" hidden><label>创建时间从(含)<input name="created_from" type="datetime-local"></label><label>到(不含)<input name="created_before" type="datetime-local"></label><small>按浏览器本地时区查询,设置后点击「查询」。</small></div></form><div class="list-summary"><span id="tasks-summary">正在读取…</span><span class="muted">当前应用 / 队列 · 按创建时间倒序</span></div><p id="tasks-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>任务 / 业务键</th><th>应用 / 环境</th><th>处理器</th><th>状态</th><th>执行次数</th><th>创建时间</th><th>操作</th></tr></thead><tbody id="task-table"><tr><td colspan="7" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="tasks-limit" data-select aria-label="任务执行每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="tasks-first" class="text-button" type="button">回到最新</button><button id="tasks-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="tasks-page">第 1 页</span><button id="tasks-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-view" id="view-schedules" data-page="schedules">
|
<div class="page-view" id="view-schedules" data-page="schedules">
|
||||||
<section class="content-grid single-column">
|
<div class="page-heading"><div><h1>定时计划</h1><p>查看计划状态与触发历史,暂停或恢复后续调度。</p></div><form class="id-lookup" data-lookup="schedules"><label class="sr-only" for="schedules-lookup">计划 ID</label><input id="schedules-lookup" placeholder="输入计划 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||||
<article class="panel" id="schedules"><div class="panel-heading"><div><span class="panel-kicker">PULSE / SCHEDULES</span><h2>定时计划</h2></div><span class="table-count" id="schedule-page-hint">点击计划查看详情</span></div><div class="schedule-list" id="schedule-list"><div class="empty-compact"><span>◷</span><div><b>等待计划数据</b><small>需要 schedules:read 权限</small></div></div></div></article>
|
<article class="panel data-panel"><div class="list-summary"><span id="schedules-summary">正在读取…</span><span class="muted">当前应用 / 队列 · 按创建时间倒序</span></div><p id="schedules-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>计划名称</th><th>应用 / 环境</th><th>状态</th><th>当前版本</th><th>下次触发</th><th>操作</th></tr></thead><tbody id="schedule-list"><tr><td colspan="6" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="schedules-limit" data-select aria-label="定时计划每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="schedules-first" class="text-button" type="button">回到最新</button><button id="schedules-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="schedules-page">第 1 页</span><button id="schedules-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-view" id="view-workflows" data-page="workflows">
|
<div class="page-view" id="view-workflows" data-page="workflows">
|
||||||
<section class="content-grid single-column">
|
<div class="page-heading"><div><h1>流程实例</h1><p>跟踪每一步进展,查看等待原因与恢复记录。</p></div><form class="id-lookup" data-lookup="workflows"><label class="sr-only" for="workflows-lookup">流程 ID</label><input id="workflows-lookup" placeholder="输入流程 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||||
<article class="panel" id="workflows"><div class="panel-heading"><div><span class="panel-kicker">WEAVE / WORKFLOWS</span><h2>流程实例</h2></div><span class="table-count">点击实例查看详情</span></div><div class="workflow-list" id="workflow-list"><div class="empty-compact"><span>⌘</span><div><b>等待流程数据</b><small>需要 workflows:read 权限</small></div></div></div></article>
|
<article class="panel data-panel"><form class="list-filters" id="workflows-filters"><label>状态<select name="state" data-select aria-label="状态"><option value="">全部状态</option><option value="RUNNING">执行中</option><option value="WAITING">等待中</option><option value="CANCELLING">取消中</option><option value="NEEDS_REVIEW">待核查</option><option value="SUCCEEDED">已成功</option><option value="FAILED">已失败</option><option value="CANCELLED">已取消</option></select></label><label class="filter-key">业务键<input name="business_key" placeholder="输入完整业务键,精确匹配" maxlength="191" autocomplete="off"></label><div class="filter-actions"><button class="primary-button compact" type="submit">查询</button><button class="secondary-button compact" type="reset">重置</button></div><button type="button" class="filter-more" aria-expanded="false" aria-controls="workflows-dates">创建时间<span aria-hidden="true">⌄</span></button><div class="filter-dates" id="workflows-dates" hidden><label>创建时间从(含)<input name="created_from" type="datetime-local"></label><label>到(不含)<input name="created_before" type="datetime-local"></label><small>按浏览器本地时区查询,设置后点击「查询」。</small></div></form><div class="list-summary"><span id="workflows-summary">正在读取…</span><span class="muted">当前应用环境 · 按创建时间倒序</span></div><p id="workflows-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>流程 / 业务键</th><th>应用 / 环境</th><th>状态</th><th>固定版本</th><th>创建时间</th><th>操作</th></tr></thead><tbody id="workflow-list"><tr><td colspan="6" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="workflows-limit" data-select aria-label="流程实例每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="workflows-first" class="text-button" type="button">回到最新</button><button id="workflows-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="workflows-page">第 1 页</span><button id="workflows-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-view" id="view-subscriptions" data-page="subscriptions">
|
<div class="page-view" id="view-subscriptions" data-page="subscriptions">
|
||||||
<section class="content-grid single-column">
|
<div class="page-heading"><div><h1>结果订阅</h1><p>查看待确认结果、交付历史和订阅状态。</p></div><form class="id-lookup" data-lookup="subscriptions"><label class="sr-only" for="subscriptions-lookup">订阅 ID</label><input id="subscriptions-lookup" placeholder="输入订阅 ID 定位" pattern="[a-f0-9]{32}" maxlength="32" required autocomplete="off"><button class="secondary-button compact" type="submit">定位</button></form></div>
|
||||||
<article class="panel" id="subscriptions"><div class="panel-heading"><div><span class="panel-kicker">EVENTS / DELIVERY</span><h2>结果订阅</h2></div><span class="live-label-small"><i></i>自动刷新</span></div><div class="subscription-list" id="subscription-list"><div class="empty-compact"><span>⌁</span><div><b>等待订阅数据</b><small>需要 events:read 权限</small></div></div></div></article>
|
<article class="panel data-panel"><div class="list-summary"><span id="subscriptions-summary">正在读取…</span><span class="muted">当前应用 / 队列 · 按创建时间倒序</span></div><p id="subscriptions-error" class="list-error" role="alert" hidden></p><div class="table-wrap"><table><thead><tr><th>订阅名称</th><th>应用 / 环境</th><th>状态</th><th>待确认 / 上限</th><th>创建时间</th><th>操作</th></tr></thead><tbody id="subscription-list"><tr><td colspan="6" class="empty-row">正在读取…</td></tr></tbody></table></div><div class="list-pagination"><label>每页 <select id="subscriptions-limit" data-select aria-label="结果订阅每页条数"><option>10</option><option>20</option><option>50</option><option>100</option></select> 条</label><div><button id="subscriptions-first" class="text-button" type="button">回到最新</button><button id="subscriptions-prev" class="secondary-button compact" type="button" disabled>上一页</button><span id="subscriptions-page">第 1 页</span><button id="subscriptions-next" class="secondary-button compact" type="button" disabled>下一页</button></div></div></article>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-view" id="view-archives" data-page="archives">
|
<div class="page-view" id="view-archives" data-page="archives">
|
||||||
<div class="archive-heading"><div><span class="panel-kicker">DATA / ARCHIVE JOBS</span><h1>让历史数据,有序归档。</h1><p>配置周期,自动分批保存历史;每次执行都有进度与校验记录。</p></div><button class="secondary-button compact" id="archive-refresh">刷新作业 ↻</button></div>
|
<div class="archive-heading"><div><h1>归档作业</h1><p>设置归档周期,查看执行进度和失败原因。</p></div><button class="secondary-button compact" id="archive-refresh">刷新作业 ↻</button></div>
|
||||||
<div class="archive-stats"><article><span>周期计划</span><strong id="archive-enabled-count">—</strong><small>已启用 / 7 个类别</small></article><article><span>本页待处理</span><strong id="archive-failed-count">—</strong><small>失败作业保留进度,等待处理</small></article><article><span>本页归档记录</span><strong id="archive-root-count">—</strong><small>数据库已确认的根记录数</small></article></div>
|
<div class="archive-stats"><article><span>周期计划</span><strong id="archive-enabled-count">—</strong><small>已启用 / 7 个类别</small></article><article><span>本页待处理</span><strong id="archive-failed-count">—</strong><small>失败作业保留进度,等待处理</small></article><article><span>本页归档记录</span><strong id="archive-root-count">—</strong><small>数据库已确认的根记录数</small></article></div>
|
||||||
<div id="archive-error" class="action-error" role="alert"></div>
|
<div id="archive-error" class="action-error" role="alert"></div>
|
||||||
<section class="archive-grid">
|
<section class="archive-grid">
|
||||||
<article class="panel archive-config">
|
<article class="panel archive-config">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">SCHEDULE</span><h2>周期配置</h2></div><span class="connection-state" id="archive-executor">正在读取</span></div>
|
<div class="panel-heading"><div><h2>周期配置</h2></div><span class="connection-state" id="archive-executor">正在读取</span></div>
|
||||||
<form id="archive-plan-form">
|
<form id="archive-plan-form">
|
||||||
<label class="field-label" for="archive-kind">归档类别</label><select id="archive-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select>
|
<label class="field-label" for="archive-kind">归档类别</label><select id="archive-kind"><option value="tasks">任务历史</option><option value="workflows">流程历史</option><option value="events">事件日志</option><option value="audit">管理审计</option><option value="deliveries">交付历史</option><option value="inbox">Inbox 历史</option><option value="idempotency">幂等记录</option></select>
|
||||||
<div class="retention-switch"><div><b>开启周期归档</b><small>首次启用立即运行,之后按周期执行。</small></div><label class="switch"><input id="archive-enabled" type="checkbox" aria-label="开启周期归档"><span></span></label></div>
|
<div class="retention-switch"><div><b>开启周期归档</b><small>首次启用立即运行,之后按周期执行。</small></div><label class="switch"><input id="archive-enabled" type="checkbox" aria-label="开启周期归档"><span></span></label></div>
|
||||||
@@ -152,7 +124,7 @@
|
|||||||
<button class="text-button" data-view="retention">调整历史保留时间 ↗</button>
|
<button class="text-button" data-view="retention">调整历史保留时间 ↗</button>
|
||||||
</article>
|
</article>
|
||||||
<article class="panel archive-history">
|
<article class="panel archive-history">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">EXECUTION HISTORY</span><h2>归档作业</h2></div><span class="table-count" id="archive-job-count">0 项</span></div>
|
<div class="panel-heading"><div><h2>归档作业</h2></div><span class="table-count" id="archive-job-count">0 项</span></div>
|
||||||
<p class="panel-description">源数据库记录始终保留。文件写入服务端持久卷,页面展示已确认的批次和 SHA-256 摘要。</p>
|
<p class="panel-description">源数据库记录始终保留。文件写入服务端持久卷,页面展示已确认的批次和 SHA-256 摘要。</p>
|
||||||
<div class="archive-job-list" id="archive-job-list"><div class="empty-row">正在读取归档作业…</div></div>
|
<div class="archive-job-list" id="archive-job-list"><div class="empty-row">正在读取归档作业…</div></div>
|
||||||
<div class="archive-pager"><button class="secondary-button compact" id="archive-first">返回最新</button><button class="secondary-button compact" id="archive-next" disabled>更早作业 ↓</button></div>
|
<div class="archive-pager"><button class="secondary-button compact" id="archive-first">返回最新</button><button class="secondary-button compact" id="archive-next" disabled>更早作业 ↓</button></div>
|
||||||
@@ -167,11 +139,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-view" id="view-retention" data-page="retention">
|
<div class="page-view" id="view-retention" data-page="retention">
|
||||||
|
<div class="page-heading"><div><h1>保留策略</h1><p>配置历史保留窗口,预览和导出归档候选。</p></div></div>
|
||||||
<section class="content-grid single-column">
|
<section class="content-grid single-column">
|
||||||
<article class="panel wide-panel retention-panel">
|
<article class="panel wide-panel retention-panel">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
<div class="panel-heading"><div><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
|
||||||
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
|
||||||
<form id="retention-form" class="retention-form">
|
<div id="retention-scope" class="retention-scope"></div><form id="retention-form" class="retention-form">
|
||||||
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
|
||||||
<div class="retention-grid">
|
<div class="retention-grid">
|
||||||
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
|
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
|
||||||
@@ -199,13 +172,40 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
<div class="page-view" id="view-applications" data-page="applications">
|
||||||
|
<div class="access-heading"><div><h1>应用接入</h1><p>选择应用和队列,复制配置即可开始接入。</p></div><button class="primary-button compact" id="access-create">新建应用 <span>+</span></button></div>
|
||||||
|
<div id="access-error" class="action-error" role="alert"></div>
|
||||||
|
<section class="access-grid">
|
||||||
|
<article class="panel access-list-panel"><div class="panel-heading"><div><h2>接入应用</h2></div><button class="text-button" id="access-refresh">刷新 ↻</button></div>
|
||||||
|
<label class="access-search">查找应用<input id="access-search" type="search" placeholder="搜索本页应用或环境" autocomplete="off"></label><div id="access-list" class="access-list"><p class="panel-description">正在读取应用…</p></div><p id="access-search-empty" class="panel-description" hidden>本页没有匹配的应用。清空搜索或翻到下一页查找。</p>
|
||||||
|
<div class="access-pager"><button class="text-button" id="access-first">第一页</button><button class="text-button" id="access-next" disabled>下一页 →</button></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel access-detail-panel"><div id="access-empty" class="empty-row">选择应用,查看队列和接入配置。</div>
|
||||||
|
<div id="access-detail" hidden>
|
||||||
|
<div class="panel-heading"><div><h2 id="access-title"></h2></div><span class="connection-state" id="access-status"></span></div>
|
||||||
|
<details class="technical-details"><summary>应用标识与环境</summary><div class="access-meta"><div><span>应用 ID</span><code id="access-app-id"></code></div><div><span>环境 / 命名空间</span><b id="access-scope"></b></div><div><span>命名空间 ID</span><code id="access-ns-id"></code></div></div></details>
|
||||||
|
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||||
|
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||||
|
<div class="access-credentials" id="access-credentials"></div>
|
||||||
|
<p class="panel-description" id="access-queue-note"></p><p class="panel-description" id="access-capacity"></p><details class="danger-zone"><summary>队列管理</summary><button class="text-button" type="button" id="access-retire-queue">退役所选队列</button>
|
||||||
|
<form id="access-retire-form" hidden><p class="panel-description">退役不可恢复。先暂停关联计划、结清任务与流程,再退役所有订阅;历史和审计继续保留。</p><label>退役原因<input id="access-retire-reason" maxlength="1024" required></label><button class="primary-button compact" type="submit" id="access-retire-submit">确认退役</button><button class="text-button" type="button" id="access-retire-cancel">取消</button><p id="access-retire-error" class="action-error"></p></form></details>
|
||||||
|
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||||
|
<details class="technical-details"><summary>配置预览(隐藏 Token)</summary><div class="access-config-heading"><h3>接入配置预览</h3><button class="text-button" id="access-copy-template">查看完整接入配置 ↗</button></div>
|
||||||
|
<p class="panel-description" id="access-config-note">预览默认隐藏 Token。点击「查看完整接入配置」,即可复制包含当前 Token 的配置,或下载 orbit.env。</p>
|
||||||
|
<pre class="access-code" id="access-config"></pre></details>
|
||||||
|
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span><span id="refresh-mode-label">每 10 秒自动刷新</span> · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
|
<div><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||||
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-status-line"><span class="state-pill" id="detail-state">—</span><span id="detail-updated">—</span></div>
|
<div class="drawer-status-line"><span class="state-pill" id="detail-state">—</span><span id="detail-updated">—</span></div>
|
||||||
@@ -239,8 +239,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
|
<div><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="schedule-detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||||
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-status-line"><span class="state-pill" id="schedule-detail-state">—</span><span id="schedule-detail-updated">—</span></div>
|
<div class="drawer-status-line"><span class="state-pill" id="schedule-detail-state">—</span><span id="schedule-detail-updated">—</span></div>
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
<div class="drawer-actions" id="schedule-actions" hidden><button class="quiet-button" id="schedule-control-button" type="button">暂停计划</button></div>
|
<div class="drawer-actions" id="schedule-actions" hidden><button class="quiet-button" id="schedule-control-button" type="button">暂停计划</button></div>
|
||||||
<p class="drawer-note" id="schedule-action-note">计划控制需要 schedules:manage 权限;暂停只停止未来触发,已经创建的任务继续执行。</p>
|
<p class="drawer-note" id="schedule-action-note">计划控制需要 schedules:manage 权限;暂停只停止未来触发,已经创建的任务继续执行。</p>
|
||||||
<form class="action-form" id="schedule-action-form" hidden>
|
<form class="action-form" id="schedule-action-form" hidden>
|
||||||
<div class="action-form-heading"><span class="panel-kicker">SCHEDULE CONTROL</span><h3 id="schedule-action-title">暂停计划</h3></div>
|
<div class="action-form-heading"><h3 id="schedule-action-title">暂停计划</h3></div>
|
||||||
<p id="schedule-action-description">暂停后不会再创建新的任务。</p>
|
<p id="schedule-action-description">暂停后不会再创建新的任务。</p>
|
||||||
<label class="field-label" for="schedule-action-reason">操作原因</label><textarea id="schedule-action-reason" rows="3" maxlength="1024" placeholder="说明本次计划控制的原因"></textarea>
|
<label class="field-label" for="schedule-action-reason">操作原因</label><textarea id="schedule-action-reason" rows="3" maxlength="1024" placeholder="说明本次计划控制的原因"></textarea>
|
||||||
<div class="action-form-buttons"><button class="quiet-button" id="schedule-action-cancel" type="button">返回</button><button class="primary-button compact" id="schedule-action-submit" type="submit">提交控制 →</button></div>
|
<div class="action-form-buttons"><button class="quiet-button" id="schedule-action-cancel" type="button">返回</button><button class="primary-button compact" id="schedule-action-submit" type="submit">提交控制 →</button></div>
|
||||||
@@ -267,8 +267,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
|
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
|
<div><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="workflow-detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||||
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
|
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-status-line"><span class="state-pill" id="workflow-detail-state">—</span><span id="workflow-detail-updated">—</span></div>
|
<div class="drawer-status-line"><span class="state-pill" id="workflow-detail-state">—</span><span id="workflow-detail-updated">—</span></div>
|
||||||
@@ -291,14 +291,14 @@
|
|||||||
<div class="drawer-actions" id="workflow-actions" hidden><button class="quiet-button" id="workflow-control-button" type="button">暂停流程</button><button class="quiet-button danger-button" id="workflow-cancel-button" type="button">取消流程</button></div>
|
<div class="drawer-actions" id="workflow-actions" hidden><button class="quiet-button" id="workflow-control-button" type="button">暂停流程</button><button class="quiet-button danger-button" id="workflow-cancel-button" type="button">取消流程</button></div>
|
||||||
<p class="drawer-note" id="workflow-detail-note">流程详情只读取当前权威视图;事件审计需要 workflows:audit:read 权限,所有控制操作仍由服务端权限和并发版本校验。</p>
|
<p class="drawer-note" id="workflow-detail-note">流程详情只读取当前权威视图;事件审计需要 workflows:audit:read 权限,所有控制操作仍由服务端权限和并发版本校验。</p>
|
||||||
<form class="action-form" id="workflow-action-form" hidden>
|
<form class="action-form" id="workflow-action-form" hidden>
|
||||||
<div class="action-form-heading"><span class="panel-kicker">WEAVE CONTROL</span><h3 id="workflow-action-title">流程控制</h3></div>
|
<div class="action-form-heading"><h3 id="workflow-action-title">流程控制</h3></div>
|
||||||
<p id="workflow-action-description">流程控制会写入不可变审计,并使用当前控制修订进行并发校验。</p>
|
<p id="workflow-action-description">流程控制会写入不可变审计,并使用当前控制修订进行并发校验。</p>
|
||||||
<label class="field-label" for="workflow-action-reason">操作原因</label><textarea id="workflow-action-reason" rows="3" maxlength="1024" placeholder="说明本次流程控制的原因"></textarea>
|
<label class="field-label" for="workflow-action-reason">操作原因</label><textarea id="workflow-action-reason" rows="3" maxlength="1024" placeholder="说明本次流程控制的原因"></textarea>
|
||||||
<div class="action-form-buttons"><button class="quiet-button" id="workflow-action-cancel" type="button">返回</button><button class="primary-button compact" id="workflow-action-submit" type="submit">提交控制 →</button></div>
|
<div class="action-form-buttons"><button class="quiet-button" id="workflow-action-cancel" type="button">返回</button><button class="primary-button compact" id="workflow-action-submit" type="submit">提交控制 →</button></div>
|
||||||
<div class="action-error" id="workflow-action-error" role="alert"></div>
|
<div class="action-error" id="workflow-action-error" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
<form class="action-form recovery-form" id="workflow-recovery-form" hidden>
|
<form class="action-form recovery-form" id="workflow-recovery-form" hidden>
|
||||||
<div class="action-form-heading"><span class="panel-kicker">WEAVE RECOVERY</span><h3 id="workflow-recovery-title">人工恢复</h3></div>
|
<div class="action-form-heading"><h3 id="workflow-recovery-title">人工恢复</h3></div>
|
||||||
<p id="workflow-recovery-description">提交后会写入流程审计,并按当前版本重新读取实例。</p>
|
<p id="workflow-recovery-description">提交后会写入流程审计,并按当前版本重新读取实例。</p>
|
||||||
<div class="recovery-snapshot" id="workflow-recovery-snapshot">当前流程修订 — · 当前步骤 —</div>
|
<div class="recovery-snapshot" id="workflow-recovery-snapshot">当前流程修订 — · 当前步骤 —</div>
|
||||||
<label class="field-label" for="workflow-recovery-reason">核查原因</label><textarea id="workflow-recovery-reason" rows="3" maxlength="1024" placeholder="说明你核实了什么事实,以及为什么可以执行该决定"></textarea>
|
<label class="field-label" for="workflow-recovery-reason">核查原因</label><textarea id="workflow-recovery-reason" rows="3" maxlength="1024" placeholder="说明你核实了什么事实,以及为什么可以执行该决定"></textarea>
|
||||||
@@ -324,8 +324,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
|
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
|
||||||
<div class="drawer-header">
|
<div class="detail-scope"></div><div class="drawer-header">
|
||||||
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
|
<div><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small><button type="button" class="copy-id text-button" data-copy-id="subscription-detail-id" aria-label="复制资源 ID">复制 ID</button></div>
|
||||||
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
|
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-status-line"><span class="state-pill" id="subscription-detail-state">—</span><span id="subscription-detail-updated">—</span></div>
|
<div class="drawer-status-line"><span class="state-pill" id="subscription-detail-state">—</span><span id="subscription-detail-updated">—</span></div>
|
||||||
@@ -340,17 +340,17 @@
|
|||||||
<div><span>事件类型</span><b id="subscription-detail-types">—</b></div>
|
<div><span>事件类型</span><b id="subscription-detail-types">—</b></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-block"><div class="detail-block-heading"><span>最近交付</span><small id="subscription-deliveries-count">加载中</small></div><div class="detail-list" id="subscription-deliveries"><div class="detail-loading">正在读取交付历史…</div></div></div>
|
<div class="detail-block"><div class="detail-block-heading"><span>最近交付</span><small id="subscription-deliveries-count">加载中</small></div><div class="detail-list" id="subscription-deliveries"><div class="detail-loading">正在读取交付历史…</div></div></div>
|
||||||
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
<div class="drawer-actions" id="subscription-actions" hidden><button class="quiet-button" id="subscription-retire-button" type="button">退役订阅</button><button class="quiet-button" id="subscription-control-button" type="button">暂停订阅</button></div>
|
||||||
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
|
<p class="drawer-note" id="subscription-detail-note">交付详情不展示租约秘密;结果确认与人工重试必须通过受保护的消费或管理接口完成。</p>
|
||||||
<form class="action-form" id="subscription-action-form" hidden>
|
<form class="action-form" id="subscription-action-form" hidden>
|
||||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS CONTROL</span><h3 id="subscription-action-title">订阅控制</h3></div>
|
<div class="action-form-heading"><h3 id="subscription-action-title">订阅控制</h3></div>
|
||||||
<p id="subscription-action-description">订阅控制会保留已有交付和审计记录。</p>
|
<p id="subscription-action-description">订阅控制会保留已有交付和审计记录。</p>
|
||||||
<label class="field-label" for="subscription-action-reason">操作原因</label><textarea id="subscription-action-reason" rows="3" maxlength="1024" placeholder="说明本次订阅控制的原因"></textarea>
|
<label class="field-label" for="subscription-action-reason">操作原因</label><textarea id="subscription-action-reason" rows="3" maxlength="1024" placeholder="说明本次订阅控制的原因"></textarea>
|
||||||
<div class="action-form-buttons"><button class="quiet-button" id="subscription-action-cancel" type="button">返回</button><button class="primary-button compact" id="subscription-action-submit" type="submit">提交控制 →</button></div>
|
<div class="action-form-buttons"><button class="quiet-button" id="subscription-action-cancel" type="button">返回</button><button class="primary-button compact" id="subscription-action-submit" type="submit">提交控制 →</button></div>
|
||||||
<div class="action-error" id="subscription-action-error" role="alert"></div>
|
<div class="action-error" id="subscription-action-error" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
<form class="action-form" id="subscription-retry-form" hidden>
|
<form class="action-form" id="subscription-retry-form" hidden>
|
||||||
<div class="action-form-heading"><span class="panel-kicker">EVENTS RECOVERY</span><h3>重试过期交付</h3></div>
|
<div class="action-form-heading"><h3>重试过期交付</h3></div>
|
||||||
<p>只允许对 EXPIRED 交付追加次数预算;原交付身份、已用次数和事件不会重置。</p>
|
<p>只允许对 EXPIRED 交付追加次数预算;原交付身份、已用次数和事件不会重置。</p>
|
||||||
<label class="field-label" for="subscription-retry-reason">操作原因</label><textarea id="subscription-retry-reason" rows="3" maxlength="1024" placeholder="说明接收方已修复的原因"></textarea>
|
<label class="field-label" for="subscription-retry-reason">操作原因</label><textarea id="subscription-retry-reason" rows="3" maxlength="1024" placeholder="说明接收方已修复的原因"></textarea>
|
||||||
<label class="field-label" for="subscription-retry-attempts">追加次数(1–100)</label><input id="subscription-retry-attempts" type="number" min="1" max="100" step="1" value="1">
|
<label class="field-label" for="subscription-retry-attempts">追加次数(1–100)</label><input id="subscription-retry-attempts" type="number" min="1" max="100" step="1" value="1">
|
||||||
@@ -360,6 +360,63 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast" role="status"></div>
|
<div class="toast" id="toast" role="status"></div>
|
||||||
|
<dialog id="login-audit-dialog" class="access-dialog login-audit-dialog" aria-labelledby="login-audit-title">
|
||||||
|
<div class="access-dialog-top"><button type="button" class="icon-button" id="login-audit-close" aria-label="关闭登录审计">×</button></div>
|
||||||
|
<h2 id="login-audit-title">登录审计</h2>
|
||||||
|
<p class="panel-description">记录登录成功、失败、临时锁定与限流。每页 10 条,时间为当前时区。</p>
|
||||||
|
<p id="login-audit-status" role="status"></p>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>时间</th><th>账号</th><th>来源 IP</th><th>结果</th><th>可重试时间</th></tr></thead><tbody id="login-audit-rows"></tbody></table></div>
|
||||||
|
<div class="audit-pagination"><button class="secondary-button compact" id="login-audit-first">最新记录</button><span id="login-audit-page">第 1 页</span><button class="secondary-button compact" id="login-audit-next" disabled>下一页 →</button></div>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="access-dialog" class="access-dialog">
|
||||||
|
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||||
|
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||||
|
<p class="access-token-once-note" id="access-token-once-note">Token 会加密保存。管理员可随时查看接入信息、复制完整配置或下载 orbit.env。</p>
|
||||||
|
<form id="access-form">
|
||||||
|
<div id="access-create-fields">
|
||||||
|
<label>应用名称<input id="access-name" maxlength="128" placeholder="例如 order-service" autocomplete="off" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||||
|
<div class="access-config-grid"><label>环境<select id="access-environment"><option value="test">测试 · test</option><option value="development">开发 · development</option><option value="production">生产 · production</option></select></label><label>命名空间<input id="access-namespace" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label></div>
|
||||||
|
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||||
|
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="access-config-grid"><label id="access-valid-field">凭据有效期<select id="access-valid-days"><option value="0">长期有效</option><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||||
|
<label id="access-grace-field" hidden>旧 Token 过渡期<select id="access-grace-days"><option value="7">7 天</option><option value="1">1 天</option><option value="30">30 天</option></select><small>新旧 Token 同时可用。请在截止前完成所有服务的配置更新;旧 Token 原到期时间更早时,以原到期时间为准。</small></label>
|
||||||
|
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||||
|
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
|
||||||
|
</form>
|
||||||
|
<p id="access-clipboard-status" class="panel-description" role="status"></p>
|
||||||
|
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="access-token-once-note" id="access-result-note">复制下面的完整接入配置即可用于业务服务。关闭后仍可从凭据卡片的「查看接入信息」重新获取当前 Token。</p><label id="access-result-config-field">完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions" id="access-result-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="access-help-dialog" class="access-dialog access-credential-help" aria-labelledby="access-help-title">
|
||||||
|
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-help-close" aria-label="关闭凭据说明">×</button></div>
|
||||||
|
<h2 id="access-help-title">凭据与换证说明</h2>
|
||||||
|
<p class="panel-description" id="access-help-scope"></p>
|
||||||
|
<div class="access-rotation-notice"><strong>当前换证状态</strong><p id="access-help-state"></p></div>
|
||||||
|
<h3>查看和复制当前接入配置</h3>
|
||||||
|
<p>在凭据卡片点击「查看接入信息」或「复制接入配置」。配置包含服务地址、应用 ID、命名空间、队列和当前 Token;换证过渡期也可以获取。</p>
|
||||||
|
<div id="access-help-transition" hidden><p>当前新 Token 可以正常查看和复制。确认所有服务已切换、在途任务已收尾后,再结束旧 Token 过渡。</p></div>
|
||||||
|
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-help-done">我知道了</button></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="access-token-dialog" class="access-dialog" aria-labelledby="access-token-title">
|
||||||
|
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-token-close" aria-label="关闭接入信息">×</button></div>
|
||||||
|
<h2 id="access-token-title">接入信息</h2><p class="panel-description" id="access-token-scope"></p>
|
||||||
|
<form id="access-token-import" hidden>
|
||||||
|
<p class="panel-description">此凭据创建于 Token 保管功能启用之前。导入已保存的当前 Token 后,管理员即可随时查看和复制完整配置。导入不会换证,也不会改变有效期或过渡时间。</p>
|
||||||
|
<label>当前 Token<input id="access-token-input" type="password" autocomplete="off" spellcheck="false" maxlength="512" required placeholder="粘贴当前 ORBIT_TOKEN"></label>
|
||||||
|
<button type="submit" class="primary-button compact" id="access-token-import-submit">导入并查看接入配置 →</button>
|
||||||
|
</form>
|
||||||
|
<section id="access-token-output" hidden>
|
||||||
|
<label>完整接入配置<textarea id="access-token-config" readonly rows="8" spellcheck="false" autocomplete="off"></textarea></label>
|
||||||
|
<p class="panel-description">按所选服务地址和授权队列生成,包含当前 Token。可以直接粘贴到业务服务的环境配置中。</p>
|
||||||
|
<div class="access-form-actions"><button type="button" class="primary-button compact" id="access-token-copy">复制接入配置 ↗</button><button type="button" class="secondary-button compact" id="access-token-download">下载 orbit.env ↓</button></div>
|
||||||
|
</section>
|
||||||
|
<p class="action-error" id="access-token-error" role="status"></p>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/console/console-core.js"></script>
|
||||||
|
<script src="/console/console-select.js"></script>
|
||||||
|
<script src="/console/applications.js"></script>
|
||||||
<script src="/console/app.js"></script>
|
<script src="/console/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
140
internal/transport/httpserver/console_applications.go
Normal file
140
internal/transport/httpserver/console_applications.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterConsoleApplications 只接受登录 Cookie;业务 Bearer 不能访问跨应用管理。
|
||||||
|
func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.Store, store consoleauth.Applications) {
|
||||||
|
if server == nil || consoleAuth == nil || store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux, ok := server.Handler.(*http.ServeMux)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
check := func(w http.ResponseWriter, r *http.Request) (auth.Token, bool) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
token, ok := consoleCookieToken(r)
|
||||||
|
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
|
writeError(w, "", fault.Unauthorized)
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
// 自定义请求头触发跨域预检;不开放 CORS,另校验浏览器 Origin 和 Fetch Metadata。
|
||||||
|
if r.Method != "GET" {
|
||||||
|
if r.Header.Get("X-Orbit-Console") != "1" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
if origin := r.Header.Get("Origin"); origin != "" {
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || !strings.EqualFold(u.Host, r.Host) || u.User != nil {
|
||||||
|
writeError(w, "", fault.Forbidden)
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
|
if auditor, ok := store.(consoleauth.LoginAuditor); ok {
|
||||||
|
mux.HandleFunc("GET /console/auth/audit", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := auditor.ListLoginAudit(r.Context(), token, r.URL.Query().Get("before"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.ListApplications(r.Context(), token, core.ID(r.URL.Query().Get("after")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /console/applications/{namespace}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.GetApplication(r.Context(), token, core.ID(r.PathValue("namespace")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /console/applications/prepare", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in consoleauth.ApplicationInput
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.PrepareApplication(r.Context(), token, in)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /console/applications/apply", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in consoleauth.ApplicationOperation
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.ApplyApplication(r.Context(), token, in)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
if secrets, ok := store.(consoleauth.CredentialTokens); ok {
|
||||||
|
for _, action := range []string{"reveal", "import"} {
|
||||||
|
mux.HandleFunc("POST /console/applications/{namespace}/credentials/{credential}/token/"+action, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := check(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in consoleauth.CredentialTokenInput
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := secrets.AccessCredentialToken(r.Context(), token, core.ID(r.PathValue("namespace")), core.ID(r.PathValue("credential")), in, action == "import")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
internal/transport/httpserver/console_applications_test.go
Normal file
127
internal/transport/httpserver/console_applications_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeApplications struct{ calls int }
|
||||||
|
|
||||||
|
func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationPage{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID) (consoleauth.ApplicationDetail, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationDetail{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationOperation{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||||
|
f.calls++
|
||||||
|
return consoleauth.ApplicationReceipt{}, nil
|
||||||
|
}
|
||||||
|
func TestConsoleApplicationSessionAndCSRF(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
id, _ := core.NewID()
|
||||||
|
raw, _, _ := auth.Issue(ns, id)
|
||||||
|
cases := []struct {
|
||||||
|
name, method, path, origin, header string
|
||||||
|
cookie bool
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{name: "no login", method: "GET", path: "/console/applications", status: 401},
|
||||||
|
{name: "SDK bearer only", method: "POST", path: "/console/applications/prepare", header: "1", body: `{}`, status: 401},
|
||||||
|
{name: "missing csrf header", method: "POST", path: "/console/applications/prepare", cookie: true, body: `{}`, status: 403},
|
||||||
|
{name: "cross origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "https://evil.example", body: `{}`, status: 403},
|
||||||
|
{name: "null origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "null", body: `{}`, status: 403},
|
||||||
|
{name: "same origin", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", origin: "http://example.com", body: `{}`, status: 200},
|
||||||
|
{name: "unknown field", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", body: `{"admin":true}`, status: 400},
|
||||||
|
{name: "read", method: "GET", path: "/console/applications", cookie: true, status: 200},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
authStore := &fakeConsoleAuth{valid: true}
|
||||||
|
store := &fakeApplications{}
|
||||||
|
server := NewWithConsoleAuth("", nil, authStore)
|
||||||
|
RegisterConsoleApplications(server, authStore, store)
|
||||||
|
req := httptest.NewRequest(tt.method, "http://example.com"+tt.path, strings.NewReader(tt.body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+raw)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Orbit-Console", tt.header)
|
||||||
|
if tt.origin != "" {
|
||||||
|
req.Header.Set("Origin", tt.origin)
|
||||||
|
}
|
||||||
|
if tt.cookie {
|
||||||
|
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||||
|
}
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.Handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != tt.status {
|
||||||
|
t.Fatalf("status=%d wanted=%d", res.Code, tt.status)
|
||||||
|
}
|
||||||
|
if tt.status != 200 && store.calls != 0 {
|
||||||
|
t.Fatal("rejected request reached store")
|
||||||
|
}
|
||||||
|
if res.Header().Get("Cache-Control") != "no-store" {
|
||||||
|
t.Fatal("management response cacheable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备接口生成的内容必须可被严格解码器原样提交,覆盖可选 ID 和空能力组。
|
||||||
|
func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
cred, _ := core.NewID()
|
||||||
|
app, _ := core.NewID()
|
||||||
|
queue, _ := core.NewID()
|
||||||
|
opID, _ := core.NewID()
|
||||||
|
token, _, _ := auth.Issue(ns, cred)
|
||||||
|
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} {
|
||||||
|
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()}
|
||||||
|
if kind == "CREATE" {
|
||||||
|
op.QueueID = queue
|
||||||
|
op.Input.Application = "sample"
|
||||||
|
op.Input.Namespace = "default"
|
||||||
|
op.Input.Environment = "test"
|
||||||
|
op.Input.Queue = "work"
|
||||||
|
} else {
|
||||||
|
op.Input.NamespaceID = ns
|
||||||
|
op.Input.CredentialID = cred
|
||||||
|
op.Input.ExpectedRevision = 1
|
||||||
|
}
|
||||||
|
if kind == "SET_EXPIRY" || kind == "FINALIZE" {
|
||||||
|
op.Version = 2
|
||||||
|
op.Token = ""
|
||||||
|
op.ExpiresAt = nil
|
||||||
|
op.Input.ValidDays = 0
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(op)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/console/applications/apply", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
var got consoleauth.ApplicationOperation
|
||||||
|
if err := decodeBody(req, &got); err != nil {
|
||||||
|
t.Fatalf("%s prepared output rejected: %v", kind, err)
|
||||||
|
}
|
||||||
|
if got.Token != op.Token || got.Input.Kind != kind {
|
||||||
|
t.Fatal("prepared operation changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ package httpserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,12 +17,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeConsoleAuth struct {
|
type fakeConsoleAuth struct {
|
||||||
valid bool
|
valid bool
|
||||||
login consoleauth.LoginResult
|
login consoleauth.LoginResult
|
||||||
|
loginError error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
||||||
return f.login, nil
|
return f.login, f.loginError
|
||||||
}
|
}
|
||||||
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
||||||
if !f.valid {
|
if !f.valid {
|
||||||
@@ -82,3 +86,69 @@ func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) {
|
|||||||
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConsoleLoginRejectsWithRetryAndNoCookie(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"password", fault.Unauthorized, 401, "UNAUTHENTICATED"},
|
||||||
|
{"locked", &consoleauth.LoginBlocked{Locked: true, RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_LOCKED"},
|
||||||
|
{"rate", &consoleauth.LoginBlocked{RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_RATE_LIMITED"},
|
||||||
|
{"storage", errors.New("private database error"), 503, "UNAVAILABLE"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{loginError: tc.err})
|
||||||
|
req := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"password-password"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
server.Handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.code) || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private database") {
|
||||||
|
t.Fatalf("response: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w.Code == 429 && w.Header().Get("Retry-After") != "2" {
|
||||||
|
t.Fatal("missing rounded Retry-After")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type policyConsoleAuth struct {
|
||||||
|
fakeConsoleAuth
|
||||||
|
policy consoleauth.LoginPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *policyConsoleAuth) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||||
|
func TestLoginSourceOnlyTrustsConfiguredProxyChain(t *testing.T) {
|
||||||
|
s := &policyConsoleAuth{policy: consoleauth.DefaultLoginPolicy()}
|
||||||
|
s.policy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||||
|
for _, tc := range []struct{ peer, forward, want string }{
|
||||||
|
{"192.0.2.1:1234", "198.51.100.7", "192.0.2.1"},
|
||||||
|
{"10.0.0.1:1234", "198.51.100.7, 10.0.0.2", "198.51.100.7"},
|
||||||
|
{"10.0.0.1:1234", "203.0.113.99, 198.51.100.7", "198.51.100.7"},
|
||||||
|
{"10.0.0.1:1234", "garbage, 10.0.0.2", "10.0.0.1"},
|
||||||
|
{"[::1]:1234", "::ffff:192.0.2.1", "192.0.2.1"},
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequest("POST", "/console/auth/login", nil)
|
||||||
|
r.RemoteAddr = tc.peer
|
||||||
|
r.Header.Set("X-Forwarded-For", tc.forward)
|
||||||
|
r.Header.Set("X-Real-IP", "203.0.113.123")
|
||||||
|
if got := loginRemoteAddress(r, s); got != tc.want {
|
||||||
|
t.Fatalf("%s / %s: got %s", tc.peer, tc.forward, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleLoginRejectsCrossSite(t *testing.T) {
|
||||||
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
||||||
|
r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Origin", "https://evil.invalid")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
server.Handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != 403 || len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Fatalf("cross-site response: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
57
internal/transport/httpserver/console_login.go
Normal file
57
internal/transport/httpserver/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginRemoteAddress 默认只信任 TCP 对端;仅受信反代可提供 X-Forwarded-For。
|
||||||
|
// 从右往左剥离可信跳点,遇到首个非可信地址即停止;不接受任意客户端伪造的首项。
|
||||||
|
func loginRemoteAddress(r *http.Request, store consoleauth.Store) string {
|
||||||
|
peer, err := netip.ParseAddr(remoteAddress(r))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
peer = peer.Unmap()
|
||||||
|
policyStore, ok := store.(interface {
|
||||||
|
LoginPolicy() consoleauth.LoginPolicy
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return peer.String()
|
||||||
|
}
|
||||||
|
trusted := policyStore.LoginPolicy().TrustedProxies
|
||||||
|
isTrusted := func(ip netip.Addr) bool {
|
||||||
|
for _, n := range trusted {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isTrusted(peer) {
|
||||||
|
return peer.String()
|
||||||
|
}
|
||||||
|
raw := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
|
||||||
|
if raw == "" || len(raw) > 2048 {
|
||||||
|
return peer.String()
|
||||||
|
}
|
||||||
|
hops := strings.Split(raw, ",")
|
||||||
|
if len(hops) > 32 {
|
||||||
|
return peer.String()
|
||||||
|
}
|
||||||
|
addresses := make([]netip.Addr, len(hops))
|
||||||
|
for i, h := range hops {
|
||||||
|
a, e := netip.ParseAddr(strings.TrimSpace(h))
|
||||||
|
if e != nil {
|
||||||
|
return peer.String()
|
||||||
|
}
|
||||||
|
addresses[i] = a.Unmap()
|
||||||
|
}
|
||||||
|
current := peer
|
||||||
|
for i := len(addresses) - 1; i >= 0 && isTrusted(current); i-- {
|
||||||
|
current = addresses[i]
|
||||||
|
}
|
||||||
|
return current.String()
|
||||||
|
}
|
||||||
64
internal/transport/httpserver/console_scope.go
Normal file
64
internal/transport/httpserver/console_scope.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type consoleScopeKey struct{}
|
||||||
|
|
||||||
|
func scopedConsoleToken(r *http.Request, original auth.Token) auth.Token {
|
||||||
|
if t, ok := r.Context().Value(consoleScopeKey{}).(auth.Token); ok && t.Console != nil && t.Console.SessionNamespaceID == original.NamespaceID && t.CredentialID == original.CredentialID && t.Digest == original.Digest {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleScopePath 仅开放已有管理读写,不允许借控制台范围启动 Worker/结果消费或创建业务资源。
|
||||||
|
func consoleScopePath(r *http.Request) bool {
|
||||||
|
p := r.URL.Path
|
||||||
|
if p == "/console/scope" {
|
||||||
|
return r.Method == "GET"
|
||||||
|
}
|
||||||
|
if p == "/console/retention" || strings.HasPrefix(p, "/console/retention/") || strings.HasPrefix(p, "/console/archive/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, resource := range []string{"tasks", "schedules", "workflows", "subscriptions", "queues"} {
|
||||||
|
prefix := "/v1/" + resource
|
||||||
|
if p == prefix {
|
||||||
|
return r.Method == "GET"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, prefix+"/") {
|
||||||
|
return r.Method == "GET" || (r.Method == "POST" && (resource != "queues" || strings.HasSuffix(p, ":retire")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterConsoleScopes(server *http.Server, store consoleauth.Scopes) {
|
||||||
|
if server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux, ok := server.Handler.(*http.ServeMux)
|
||||||
|
if !ok || store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.HandleFunc("GET /console/scope", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, ok := consoleCookieToken(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, "", fault.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := store.GetScope(r.Context(), scopedConsoleToken(r, t), core.ID(r.URL.Query().Get("after")))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||||
|
})
|
||||||
|
}
|
||||||
79
internal/transport/httpserver/console_scope_test.go
Normal file
79
internal/transport/httpserver/console_scope_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeConsoleScopes struct{ calls int }
|
||||||
|
|
||||||
|
func (s *fakeConsoleScopes) ResolveScope(_ context.Context, t auth.Token, target core.ID) (auth.Token, error) {
|
||||||
|
s.calls++
|
||||||
|
t.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: "admin", UserID: t.CredentialID}
|
||||||
|
t.NamespaceID = target
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
func (*fakeConsoleScopes) GetScope(context.Context, auth.Token, core.ID) (consoleauth.Scope, error) {
|
||||||
|
return consoleauth.Scope{}, nil
|
||||||
|
}
|
||||||
|
func TestConsoleScopeOnlyBridgesCookieManagementRequests(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
cred, _ := core.NewID()
|
||||||
|
target, _ := core.NewID()
|
||||||
|
raw, _, _ := auth.Issue(ns, cred)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, method, path, origin, bearer string
|
||||||
|
cookie, marker bool
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"read", "GET", "/v1/tasks", "", "", true, true, 200},
|
||||||
|
{"control", "POST", "/v1/tasks/task:cancel", "http://example.com", "", true, true, 200},
|
||||||
|
{"SDK", "GET", "/v1/tasks", "", "Bearer " + raw, false, true, 403},
|
||||||
|
{"ambiguous", "GET", "/v1/tasks", "", "Bearer " + raw, true, true, 403},
|
||||||
|
{"anonymous", "GET", "/v1/tasks", "", "", false, true, 401},
|
||||||
|
{"missing-marker", "GET", "/v1/tasks", "", "", true, false, 403},
|
||||||
|
{"cross-origin", "POST", "/v1/tasks/task:cancel", "https://other.invalid", "", true, true, 403},
|
||||||
|
{"worker", "GET", "/v1/worker/connect", "", "", true, true, 403},
|
||||||
|
{"consumer", "GET", "/v1/events/connect", "", "", true, true, 403},
|
||||||
|
{"create", "POST", "/v1/tasks", "", "", true, true, 403},
|
||||||
|
{"onboarding", "POST", "/console/applications/prepare", "", "", true, true, 403},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
scope := &fakeConsoleScopes{}
|
||||||
|
handler := ConsoleSessionMiddleware(wrap(func(r *http.Request, tok auth.Token) (any, int, error) {
|
||||||
|
if tok.NamespaceID != target || tok.Console == nil || tok.Console.SessionNamespaceID != ns {
|
||||||
|
t.Fatal("scope lost")
|
||||||
|
}
|
||||||
|
return map[string]bool{"ok": true}, 200, nil
|
||||||
|
}), &fakeConsoleAuth{valid: true}, scope)
|
||||||
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||||
|
req.Header.Set("X-Orbit-Namespace", string(target))
|
||||||
|
if tc.marker {
|
||||||
|
req.Header.Set("X-Orbit-Console", "1")
|
||||||
|
}
|
||||||
|
if tc.origin != "" {
|
||||||
|
req.Header.Set("Origin", tc.origin)
|
||||||
|
}
|
||||||
|
if tc.bearer != "" {
|
||||||
|
req.Header.Set("Authorization", tc.bearer)
|
||||||
|
}
|
||||||
|
if tc.cookie {
|
||||||
|
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||||
|
}
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != tc.status {
|
||||||
|
t.Fatalf("HTTP %d: %s", res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if tc.status != 200 && scope.calls != 0 {
|
||||||
|
t.Fatal("rejected request reached scope resolver")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,12 +47,24 @@ func RegisterEventSubscriptions(server *http.Server, s *events.Service) {
|
|||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /v1/subscriptions/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
mux.HandleFunc("POST /v1/subscriptions/{command}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
|
||||||
id, command, ok := strings.Cut(r.PathValue("command"), ":")
|
id, command, ok := strings.Cut(r.PathValue("command"), ":")
|
||||||
if !ok || command != "configure" {
|
if !ok || (command != "configure" && command != "retire") {
|
||||||
return nil, 0, fault.NotFound
|
return nil, 0, fault.NotFound
|
||||||
}
|
}
|
||||||
if err := noEventQuery(r); err != nil {
|
if err := noEventQuery(r); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
if command == "retire" {
|
||||||
|
var in events.RetireInput
|
||||||
|
if err := decodeEventBody(r, &in); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
key, err := idempotencyKey(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out, err := s.Retire(r.Context(), t, core.ID(id), key, in)
|
||||||
|
return out, 200, err
|
||||||
|
}
|
||||||
var in events.ControlInput
|
var in events.ControlInput
|
||||||
if err := decodeEventBody(r, &in); err != nil {
|
if err := decodeEventBody(r, &in); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func RegisterConsoleRetention(server *http.Server, consoleAuth RetentionConsoleA
|
|||||||
writeError(w, "", fault.Unauthorized)
|
writeError(w, "", fault.Unauthorized)
|
||||||
return auth.Token{}, false
|
return auth.Token{}, false
|
||||||
}
|
}
|
||||||
return t, true
|
return scopedConsoleToken(r, t), true
|
||||||
}
|
}
|
||||||
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
|
||||||
t, ok := check(w, r)
|
t, ok := check(w, r)
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ func newServer(addr string, ready Checker, consoleAuth consoleauth.Store, tasks
|
|||||||
|
|
||||||
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
|
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
|
||||||
// 直接访问底层 ServeMux;包装完成后,浏览器会话才会转换为内部 Bearer 请求。
|
// 直接访问底层 ServeMux;包装完成后,浏览器会话才会转换为内部 Bearer 请求。
|
||||||
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store) {
|
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) {
|
||||||
if server == nil || consoleAuth == nil {
|
if server == nil || consoleAuth == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth)
|
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth, scopes...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapMetrics must run after all Register* functions because those functions
|
// WrapMetrics must run after all Register* functions because those functions
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user