ErisPulse-ShadowOps 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- erispulse_shadowops-0.1.0/ErisPulse_ShadowOps.egg-info/PKG-INFO +144 -0
- erispulse_shadowops-0.1.0/ErisPulse_ShadowOps.egg-info/SOURCES.txt +16 -0
- erispulse_shadowops-0.1.0/ErisPulse_ShadowOps.egg-info/dependency_links.txt +1 -0
- erispulse_shadowops-0.1.0/ErisPulse_ShadowOps.egg-info/entry_points.txt +2 -0
- erispulse_shadowops-0.1.0/ErisPulse_ShadowOps.egg-info/requires.txt +5 -0
- erispulse_shadowops-0.1.0/ErisPulse_ShadowOps.egg-info/top_level.txt +1 -0
- erispulse_shadowops-0.1.0/LICENSE +21 -0
- erispulse_shadowops-0.1.0/PKG-INFO +144 -0
- erispulse_shadowops-0.1.0/README.md +129 -0
- erispulse_shadowops-0.1.0/ShadowOps/Config.py +48 -0
- erispulse_shadowops-0.1.0/ShadowOps/Core.py +148 -0
- erispulse_shadowops-0.1.0/ShadowOps/I18n.py +125 -0
- erispulse_shadowops-0.1.0/ShadowOps/Service.py +166 -0
- erispulse_shadowops-0.1.0/ShadowOps/Visualizer.py +160 -0
- erispulse_shadowops-0.1.0/ShadowOps/__init__.py +3 -0
- erispulse_shadowops-0.1.0/pyproject.toml +30 -0
- erispulse_shadowops-0.1.0/setup.cfg +4 -0
- erispulse_shadowops-0.1.0/tests/test_commands.py +155 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ErisPulse-ShadowOps
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 影子灰度管理模块:启动/对比/转正/放弃影子模块,附归属权审计
|
|
5
|
+
Author: wsu2059q
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
13
|
+
Requires-Dist: ErisPulse-Testing; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
<div align="center">
|
|
17
|
+
|
|
18
|
+
<img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse ShadowOps" />
|
|
19
|
+
|
|
20
|
+
# ErisPulse ShadowOps
|
|
21
|
+
|
|
22
|
+
**影子灰度管理模块 —— 在聊天中启动、对比、转正、放弃影子模块。**
|
|
23
|
+
|
|
24
|
+
基于 ErisPulse 2.9 影子模块运行时 API 的运营工具,附归属权审计,报告经 Takumi 渲染为卡片。
|
|
25
|
+
|
|
26
|
+
<p>
|
|
27
|
+
<a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/pypi/v/ErisPulse-ShadowOps?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
|
|
28
|
+
<a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
|
|
29
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
|
|
30
|
+
<a href="https://github.com/ErisPulse/ErisPulse-ShadowOps"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-ShadowOps?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
|
|
31
|
+
<a href="https://pepy.tech/project/ErisPulse-ShadowOps"><img src="https://img.shields.io/pepy/dt/ErisPulse-ShadowOps?style=for-the-badge&color=blue" alt="Downloads"></a>
|
|
32
|
+
<a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p>
|
|
36
|
+
<a href="./README.md">简体中文</a> | <a href="./README.en.md">English</a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 简介
|
|
44
|
+
|
|
45
|
+
ShadowOps 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构开发的影子灰度运营模块。影子 = 同一模块的新版本,以独立 owner 与线上旧版并存试运行:收到真实事件副本、出站被拦截记账而非真正发出。本模块把这些运行时操作暴露为聊天命令:
|
|
46
|
+
|
|
47
|
+
- `shadow start`:把 v2 代码目录装载为影子
|
|
48
|
+
- `shadow diff`:对比 v1 实际发送 × v2 意向发送(按 trace_id 对齐)
|
|
49
|
+
- `shadow promote`:确认后一键转正(失败自动回滚)
|
|
50
|
+
- `shadow dismiss`:放弃影子
|
|
51
|
+
- `shadow audit`:归属权审计(资源计数 / 孤儿 owner)
|
|
52
|
+
|
|
53
|
+
要求 ErisPulse >= 2.9.0。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 效果展示
|
|
58
|
+
|
|
59
|
+
| `/shadow list` | `/shadow diff` | `/shadow audit` |
|
|
60
|
+
|:---:|:---:|:---:|
|
|
61
|
+
|  |  |  |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 安装
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
epsdk install ErisPulse-ShadowOps
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 命令
|
|
77
|
+
|
|
78
|
+
| 命令 | 说明 |
|
|
79
|
+
|------|------|
|
|
80
|
+
| `/shadow` | 显示用法 |
|
|
81
|
+
| `/shadow start <模块名> <v2路径>` | 启动影子(v2 代码目录或单 .py 文件,建议放在插件目录之外) |
|
|
82
|
+
| `/shadow list` | 列出运行中的影子 |
|
|
83
|
+
| `/shadow diff <模块名>` | 行为对比报告 |
|
|
84
|
+
| `/shadow promote <模块名> yes` | 转正(必须带 `yes` 确认) |
|
|
85
|
+
| `/shadow dismiss <模块名>` | 放弃影子 |
|
|
86
|
+
| `/shadow audit <模块名>` | 归属权审计 |
|
|
87
|
+
|
|
88
|
+
全部命令需要框架主人权限。
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 使用示例
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# 1. 把新版代码放到服务器任意目录(如 downloads/roll_v2/)
|
|
96
|
+
|
|
97
|
+
# 2. 启动灰度
|
|
98
|
+
/shadow start roll downloads/roll_v2
|
|
99
|
+
|
|
100
|
+
# 3. 试运行一段时间后对比行为
|
|
101
|
+
/shadow diff roll
|
|
102
|
+
|
|
103
|
+
# 4. 确认无害后转正(失败自动回滚旧版本)
|
|
104
|
+
/shadow promote roll yes
|
|
105
|
+
|
|
106
|
+
# 或放弃
|
|
107
|
+
/shadow dismiss roll
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 配置说明
|
|
113
|
+
|
|
114
|
+
```toml
|
|
115
|
+
[ShadowOps]
|
|
116
|
+
enabled = true # 是否启用模块
|
|
117
|
+
diff_max_rows = 10 # 对比报告单次展示条目数
|
|
118
|
+
audit_deep = false # 审计附带 gc 实例普查(有全局暂停开销)
|
|
119
|
+
render_card = true # 报告用 Takumi 渲染为卡片
|
|
120
|
+
theme = "auto" # 卡片配色 (auto/light/dark)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
124
|
+
|--------|------|--------|------|
|
|
125
|
+
| `enabled` | bool | `true` | 是否启用模块 |
|
|
126
|
+
| `diff_max_rows` | int | `10` | 对比报告条目上限 |
|
|
127
|
+
| `audit_deep` | bool | `false` | gc 实例普查 |
|
|
128
|
+
| `render_card` | bool | `true` | 卡片渲染 |
|
|
129
|
+
| `theme` | str | `auto` | 卡片配色 |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 注意事项
|
|
134
|
+
|
|
135
|
+
1. 影子源为本地路径;同一 PyPI 包无法在同解释器内新旧并存
|
|
136
|
+
2. promote 是运行时切换,重启后需持久化安装新版本(`pip install -U`)
|
|
137
|
+
3. 走框架的发送/API/KV 全部拦得住;绕过框架的副作用不可拦截(audit 可见资源归属)
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 参考链接
|
|
142
|
+
|
|
143
|
+
- [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
|
|
144
|
+
- [影子模块文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/advanced/shadow.md)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
ErisPulse_ShadowOps.egg-info/PKG-INFO
|
|
5
|
+
ErisPulse_ShadowOps.egg-info/SOURCES.txt
|
|
6
|
+
ErisPulse_ShadowOps.egg-info/dependency_links.txt
|
|
7
|
+
ErisPulse_ShadowOps.egg-info/entry_points.txt
|
|
8
|
+
ErisPulse_ShadowOps.egg-info/requires.txt
|
|
9
|
+
ErisPulse_ShadowOps.egg-info/top_level.txt
|
|
10
|
+
ShadowOps/Config.py
|
|
11
|
+
ShadowOps/Core.py
|
|
12
|
+
ShadowOps/I18n.py
|
|
13
|
+
ShadowOps/Service.py
|
|
14
|
+
ShadowOps/Visualizer.py
|
|
15
|
+
ShadowOps/__init__.py
|
|
16
|
+
tests/test_commands.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ShadowOps
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wsu2059q
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ErisPulse-ShadowOps
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 影子灰度管理模块:启动/对比/转正/放弃影子模块,附归属权审计
|
|
5
|
+
Author: wsu2059q
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
13
|
+
Requires-Dist: ErisPulse-Testing; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
<div align="center">
|
|
17
|
+
|
|
18
|
+
<img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse ShadowOps" />
|
|
19
|
+
|
|
20
|
+
# ErisPulse ShadowOps
|
|
21
|
+
|
|
22
|
+
**影子灰度管理模块 —— 在聊天中启动、对比、转正、放弃影子模块。**
|
|
23
|
+
|
|
24
|
+
基于 ErisPulse 2.9 影子模块运行时 API 的运营工具,附归属权审计,报告经 Takumi 渲染为卡片。
|
|
25
|
+
|
|
26
|
+
<p>
|
|
27
|
+
<a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/pypi/v/ErisPulse-ShadowOps?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
|
|
28
|
+
<a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
|
|
29
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
|
|
30
|
+
<a href="https://github.com/ErisPulse/ErisPulse-ShadowOps"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-ShadowOps?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
|
|
31
|
+
<a href="https://pepy.tech/project/ErisPulse-ShadowOps"><img src="https://img.shields.io/pepy/dt/ErisPulse-ShadowOps?style=for-the-badge&color=blue" alt="Downloads"></a>
|
|
32
|
+
<a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p>
|
|
36
|
+
<a href="./README.md">简体中文</a> | <a href="./README.en.md">English</a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 简介
|
|
44
|
+
|
|
45
|
+
ShadowOps 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构开发的影子灰度运营模块。影子 = 同一模块的新版本,以独立 owner 与线上旧版并存试运行:收到真实事件副本、出站被拦截记账而非真正发出。本模块把这些运行时操作暴露为聊天命令:
|
|
46
|
+
|
|
47
|
+
- `shadow start`:把 v2 代码目录装载为影子
|
|
48
|
+
- `shadow diff`:对比 v1 实际发送 × v2 意向发送(按 trace_id 对齐)
|
|
49
|
+
- `shadow promote`:确认后一键转正(失败自动回滚)
|
|
50
|
+
- `shadow dismiss`:放弃影子
|
|
51
|
+
- `shadow audit`:归属权审计(资源计数 / 孤儿 owner)
|
|
52
|
+
|
|
53
|
+
要求 ErisPulse >= 2.9.0。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 效果展示
|
|
58
|
+
|
|
59
|
+
| `/shadow list` | `/shadow diff` | `/shadow audit` |
|
|
60
|
+
|:---:|:---:|:---:|
|
|
61
|
+
|  |  |  |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 安装
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
epsdk install ErisPulse-ShadowOps
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 命令
|
|
77
|
+
|
|
78
|
+
| 命令 | 说明 |
|
|
79
|
+
|------|------|
|
|
80
|
+
| `/shadow` | 显示用法 |
|
|
81
|
+
| `/shadow start <模块名> <v2路径>` | 启动影子(v2 代码目录或单 .py 文件,建议放在插件目录之外) |
|
|
82
|
+
| `/shadow list` | 列出运行中的影子 |
|
|
83
|
+
| `/shadow diff <模块名>` | 行为对比报告 |
|
|
84
|
+
| `/shadow promote <模块名> yes` | 转正(必须带 `yes` 确认) |
|
|
85
|
+
| `/shadow dismiss <模块名>` | 放弃影子 |
|
|
86
|
+
| `/shadow audit <模块名>` | 归属权审计 |
|
|
87
|
+
|
|
88
|
+
全部命令需要框架主人权限。
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 使用示例
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# 1. 把新版代码放到服务器任意目录(如 downloads/roll_v2/)
|
|
96
|
+
|
|
97
|
+
# 2. 启动灰度
|
|
98
|
+
/shadow start roll downloads/roll_v2
|
|
99
|
+
|
|
100
|
+
# 3. 试运行一段时间后对比行为
|
|
101
|
+
/shadow diff roll
|
|
102
|
+
|
|
103
|
+
# 4. 确认无害后转正(失败自动回滚旧版本)
|
|
104
|
+
/shadow promote roll yes
|
|
105
|
+
|
|
106
|
+
# 或放弃
|
|
107
|
+
/shadow dismiss roll
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 配置说明
|
|
113
|
+
|
|
114
|
+
```toml
|
|
115
|
+
[ShadowOps]
|
|
116
|
+
enabled = true # 是否启用模块
|
|
117
|
+
diff_max_rows = 10 # 对比报告单次展示条目数
|
|
118
|
+
audit_deep = false # 审计附带 gc 实例普查(有全局暂停开销)
|
|
119
|
+
render_card = true # 报告用 Takumi 渲染为卡片
|
|
120
|
+
theme = "auto" # 卡片配色 (auto/light/dark)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
124
|
+
|--------|------|--------|------|
|
|
125
|
+
| `enabled` | bool | `true` | 是否启用模块 |
|
|
126
|
+
| `diff_max_rows` | int | `10` | 对比报告条目上限 |
|
|
127
|
+
| `audit_deep` | bool | `false` | gc 实例普查 |
|
|
128
|
+
| `render_card` | bool | `true` | 卡片渲染 |
|
|
129
|
+
| `theme` | str | `auto` | 卡片配色 |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 注意事项
|
|
134
|
+
|
|
135
|
+
1. 影子源为本地路径;同一 PyPI 包无法在同解释器内新旧并存
|
|
136
|
+
2. promote 是运行时切换,重启后需持久化安装新版本(`pip install -U`)
|
|
137
|
+
3. 走框架的发送/API/KV 全部拦得住;绕过框架的副作用不可拦截(audit 可见资源归属)
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 参考链接
|
|
142
|
+
|
|
143
|
+
- [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
|
|
144
|
+
- [影子模块文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/advanced/shadow.md)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse ShadowOps" />
|
|
4
|
+
|
|
5
|
+
# ErisPulse ShadowOps
|
|
6
|
+
|
|
7
|
+
**影子灰度管理模块 —— 在聊天中启动、对比、转正、放弃影子模块。**
|
|
8
|
+
|
|
9
|
+
基于 ErisPulse 2.9 影子模块运行时 API 的运营工具,附归属权审计,报告经 Takumi 渲染为卡片。
|
|
10
|
+
|
|
11
|
+
<p>
|
|
12
|
+
<a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/pypi/v/ErisPulse-ShadowOps?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
|
|
13
|
+
<a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
|
|
14
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
|
|
15
|
+
<a href="https://github.com/ErisPulse/ErisPulse-ShadowOps"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-ShadowOps?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
|
|
16
|
+
<a href="https://pepy.tech/project/ErisPulse-ShadowOps"><img src="https://img.shields.io/pepy/dt/ErisPulse-ShadowOps?style=for-the-badge&color=blue" alt="Downloads"></a>
|
|
17
|
+
<a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p>
|
|
21
|
+
<a href="./README.md">简体中文</a> | <a href="./README.en.md">English</a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 简介
|
|
29
|
+
|
|
30
|
+
ShadowOps 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构开发的影子灰度运营模块。影子 = 同一模块的新版本,以独立 owner 与线上旧版并存试运行:收到真实事件副本、出站被拦截记账而非真正发出。本模块把这些运行时操作暴露为聊天命令:
|
|
31
|
+
|
|
32
|
+
- `shadow start`:把 v2 代码目录装载为影子
|
|
33
|
+
- `shadow diff`:对比 v1 实际发送 × v2 意向发送(按 trace_id 对齐)
|
|
34
|
+
- `shadow promote`:确认后一键转正(失败自动回滚)
|
|
35
|
+
- `shadow dismiss`:放弃影子
|
|
36
|
+
- `shadow audit`:归属权审计(资源计数 / 孤儿 owner)
|
|
37
|
+
|
|
38
|
+
要求 ErisPulse >= 2.9.0。
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 效果展示
|
|
43
|
+
|
|
44
|
+
| `/shadow list` | `/shadow diff` | `/shadow audit` |
|
|
45
|
+
|:---:|:---:|:---:|
|
|
46
|
+
|  |  |  |
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 安装
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
epsdk install ErisPulse-ShadowOps
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 命令
|
|
62
|
+
|
|
63
|
+
| 命令 | 说明 |
|
|
64
|
+
|------|------|
|
|
65
|
+
| `/shadow` | 显示用法 |
|
|
66
|
+
| `/shadow start <模块名> <v2路径>` | 启动影子(v2 代码目录或单 .py 文件,建议放在插件目录之外) |
|
|
67
|
+
| `/shadow list` | 列出运行中的影子 |
|
|
68
|
+
| `/shadow diff <模块名>` | 行为对比报告 |
|
|
69
|
+
| `/shadow promote <模块名> yes` | 转正(必须带 `yes` 确认) |
|
|
70
|
+
| `/shadow dismiss <模块名>` | 放弃影子 |
|
|
71
|
+
| `/shadow audit <模块名>` | 归属权审计 |
|
|
72
|
+
|
|
73
|
+
全部命令需要框架主人权限。
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 使用示例
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# 1. 把新版代码放到服务器任意目录(如 downloads/roll_v2/)
|
|
81
|
+
|
|
82
|
+
# 2. 启动灰度
|
|
83
|
+
/shadow start roll downloads/roll_v2
|
|
84
|
+
|
|
85
|
+
# 3. 试运行一段时间后对比行为
|
|
86
|
+
/shadow diff roll
|
|
87
|
+
|
|
88
|
+
# 4. 确认无害后转正(失败自动回滚旧版本)
|
|
89
|
+
/shadow promote roll yes
|
|
90
|
+
|
|
91
|
+
# 或放弃
|
|
92
|
+
/shadow dismiss roll
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 配置说明
|
|
98
|
+
|
|
99
|
+
```toml
|
|
100
|
+
[ShadowOps]
|
|
101
|
+
enabled = true # 是否启用模块
|
|
102
|
+
diff_max_rows = 10 # 对比报告单次展示条目数
|
|
103
|
+
audit_deep = false # 审计附带 gc 实例普查(有全局暂停开销)
|
|
104
|
+
render_card = true # 报告用 Takumi 渲染为卡片
|
|
105
|
+
theme = "auto" # 卡片配色 (auto/light/dark)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
109
|
+
|--------|------|--------|------|
|
|
110
|
+
| `enabled` | bool | `true` | 是否启用模块 |
|
|
111
|
+
| `diff_max_rows` | int | `10` | 对比报告条目上限 |
|
|
112
|
+
| `audit_deep` | bool | `false` | gc 实例普查 |
|
|
113
|
+
| `render_card` | bool | `true` | 卡片渲染 |
|
|
114
|
+
| `theme` | str | `auto` | 卡片配色 |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 注意事项
|
|
119
|
+
|
|
120
|
+
1. 影子源为本地路径;同一 PyPI 包无法在同解释器内新旧并存
|
|
121
|
+
2. promote 是运行时切换,重启后需持久化安装新版本(`pip install -U`)
|
|
122
|
+
3. 走框架的发送/API/KV 全部拦得住;绕过框架的副作用不可拦截(audit 可见资源归属)
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 参考链接
|
|
127
|
+
|
|
128
|
+
- [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
|
|
129
|
+
- [影子模块文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/advanced/shadow.md)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from ErisPulse.Core.Bases import BaseConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ShadowOpsConfig(BaseConfig):
|
|
8
|
+
|
|
9
|
+
enabled: bool = field(
|
|
10
|
+
default=True,
|
|
11
|
+
metadata={
|
|
12
|
+
"description": {"i18n": "ShadowOps.enabled", "default": "是否启用模块"},
|
|
13
|
+
"ui": {"widget": "switch", "group": "basic", "order": 1},
|
|
14
|
+
},
|
|
15
|
+
)
|
|
16
|
+
diff_max_rows: int = field(
|
|
17
|
+
default=10,
|
|
18
|
+
metadata={
|
|
19
|
+
"description": {"i18n": "ShadowOps.diff_max_rows", "default": "对比报告单次展示的最大条目数"},
|
|
20
|
+
"ui": {"widget": "number", "group": "report", "order": 2},
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
audit_deep: bool = field(
|
|
24
|
+
default=False,
|
|
25
|
+
metadata={
|
|
26
|
+
"description": {"i18n": "ShadowOps.audit_deep", "default": "审计时附带 gc 实例普查(有全局暂停开销)"},
|
|
27
|
+
"ui": {"widget": "switch", "group": "report", "order": 3},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
render_card: bool = field(
|
|
31
|
+
default=True,
|
|
32
|
+
metadata={
|
|
33
|
+
"description": {"i18n": "ShadowOps.render_card", "default": "报告用 Takumi 渲染为卡片(不可用时降级文本)"},
|
|
34
|
+
"ui": {"widget": "switch", "group": "render", "order": 4},
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
theme: str = field(
|
|
38
|
+
default="auto",
|
|
39
|
+
metadata={
|
|
40
|
+
"description": {"i18n": "ShadowOps.theme", "default": "卡片配色 (auto=按时间自动切深浅色)"},
|
|
41
|
+
"ui": {
|
|
42
|
+
"widget": "select",
|
|
43
|
+
"options": ["auto", "light", "dark"],
|
|
44
|
+
"group": "render",
|
|
45
|
+
"order": 5,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from ErisPulse import i18n, sdk
|
|
2
|
+
from ErisPulse.Core.Bases import BaseModule, ModuleMeta
|
|
3
|
+
from ErisPulse.Core.Event import command
|
|
4
|
+
from ErisPulse.loaders import ModuleLoadStrategy
|
|
5
|
+
|
|
6
|
+
from .Config import ShadowOpsConfig
|
|
7
|
+
from .I18n import ShadowOpsI18n
|
|
8
|
+
from .Service import ShadowOpsService
|
|
9
|
+
from .Visualizer import CardVisualizer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Main(BaseModule):
|
|
13
|
+
|
|
14
|
+
ConfigClass = ShadowOpsConfig
|
|
15
|
+
I18nClass = ShadowOpsI18n
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.sdk = sdk
|
|
19
|
+
self.logger = sdk.logger.get_child("ShadowOps")
|
|
20
|
+
self._service = ShadowOpsService(sdk, self.logger)
|
|
21
|
+
self._viz = CardVisualizer(sdk, self.logger)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def get_load_strategy() -> ModuleLoadStrategy:
|
|
25
|
+
return ModuleLoadStrategy(lazy_load=False, priority=20)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def get_meta() -> ModuleMeta:
|
|
29
|
+
return ModuleMeta(
|
|
30
|
+
name="ShadowOps",
|
|
31
|
+
description="影子灰度管理模块:启动/对比/转正/放弃影子模块,附归属权审计",
|
|
32
|
+
version="0.1.0",
|
|
33
|
+
author="ErisPulse-devs",
|
|
34
|
+
group="运维",
|
|
35
|
+
tags=["影子模块", "灰度", "热更新", "审计"],
|
|
36
|
+
min_sdk_version="2.9.0",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def on_load(self, event: dict) -> bool:
|
|
40
|
+
if not self.cfg.enabled:
|
|
41
|
+
self.logger.info("影子灰度管理未启用(enabled=false)")
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
self._register_commands()
|
|
45
|
+
self.logger.info(i18n.t("ShadowOps.module_loaded", event=event))
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
async def on_unload(self, event: dict) -> bool:
|
|
49
|
+
self.logger.info(i18n.t("ShadowOps.module_unloaded", event=event))
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
async def _reply_card(self, event, html, fallback_text: str):
|
|
53
|
+
if self.cfg.render_card:
|
|
54
|
+
png = await self._viz.render(html, theme=self.cfg.theme)
|
|
55
|
+
if png is not None:
|
|
56
|
+
try:
|
|
57
|
+
await event.reply(png, method="Image")
|
|
58
|
+
return
|
|
59
|
+
except Exception as e:
|
|
60
|
+
self.logger.warning(f"图片回复失败,降级文本: {e}")
|
|
61
|
+
await event.reply(fallback_text)
|
|
62
|
+
|
|
63
|
+
def _shadow_error_text(self, error: Exception, name: str) -> str:
|
|
64
|
+
from ErisPulse.Core.Bases.errors import ShadowError
|
|
65
|
+
|
|
66
|
+
if isinstance(error, ShadowError):
|
|
67
|
+
return f"{type(error).__name__}: {error}"
|
|
68
|
+
return f"{type(error).__name__}: {error}"
|
|
69
|
+
|
|
70
|
+
def _register_commands(self):
|
|
71
|
+
@command("shadow", master=True, help="影子灰度管理")
|
|
72
|
+
async def shadow_handler(event):
|
|
73
|
+
await event.reply(
|
|
74
|
+
"用法: /shadow start <模块名> <v2路径> | /shadow list | /shadow diff <模块名> | "
|
|
75
|
+
"/shadow promote <模块名> confirm | /shadow dismiss <模块名> | /shadow audit <模块名>"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@command("shadow start", master=True, help="启动影子(新版代码以独立 owner 并存试运行)",
|
|
79
|
+
args="<name:str> <source:rest>")
|
|
80
|
+
async def shadow_start_handler(event, name: str, source: str):
|
|
81
|
+
source = source.strip()
|
|
82
|
+
if not source:
|
|
83
|
+
await event.reply("缺少 v2 路径")
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
owner = await self._service.start(name, source)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
await event.reply(self._shadow_error_text(e, name))
|
|
89
|
+
return
|
|
90
|
+
await event.reply(i18n.t("ShadowOps.started", owner=owner, name=name))
|
|
91
|
+
|
|
92
|
+
@command("shadow list", master=True, help="列出运行中的影子")
|
|
93
|
+
async def shadow_list_handler(event):
|
|
94
|
+
await self._reply_card(
|
|
95
|
+
event,
|
|
96
|
+
self._service.list_card_html(self._viz),
|
|
97
|
+
self._service.format_list_text(),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@command("shadow diff", master=True, help="对比 v1 实际发送 × v2 意向发送", args="<name:str>")
|
|
101
|
+
async def shadow_diff_handler(event, name: str):
|
|
102
|
+
report = self._service.diff(name)
|
|
103
|
+
await self._reply_card(
|
|
104
|
+
event,
|
|
105
|
+
self._service.diff_card_html(name, report, self.cfg.diff_max_rows, self._viz),
|
|
106
|
+
self._service.format_diff_text(name, report, self.cfg.diff_max_rows),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@command("shadow promote", master=True, help="转正影子(需 yes 确认)",
|
|
110
|
+
args="<name:str> [confirm:literal=no|yes]")
|
|
111
|
+
async def shadow_promote_handler(event, name: str, confirm: str = "no"):
|
|
112
|
+
if confirm != "yes":
|
|
113
|
+
await event.reply(i18n.t("ShadowOps.promote_need_confirm", name=name))
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
ok = await self._service.promote(name)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
await event.reply(self._shadow_error_text(e, name))
|
|
119
|
+
return
|
|
120
|
+
if ok:
|
|
121
|
+
await event.reply(i18n.t("ShadowOps.promoted", name=name))
|
|
122
|
+
else:
|
|
123
|
+
await event.reply(f"promote returned False: {name}")
|
|
124
|
+
|
|
125
|
+
@command("shadow dismiss", master=True, help="放弃影子", args="<name:str>")
|
|
126
|
+
async def shadow_dismiss_handler(event, name: str):
|
|
127
|
+
try:
|
|
128
|
+
ok = await self._service.dismiss(name)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
await event.reply(self._shadow_error_text(e, name))
|
|
131
|
+
return
|
|
132
|
+
if ok:
|
|
133
|
+
await event.reply(i18n.t("ShadowOps.dismissed", name=name))
|
|
134
|
+
else:
|
|
135
|
+
await event.reply(f"dismiss returned False: {name}")
|
|
136
|
+
|
|
137
|
+
@command("shadow audit", master=True, help="归属权审计", args="<name:str>")
|
|
138
|
+
async def shadow_audit_handler(event, name: str):
|
|
139
|
+
try:
|
|
140
|
+
report = self._service.audit(name, deep=self.cfg.audit_deep)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
await event.reply(self._shadow_error_text(e, name))
|
|
143
|
+
return
|
|
144
|
+
await self._reply_card(
|
|
145
|
+
event,
|
|
146
|
+
self._service.audit_card_html(name, report, self._viz),
|
|
147
|
+
self._service.format_audit_text(name, report),
|
|
148
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from ErisPulse.Core.Bases import BaseI18n, I18nKey
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ShadowOpsI18n(BaseI18n):
|
|
5
|
+
|
|
6
|
+
enabled: I18nKey = I18nKey(
|
|
7
|
+
default="Enable module",
|
|
8
|
+
zh_CN="是否启用模块",
|
|
9
|
+
zh_TW="是否啟用模組",
|
|
10
|
+
en="Enable module",
|
|
11
|
+
ja="モジュールを有効化",
|
|
12
|
+
ru="Включить модуль",
|
|
13
|
+
)
|
|
14
|
+
diff_max_rows: I18nKey = I18nKey(
|
|
15
|
+
default="Max entries shown in one diff report",
|
|
16
|
+
zh_CN="对比报告单次展示的最大条目数",
|
|
17
|
+
zh_TW="對比報告單次展示的最大條目數",
|
|
18
|
+
en="Max entries shown in one diff report",
|
|
19
|
+
ja="差分レポート1回の最大表示件数",
|
|
20
|
+
ru="Максимум записей в одном отчёте сравнения",
|
|
21
|
+
)
|
|
22
|
+
audit_deep: I18nKey = I18nKey(
|
|
23
|
+
default="Include gc instance census in audit (has global pause cost)",
|
|
24
|
+
zh_CN="审计时附带 gc 实例普查(有全局暂停开销)",
|
|
25
|
+
zh_TW="審計時附帶 gc 實例普查(有全域暫停開銷)",
|
|
26
|
+
en="Include gc instance census in audit (has global pause cost)",
|
|
27
|
+
ja="監査に gc インスタンス調査を含める(グローバル一時停止あり)",
|
|
28
|
+
ru="Включать обзор gc-экземпляров в аудит (глобальные паузы)",
|
|
29
|
+
)
|
|
30
|
+
render_card: I18nKey = I18nKey(
|
|
31
|
+
default="Render reports as image cards via Takumi (fallback to text)",
|
|
32
|
+
zh_CN="报告用 Takumi 渲染为卡片(不可用时降级文本)",
|
|
33
|
+
zh_TW="報告用 Takumi 渲染為卡片(不可用時降級文本)",
|
|
34
|
+
en="Render reports as image cards via Takumi (fallback to text)",
|
|
35
|
+
ja="レポートを Takumi でカード画像化(不可時はテキスト)",
|
|
36
|
+
ru="Рендер отчётов в карточки через Takumi (иначе текст)",
|
|
37
|
+
)
|
|
38
|
+
theme: I18nKey = I18nKey(
|
|
39
|
+
default="Card color theme (auto = light/dark by time)",
|
|
40
|
+
zh_CN="卡片配色 (auto=按时间自动切深浅色)",
|
|
41
|
+
zh_TW="卡片配色 (auto=按時間自動切深淺色)",
|
|
42
|
+
en="Card color theme (auto = light/dark by time)",
|
|
43
|
+
ja="カード配色(auto=時間で自動切替)",
|
|
44
|
+
ru="Тема карточек (auto = светлая/тёмная по времени)",
|
|
45
|
+
)
|
|
46
|
+
module_loaded: I18nKey = I18nKey(
|
|
47
|
+
default="ShadowOps loaded: {event}",
|
|
48
|
+
zh_CN="影子灰度管理已加载: {event}",
|
|
49
|
+
zh_TW="影子灰度管理已載入: {event}",
|
|
50
|
+
en="ShadowOps loaded: {event}",
|
|
51
|
+
ja="ShadowOps を読み込みました: {event}",
|
|
52
|
+
ru="ShadowOps загружен: {event}",
|
|
53
|
+
)
|
|
54
|
+
module_unloaded: I18nKey = I18nKey(
|
|
55
|
+
default="ShadowOps unloaded: {event}",
|
|
56
|
+
zh_CN="影子灰度管理已卸载: {event}",
|
|
57
|
+
zh_TW="影子灰度管理已卸載: {event}",
|
|
58
|
+
en="ShadowOps unloaded: {event}",
|
|
59
|
+
ja="ShadowOps を解除しました: {event}",
|
|
60
|
+
ru="ShadowOps выгружен: {event}",
|
|
61
|
+
)
|
|
62
|
+
started: I18nKey = I18nKey(
|
|
63
|
+
default="Shadow started: {owner} (shadowing {name})",
|
|
64
|
+
zh_CN="影子已启动: {owner}(对照模块 {name})",
|
|
65
|
+
zh_TW="影子已啟動: {owner}(對照模組 {name})",
|
|
66
|
+
en="Shadow started: {owner} (shadowing {name})",
|
|
67
|
+
ja="シャドーを開始しました: {owner}(対象 {name})",
|
|
68
|
+
ru="Тень запущена: {owner} (для {name})",
|
|
69
|
+
)
|
|
70
|
+
promoted: I18nKey = I18nKey(
|
|
71
|
+
default="Shadow promoted: {name}",
|
|
72
|
+
zh_CN="影子已转正: {name}",
|
|
73
|
+
zh_TW="影子已轉正: {name}",
|
|
74
|
+
en="Shadow promoted: {name}",
|
|
75
|
+
ja="シャドーを本番化しました: {name}",
|
|
76
|
+
ru="Тень повышена: {name}",
|
|
77
|
+
)
|
|
78
|
+
dismissed: I18nKey = I18nKey(
|
|
79
|
+
default="Shadow dismissed: {name}",
|
|
80
|
+
zh_CN="影子已放弃: {name}",
|
|
81
|
+
zh_TW="影子已放棄: {name}",
|
|
82
|
+
en="Shadow dismissed: {name}",
|
|
83
|
+
ja="シャドーを破棄しました: {name}",
|
|
84
|
+
ru="Тень отклонена: {name}",
|
|
85
|
+
)
|
|
86
|
+
list_empty: I18nKey = I18nKey(
|
|
87
|
+
default="No active shadows",
|
|
88
|
+
zh_CN="当前没有运行中的影子",
|
|
89
|
+
zh_TW="當前沒有運行中的影子",
|
|
90
|
+
en="No active shadows",
|
|
91
|
+
ja="実行中のシャドーはありません",
|
|
92
|
+
ru="Нет активных теней",
|
|
93
|
+
)
|
|
94
|
+
list_header: I18nKey = I18nKey(
|
|
95
|
+
default="Active shadows ({count}):",
|
|
96
|
+
zh_CN="运行中的影子({count} 个):",
|
|
97
|
+
zh_TW="運行中的影子({count} 個):",
|
|
98
|
+
en="Active shadows ({count}):",
|
|
99
|
+
ja="実行中のシャドー({count} 件):",
|
|
100
|
+
ru="Активные тени ({count}):",
|
|
101
|
+
)
|
|
102
|
+
diff_no_shadow: I18nKey = I18nKey(
|
|
103
|
+
default="Module {name} has no active shadow",
|
|
104
|
+
zh_CN="模块 {name} 没有运行中的影子",
|
|
105
|
+
zh_TW="模組 {name} 沒有運行中的影子",
|
|
106
|
+
en="Module {name} has no active shadow",
|
|
107
|
+
ja="モジュール {name} に実行中のシャドーはありません",
|
|
108
|
+
ru="У модуля {name} нет активной тени",
|
|
109
|
+
)
|
|
110
|
+
diff_empty: I18nKey = I18nKey(
|
|
111
|
+
default="Shadow has no outbound entries yet",
|
|
112
|
+
zh_CN="影子还没有意向出站记录",
|
|
113
|
+
zh_TW="影子還沒有意向出站記錄",
|
|
114
|
+
en="Shadow has no outbound entries yet",
|
|
115
|
+
ja="シャドーの出站記録はまだありません",
|
|
116
|
+
ru="У тени пока нет записей исходящих",
|
|
117
|
+
)
|
|
118
|
+
promote_need_confirm: I18nKey = I18nKey(
|
|
119
|
+
default="Promote will unload {name} and switch to the shadow version; retry with: /shadow promote {name} yes",
|
|
120
|
+
zh_CN="转正会卸载 {name} 并切换到影子版本;确认请输入: /shadow promote {name} yes",
|
|
121
|
+
zh_TW="轉正會卸載 {name} 並切換到影子版本;確認請輸入: /shadow promote {name} yes",
|
|
122
|
+
en="Promote will unload {name} and switch to the shadow version; retry with: /shadow promote {name} yes",
|
|
123
|
+
ja="本番化は {name} をアンロードしてシャドー版に切替えます。確認するには: /shadow promote {name} yes",
|
|
124
|
+
ru="Повышение выгрузит {name} и переключит на версию-тень; подтвердите: /shadow promote {name} yes",
|
|
125
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ErisPulse import i18n
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _shadow_manager():
|
|
7
|
+
from ErisPulse.Core.shadow import shadow_manager
|
|
8
|
+
|
|
9
|
+
return shadow_manager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ShadowOpsService:
|
|
13
|
+
|
|
14
|
+
def __init__(self, sdk, logger):
|
|
15
|
+
self.sdk = sdk
|
|
16
|
+
self.logger = logger
|
|
17
|
+
|
|
18
|
+
def list_shadows(self) -> list[dict[str, str]]:
|
|
19
|
+
items: list[dict[str, str]] = []
|
|
20
|
+
try:
|
|
21
|
+
shadows = getattr(_shadow_manager(), "_shadows", {}) or {}
|
|
22
|
+
except Exception:
|
|
23
|
+
shadows = {}
|
|
24
|
+
for real_name, info in sorted(shadows.items()):
|
|
25
|
+
items.append({
|
|
26
|
+
"real_name": real_name,
|
|
27
|
+
"shadow_owner": info.get("shadow_owner", ""),
|
|
28
|
+
})
|
|
29
|
+
return items
|
|
30
|
+
|
|
31
|
+
def format_list_text(self) -> str:
|
|
32
|
+
items = self.list_shadows()
|
|
33
|
+
if not items:
|
|
34
|
+
return i18n.t("ShadowOps.list_empty")
|
|
35
|
+
lines = [i18n.t("ShadowOps.list_header", count=len(items))]
|
|
36
|
+
for item in items:
|
|
37
|
+
lines.append(f"{item['real_name']} -> {item['shadow_owner']}")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
def list_card_html(self, viz) -> str:
|
|
41
|
+
import datetime
|
|
42
|
+
|
|
43
|
+
items = self.list_shadows()
|
|
44
|
+
if items:
|
|
45
|
+
rows = "".join(
|
|
46
|
+
viz.count_row(item["real_name"], "", f"shadow owner: {item['shadow_owner']}")
|
|
47
|
+
for item in items
|
|
48
|
+
)
|
|
49
|
+
body = rows
|
|
50
|
+
subtitle = f"共 {len(items)} 个影子在运行"
|
|
51
|
+
else:
|
|
52
|
+
body = "<div class='empty'>当前没有运行中的影子</div>"
|
|
53
|
+
subtitle = "共 0 个影子"
|
|
54
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
55
|
+
return (
|
|
56
|
+
viz.head("影子模块", subtitle)
|
|
57
|
+
+ f"<div class='body'>{body}</div>"
|
|
58
|
+
+ viz.foot(f"ShadowOps · {stamp}", "shadow_start / diff / promote / dismiss")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def diff(self, name: str) -> dict[str, Any]:
|
|
62
|
+
return self.sdk.module.shadow_diff(name)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _entry_line(entry: dict) -> str:
|
|
66
|
+
kind = entry.get("kind", "?")
|
|
67
|
+
platform = entry.get("platform", "")
|
|
68
|
+
method = entry.get("method", "")
|
|
69
|
+
target = entry.get("target", "")
|
|
70
|
+
preview = str(entry.get("preview", "") or "")
|
|
71
|
+
return f"[{kind}] {platform}.{method} -> {target} {preview}".rstrip()
|
|
72
|
+
|
|
73
|
+
def format_diff_text(self, name: str, report: dict, max_rows: int) -> str:
|
|
74
|
+
if not report.get("shadow_owner"):
|
|
75
|
+
return i18n.t("ShadowOps.diff_no_shadow", name=name)
|
|
76
|
+
aligned = report.get("aligned") or []
|
|
77
|
+
if not aligned:
|
|
78
|
+
return i18n.t("ShadowOps.diff_empty")
|
|
79
|
+
lines = [f"影子 {report['shadow_owner']} · 在账 {report.get('count', len(aligned))} 条"]
|
|
80
|
+
for item in aligned[:max_rows]:
|
|
81
|
+
shadow = item.get("shadow") or {}
|
|
82
|
+
actual = item.get("actual") or []
|
|
83
|
+
lines.append(self._entry_line(shadow))
|
|
84
|
+
lines.append(f" 实际发送: {len(actual)} 条 (trace={shadow.get('trace_id', '-')[:8]})")
|
|
85
|
+
hidden = len(aligned) - max_rows
|
|
86
|
+
if hidden > 0:
|
|
87
|
+
lines.append(f"…其余 {hidden} 条已省略")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
def diff_card_html(self, name: str, report: dict, max_rows: int, viz) -> str:
|
|
91
|
+
import datetime
|
|
92
|
+
|
|
93
|
+
if not report.get("shadow_owner"):
|
|
94
|
+
return (
|
|
95
|
+
viz.head("行为对比", f"模块 {name}")
|
|
96
|
+
+ "<div class='body'><div class='empty'>没有运行中的影子</div></div>"
|
|
97
|
+
+ viz.foot("ShadowOps", "shadow_start")
|
|
98
|
+
)
|
|
99
|
+
aligned = report.get("aligned") or []
|
|
100
|
+
if not aligned:
|
|
101
|
+
body = "<div class='empty'>影子还没有意向出站记录</div>"
|
|
102
|
+
else:
|
|
103
|
+
matched = sum(1 for item in aligned if item.get("actual"))
|
|
104
|
+
rows = []
|
|
105
|
+
for item in aligned[:max(1, max_rows)]:
|
|
106
|
+
shadow = item.get("shadow") or {}
|
|
107
|
+
actual = item.get("actual") or []
|
|
108
|
+
line = self._entry_line(shadow)
|
|
109
|
+
chips = viz.chips([
|
|
110
|
+
f"trace {str(shadow.get('trace_id', ''))[:12]}",
|
|
111
|
+
f"实际发送 {len(actual)} 条",
|
|
112
|
+
])
|
|
113
|
+
rows.append(viz.count_row(
|
|
114
|
+
shadow.get("kind", "?"), "✓" if actual else "✗", line, chips,
|
|
115
|
+
))
|
|
116
|
+
body = "".join(rows)
|
|
117
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
118
|
+
return (
|
|
119
|
+
viz.head("行为对比", f"模块 {name} · 影子 {report['shadow_owner']} · 在账 {report.get('count', 0)} 条")
|
|
120
|
+
+ f"<div class='body'>{body}</div>"
|
|
121
|
+
+ viz.foot(f"ShadowOps · {stamp}", f"展示上限 {max(1, max_rows)}")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def start(self, name: str, source: str) -> str:
|
|
125
|
+
return await self.sdk.module.shadow_start(name, source=source)
|
|
126
|
+
|
|
127
|
+
async def promote(self, name: str) -> bool:
|
|
128
|
+
return await self.sdk.module.promote_shadow(name)
|
|
129
|
+
|
|
130
|
+
async def dismiss(self, name: str) -> bool:
|
|
131
|
+
return await self.sdk.module.dismiss_shadow(name)
|
|
132
|
+
|
|
133
|
+
def audit(self, name: str, deep: bool = False) -> dict[str, Any]:
|
|
134
|
+
return self.sdk.module.audit(name, deep=deep)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def format_audit_text(name: str, report: dict) -> str:
|
|
138
|
+
lines = [f"归属审计: {name}"]
|
|
139
|
+
counts = report.get("counts") or {}
|
|
140
|
+
rendered = " ".join(f"{k}={v}" for k, v in sorted(counts.items()))
|
|
141
|
+
lines.append(f"counts: {rendered or '-'}")
|
|
142
|
+
orphans = report.get("orphans")
|
|
143
|
+
if orphans:
|
|
144
|
+
lines.append(f"orphans: {orphans}")
|
|
145
|
+
recyclable = report.get("instance_recyclable")
|
|
146
|
+
if recyclable is not None:
|
|
147
|
+
lines.append(f"instance_recyclable: {recyclable}")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
def audit_card_html(self, name: str, report: dict, viz) -> str:
|
|
151
|
+
import datetime
|
|
152
|
+
|
|
153
|
+
counts = sorted((report.get("counts") or {}).items())
|
|
154
|
+
body = viz.kv_section("归属资源计数", counts or [("-", "0")])
|
|
155
|
+
orphans = report.get("orphans")
|
|
156
|
+
if orphans:
|
|
157
|
+
body += viz.kv_section("孤儿 owner", [(str(o), "") for o in orphans])
|
|
158
|
+
recyclable = report.get("instance_recyclable")
|
|
159
|
+
if recyclable is not None:
|
|
160
|
+
body += viz.kv_section("实例可回收", [("instance_recyclable", str(recyclable))])
|
|
161
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
162
|
+
return (
|
|
163
|
+
viz.head("归属权审计", f"模块 {name}")
|
|
164
|
+
+ f"<div class='body'>{body}</div>"
|
|
165
|
+
+ viz.foot(f"ShadowOps · {stamp}", "module.audit")
|
|
166
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
PALETTES = {
|
|
5
|
+
"light": {
|
|
6
|
+
"page": "#f4f6f9", "card": "#ffffff", "ink": "#2b3440", "sub": "#7d8a99",
|
|
7
|
+
"accent": "#8a6db5", "accent2": "#5aa27b", "warn": "#c0804a",
|
|
8
|
+
"accent_soft": "#ece5f6", "border": "#dfe6ee", "chipbg": "#f0f4f9", "codebg": "#eef2f7",
|
|
9
|
+
},
|
|
10
|
+
"dark": {
|
|
11
|
+
"page": "#171b21", "card": "#20262e", "ink": "#e6ebf1", "sub": "#93a1b0",
|
|
12
|
+
"accent": "#ab8fd6", "accent2": "#7fc29a", "warn": "#d3a569",
|
|
13
|
+
"accent_soft": "rgba(171,143,214,0.14)", "border": "#2c343e",
|
|
14
|
+
"chipbg": "rgba(171,143,214,0.08)", "codebg": "#262d36",
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_FONTS = '"Noto Sans SC", "Roboto", "PingFang SC", "Microsoft YaHei", sans-serif'
|
|
19
|
+
_FONTS_MONO = '"JetBrains Mono", "Cascadia Code", Consolas, ui-monospace, monospace'
|
|
20
|
+
|
|
21
|
+
_CSS_TPL = """
|
|
22
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
23
|
+
body { font-family: __FONTS__; color: __INK__; -webkit-font-smoothing: antialiased; }
|
|
24
|
+
.page { background: __PAGE__; padding: 24px; }
|
|
25
|
+
.card { background: __CARD__; border: 1px solid __BORDER__; border-radius: 16px; overflow: hidden; }
|
|
26
|
+
.head { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px; border-bottom: 1px solid __BORDER__; }
|
|
27
|
+
.title { font-size: 19px; font-weight: 700; letter-spacing: 0.5px; }
|
|
28
|
+
.subtitle { font-size: 12.5px; color: __SUB__; margin-top: 3px; }
|
|
29
|
+
.body { padding: 18px 24px 22px; }
|
|
30
|
+
.nums { display: flex; gap: 12px; }
|
|
31
|
+
.num { flex: 1; background: __CODEBG__; border-radius: 12px; padding: 14px 16px; text-align: center; }
|
|
32
|
+
.num .label { font-size: 12px; color: __SUB__; }
|
|
33
|
+
.num .value { font-size: 26px; font-weight: 700; color: __ACCENT__; margin-top: 4px; }
|
|
34
|
+
.num.ok .value { color: __ACCENT2__; }
|
|
35
|
+
.num.warn .value { color: __WARN__; }
|
|
36
|
+
.section { margin-top: 18px; }
|
|
37
|
+
.section-title { font-size: 12.5px; color: __SUB__; font-weight: 600; letter-spacing: 1px; margin-bottom: 8px; text-transform: uppercase; }
|
|
38
|
+
.kv-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
39
|
+
.kv { background: __CHIPBG__; border: 1px solid __BORDER__; border-radius: 8px; padding: 6px 12px; font-size: 13px; }
|
|
40
|
+
.kv b { color: __ACCENT__; font-weight: 600; }
|
|
41
|
+
.row { padding: 10px 12px; border-radius: 10px; margin-bottom: 6px; background: __CHIPBG__; border: 1px solid __BORDER__; }
|
|
42
|
+
.row:last-child { margin-bottom: 0; }
|
|
43
|
+
.row-main { display: flex; align-items: center; gap: 10px; }
|
|
44
|
+
.row-name { font-family: __FONTMONO__; font-size: 13px; background: __CODEBG__; border-radius: 6px; padding: 3px 8px; }
|
|
45
|
+
.row-dim { font-size: 12px; color: __SUB__; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
46
|
+
.row-count { font-size: 15px; font-weight: 700; color: __ACCENT__; font-family: __FONTMONO__; }
|
|
47
|
+
.row-count.ok { color: __ACCENT2__; }
|
|
48
|
+
.row-count.miss { color: __WARN__; }
|
|
49
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
|
50
|
+
.chip { font-size: 11.5px; background: __ACCENT_SOFT__; color: __ACCENT__; border-radius: 9999px; padding: 2px 10px; }
|
|
51
|
+
.empty { text-align: center; color: __SUB__; font-size: 13px; padding: 18px 0; }
|
|
52
|
+
.foot { padding: 12px 24px; border-top: 1px solid __BORDER__; font-size: 11.5px; color: __SUB__; display: flex; justify-content: space-between; }
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _esc(text) -> str:
|
|
57
|
+
import html
|
|
58
|
+
|
|
59
|
+
return html.escape(str(text))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CardVisualizer:
|
|
63
|
+
|
|
64
|
+
CARD_WIDTH = 780
|
|
65
|
+
|
|
66
|
+
def __init__(self, sdk, logger):
|
|
67
|
+
self.sdk = sdk
|
|
68
|
+
self.logger = logger
|
|
69
|
+
self._takumi_inst = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def takumi(self):
|
|
73
|
+
if self._takumi_inst is None:
|
|
74
|
+
inst = None
|
|
75
|
+
try:
|
|
76
|
+
inst = self.sdk.module.get("Takumi")
|
|
77
|
+
except Exception:
|
|
78
|
+
inst = None
|
|
79
|
+
if inst is None:
|
|
80
|
+
inst = getattr(self.sdk, "Takumi", None)
|
|
81
|
+
self._takumi_inst = inst
|
|
82
|
+
return self._takumi_inst
|
|
83
|
+
|
|
84
|
+
def resolve_palette(self, theme: str) -> dict:
|
|
85
|
+
import datetime
|
|
86
|
+
|
|
87
|
+
if theme in PALETTES:
|
|
88
|
+
mode = theme
|
|
89
|
+
else:
|
|
90
|
+
hour = datetime.datetime.now().hour
|
|
91
|
+
mode = "dark" if (hour >= 19 or hour < 7) else "light"
|
|
92
|
+
return dict(PALETTES[mode])
|
|
93
|
+
|
|
94
|
+
def _css(self, palette: dict) -> str:
|
|
95
|
+
css = _CSS_TPL.replace("__FONTS__", _FONTS).replace("__FONTMONO__", _FONTS_MONO)
|
|
96
|
+
for key, value in palette.items():
|
|
97
|
+
css = css.replace(f"__{key.upper()}__", value)
|
|
98
|
+
return css
|
|
99
|
+
|
|
100
|
+
async def render(self, body_html: str, theme: str = "auto") -> Optional[bytes]:
|
|
101
|
+
takumi = self.takumi
|
|
102
|
+
if takumi is None or not hasattr(takumi, "render_html"):
|
|
103
|
+
self.logger.debug("Takumi 不可用,跳过渲染")
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
result = takumi.render_html(
|
|
107
|
+
f"<div class='page'>{body_html}</div>",
|
|
108
|
+
stylesheets=[self._css(self.resolve_palette(theme))],
|
|
109
|
+
width=self.CARD_WIDTH,
|
|
110
|
+
height=None,
|
|
111
|
+
lang="zh-CN",
|
|
112
|
+
)
|
|
113
|
+
if asyncio.iscoroutine(result):
|
|
114
|
+
result = await result
|
|
115
|
+
return result if isinstance(result, (bytes, bytearray)) else None
|
|
116
|
+
except Exception as e:
|
|
117
|
+
self.logger.warning(f"渲染卡片失败: {e}")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def head(self, title: str, subtitle: str) -> str:
|
|
121
|
+
return (
|
|
122
|
+
f"<div class='head'><div><div class='title'>{_esc(title)}</div>"
|
|
123
|
+
f"<div class='subtitle'>{_esc(subtitle)}</div></div></div>"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def foot(self, left: str, right: str) -> str:
|
|
127
|
+
return f"<div class='foot'><span>{_esc(left)}</span><span>{_esc(right)}</span></div>"
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def nums(items) -> str:
|
|
131
|
+
cells = "".join(
|
|
132
|
+
f"<div class='num {cls}'><div class='label'>{_esc(label)}</div>"
|
|
133
|
+
f"<div class='value'>{_esc(value)}</div></div>"
|
|
134
|
+
for label, value, cls in items
|
|
135
|
+
)
|
|
136
|
+
return f"<div class='nums'>{cells}</div>"
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def kv_section(title: str, pairs) -> str:
|
|
140
|
+
chips = "".join(f"<div class='kv'>{_esc(k)} <b>{_esc(v)}</b></div>" for k, v in pairs)
|
|
141
|
+
return (
|
|
142
|
+
f"<div class='section'><div class='section-title'>{_esc(title)}</div>"
|
|
143
|
+
f"<div class='kv-grid'>{chips}</div></div>"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def count_row(name: str, right: str, dim: str = "", chips: str = "", right_cls: str = "") -> str:
|
|
148
|
+
return (
|
|
149
|
+
f"<div class='row'><div class='row-main'>"
|
|
150
|
+
f"<span class='row-name'>{_esc(name)}</span>"
|
|
151
|
+
f"<span class='row-dim'>{_esc(dim)}</span>"
|
|
152
|
+
f"<span class='row-count {right_cls}'>{_esc(right)}</span></div>{chips}</div>"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def chips(items, cls: str = "") -> str:
|
|
157
|
+
if not items:
|
|
158
|
+
return ""
|
|
159
|
+
rendered = "".join(f"<span class='chip {cls}'>{_esc(t)}</span>" for t in items)
|
|
160
|
+
return f"<div class='chips'>{rendered}</div>"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ErisPulse-ShadowOps"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "影子灰度管理模块:启动/对比/转正/放弃影子模块,附归属权审计"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [ { name = "wsu2059q" } ]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
test = [
|
|
13
|
+
"pytest>=7.0",
|
|
14
|
+
"pytest-asyncio>=0.21",
|
|
15
|
+
"ErisPulse-Testing",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.entry-points."erispulse.module"]
|
|
19
|
+
"ShadowOps" = "ShadowOps:Main"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["setuptools>=61.0"]
|
|
23
|
+
build-backend = "setuptools.build_meta"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
packages = ["ShadowOps"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
from ErisPulse_Testing import create_command_event
|
|
4
|
+
|
|
5
|
+
from ErisPulse.Core.Bases import BaseModule
|
|
6
|
+
|
|
7
|
+
V2_SOURCE = '''
|
|
8
|
+
from ErisPulse import sdk
|
|
9
|
+
from ErisPulse.Core.Bases import BaseModule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Main(BaseModule):
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.sdk = sdk
|
|
15
|
+
self.logger = sdk.logger.get_child("ShadowDemoV2")
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def get_meta():
|
|
19
|
+
from ErisPulse.Core.Bases import ModuleMeta
|
|
20
|
+
|
|
21
|
+
return ModuleMeta(name="ShadowDemoV2", description="v2", version="2.0.0")
|
|
22
|
+
|
|
23
|
+
async def on_load(self, event):
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
async def on_unload(self, event):
|
|
27
|
+
return True
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ShadowDemoV1(BaseModule):
|
|
32
|
+
def __init__(self):
|
|
33
|
+
from ErisPulse import sdk
|
|
34
|
+
|
|
35
|
+
self.sdk = sdk
|
|
36
|
+
self.logger = sdk.logger.get_child("ShadowDemo")
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def get_meta():
|
|
40
|
+
from ErisPulse.Core.Bases import ModuleMeta
|
|
41
|
+
|
|
42
|
+
return ModuleMeta(name="ShadowDemo", description="v1", version="1.0.0")
|
|
43
|
+
|
|
44
|
+
async def on_load(self, event):
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
async def on_unload(self, event):
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _ensure_plugin_loader(bot):
|
|
52
|
+
from ErisPulse import module, sdk
|
|
53
|
+
from ErisPulse.loaders.plugin_folder import PluginFolderLoader
|
|
54
|
+
|
|
55
|
+
if getattr(module, "_sdk", None) is None:
|
|
56
|
+
module.set_sdk_ref(sdk)
|
|
57
|
+
|
|
58
|
+
loader = getattr(sdk, "_module_loader", None)
|
|
59
|
+
if loader is None:
|
|
60
|
+
sdk._module_loader = SimpleNamespace(_plugin_loader=PluginFolderLoader())
|
|
61
|
+
elif getattr(loader, "_plugin_loader", None) is None:
|
|
62
|
+
loader._plugin_loader = PluginFolderLoader()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _start_shadow(bot, tmp_path):
|
|
66
|
+
await bot.load_module(ShadowDemoV1)
|
|
67
|
+
await _ensure_plugin_loader(bot)
|
|
68
|
+
src = tmp_path / "shadowdemo_v2.py"
|
|
69
|
+
src.write_text(V2_SOURCE, encoding="utf-8")
|
|
70
|
+
return src
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_shadow_usage_hint(shadowbot):
|
|
74
|
+
await shadowbot.dispatch(create_command_event("shadow"))
|
|
75
|
+
shadowbot.assert_reply_contains("用法")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_shadow_list_empty(shadowbot):
|
|
79
|
+
await shadowbot.dispatch(create_command_event("shadow list"))
|
|
80
|
+
shadowbot.assert_reply_contains("没有运行中的影子")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_shadow_start_and_list(shadowbot, tmp_path):
|
|
84
|
+
src = await _start_shadow(shadowbot, tmp_path)
|
|
85
|
+
await shadowbot.dispatch(create_command_event(f"shadow start ShadowDemo {src}"))
|
|
86
|
+
reply = shadowbot.last_reply.text
|
|
87
|
+
assert "影子已启动" in reply
|
|
88
|
+
assert "shadowdemo_v2" in reply
|
|
89
|
+
|
|
90
|
+
await shadowbot.dispatch(create_command_event("shadow list"))
|
|
91
|
+
reply = shadowbot.last_reply.text
|
|
92
|
+
assert "运行中的影子(1 个)" in reply
|
|
93
|
+
assert "ShadowDemo -> shadowdemo_v2" in reply
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_shadow_start_not_loaded(shadowbot, tmp_path):
|
|
97
|
+
src = tmp_path / "whatever.py"
|
|
98
|
+
src.write_text(V2_SOURCE, encoding="utf-8")
|
|
99
|
+
await shadowbot.dispatch(create_command_event(f"shadow start NoSuch {src}"))
|
|
100
|
+
shadowbot.assert_reply_contains("ShadowStateError")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def test_shadow_start_bad_path(shadowbot, tmp_path):
|
|
104
|
+
await shadowbot.load_module(ShadowDemoV1)
|
|
105
|
+
await shadowbot.dispatch(create_command_event("shadow start ShadowDemo no/such/path"))
|
|
106
|
+
shadowbot.assert_reply_contains("ShadowSourceError")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_shadow_diff_without_shadow(shadowbot):
|
|
110
|
+
await shadowbot.dispatch(create_command_event("shadow diff ShadowDemo"))
|
|
111
|
+
shadowbot.assert_reply_contains("没有运行中的影子")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_shadow_diff_with_ledger_entries(shadowbot, tmp_path):
|
|
115
|
+
src = await _start_shadow(shadowbot, tmp_path)
|
|
116
|
+
await shadowbot.dispatch(create_command_event(f"shadow start ShadowDemo {src}"))
|
|
117
|
+
from ErisPulse.Core.shadow import shadow_ledger, shadow_manager
|
|
118
|
+
|
|
119
|
+
owner = shadow_manager.shadow_owner_of("ShadowDemo")
|
|
120
|
+
shadow_ledger.record(owner, {
|
|
121
|
+
"kind": "send", "platform": "test", "method": "Text",
|
|
122
|
+
"target": "user/u1", "trace_id": "trace-abc123", "preview": "hello v2",
|
|
123
|
+
})
|
|
124
|
+
await shadowbot.dispatch(create_command_event("shadow diff ShadowDemo"))
|
|
125
|
+
reply = shadowbot.last_reply.text
|
|
126
|
+
assert "在账 1 条" in reply
|
|
127
|
+
assert "hello v2" in reply
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_shadow_dismiss(shadowbot, tmp_path):
|
|
131
|
+
src = await _start_shadow(shadowbot, tmp_path)
|
|
132
|
+
await shadowbot.dispatch(create_command_event(f"shadow start ShadowDemo {src}"))
|
|
133
|
+
await shadowbot.dispatch(create_command_event("shadow dismiss ShadowDemo"))
|
|
134
|
+
shadowbot.assert_reply_contains("影子已放弃")
|
|
135
|
+
|
|
136
|
+
from ErisPulse.Core.shadow import shadow_manager
|
|
137
|
+
|
|
138
|
+
assert shadow_manager.shadow_owner_of("ShadowDemo") is None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_shadow_promote_needs_confirm(shadowbot, tmp_path):
|
|
142
|
+
src = await _start_shadow(shadowbot, tmp_path)
|
|
143
|
+
await shadowbot.dispatch(create_command_event(f"shadow start ShadowDemo {src}"))
|
|
144
|
+
await shadowbot.dispatch(create_command_event("shadow promote ShadowDemo"))
|
|
145
|
+
shadowbot.assert_reply_contains("确认请输入")
|
|
146
|
+
from ErisPulse.Core.shadow import shadow_manager
|
|
147
|
+
|
|
148
|
+
assert shadow_manager.shadow_owner_of("ShadowDemo") is not None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_shadow_audit(shadowbot):
|
|
152
|
+
await shadowbot.dispatch(create_command_event("shadow audit ShadowOps"))
|
|
153
|
+
reply = shadowbot.last_reply.text
|
|
154
|
+
assert "归属审计" in reply
|
|
155
|
+
assert "counts:" in reply
|