1panel-toolkit 0.9.0__tar.gz → 0.9.2__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.
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/CHANGELOG.md +68 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/PKG-INFO +29 -7
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/README.md +28 -6
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/pyproject.toml +1 -1
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/skill/SKILL.md +1 -1
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/__init__.py +1 -1
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/cli.py +3 -4
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/deploy.py +32 -1
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/remote.py +9 -37
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/lessons.yaml +10 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_config.py +43 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_deploy.py +74 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_remote.py +13 -82
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/.gitignore +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/LICENSE +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/docs/generation-depth.md +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/docs/patterns.md +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/examples/demo-db-spec.json +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/examples/demo-spec.json +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/__init__.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/compose.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/forms.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/hygiene.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/i18n.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/lessons.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/structure.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/substance.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/config.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/dependencies.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/depth.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/errors.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/evidence.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/fix.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/form.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/gen.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/lifecycle.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/loader.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/logo.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/model.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/net.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/panel.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/__init__.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/catalog.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/data/patterns.json +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/extract.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/recipes.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/report.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/resolve/__init__.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/resolve/analyze.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/resolve/sources.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/__init__.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/decisions.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/dependencies.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/envkeys.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/form-design.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/form-review.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/i18n.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/recipes.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/store.yaml +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/selftest.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/conftest.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_checks.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_corpus.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_dependencies.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_depth.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_evidence.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_fix.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_form.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_forms.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_gen.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_github_source.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_lifecycle.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_net.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_panel.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_patterns.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_recipes.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_regressions.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_remote_cli.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_resolve.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_rules_data.py +0 -0
- {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_selftest.py +0 -0
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.9.2 — 2026-09-20
|
|
4
|
+
|
|
5
|
+
面板侧做到"够用"就停:**失败时把根因说清楚**,并把"可选"这条边界写进文档。
|
|
6
|
+
|
|
7
|
+
### deploy 失败不再只有一句 "status=error"
|
|
8
|
+
|
|
9
|
+
`install_log` 这个字段从 0.1.0 起就在 `DeployResult` 里、渲染逻辑也有,但**从来没有被填过**
|
|
10
|
+
——安装失败时只打印一行状态,日志一个字都没有,等于把排查推回给人。
|
|
11
|
+
|
|
12
|
+
现在安装没到 `Running` 时会去读那个容器的 `docker logs --tail=120`,复用冒烟测试那套
|
|
13
|
+
lesson 匹配,报告里直接给:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
FAIL deploy/verify
|
|
17
|
+
安装状态为 error
|
|
18
|
+
根因提示: L008 宿主机端口已经被占用
|
|
19
|
+
→ 这不是包的缺陷——1Panel 安装时会在表单里提示端口占用并让你换一个…
|
|
20
|
+
--- 安装日志尾部 ---
|
|
21
|
+
| ...
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
缺密钥的情况也会单独标出来(`待填: …(不是包的缺陷,装的时候补上即可)`),
|
|
25
|
+
和冒烟的 `PENDING` 语义一致。`--json` 里多了 `lessons` / `needs_input`。
|
|
26
|
+
|
|
27
|
+
### 边界写进 README
|
|
28
|
+
|
|
29
|
+
- 开头声明:**核心路径不需要面板、不需要 SSH、不需要密钥**——`resolve → recipe → gen →
|
|
30
|
+
check → depth → fix` 全部离线跑;`deploy` / `uninstall` / `remote` / `panel` 是可选附加。
|
|
31
|
+
- 「它不做什么」补一条:不把在线步骤变成必需,并有测试盯着——把配置指向不存在的文件,
|
|
32
|
+
`gen` / `check` / `form audit` / `depth` 必须照常通过。
|
|
33
|
+
|
|
34
|
+
面板侧到此为止(够用即停,不再往下延伸);有需要再动。
|
|
35
|
+
`pytest` 303 passed,`p1 selftest` 10/10。
|
|
36
|
+
|
|
37
|
+
## 0.9.1 — 2026-09-20
|
|
38
|
+
|
|
39
|
+
**撤回 0.9.0 的端口自动化,并把边界写进 README。** 用户指出方向偏了。
|
|
40
|
+
|
|
41
|
+
### 为什么撤
|
|
42
|
+
|
|
43
|
+
0.9.0 让 `p1 remote smoke` 在不传 `--ports` 时"从实测放行的候选区间里挑一个空闲端口"。
|
|
44
|
+
两个问题:
|
|
45
|
+
|
|
46
|
+
1. **端口是包的决定,不是服务器的决定。** 表单默认值就是包的立场,冲突该由 1Panel
|
|
47
|
+
在安装表单里提示、由 Compose 报错。工具为了"跑通"另找一个端口来装,
|
|
48
|
+
测的已经不是这个包了——这正是这个工具一直在反对的"为了绿灯而改东西"。
|
|
49
|
+
2. **服务器是变化的,而且是多台。** 把"哪些端口放行、哪些端口空闲"写成常量,
|
|
50
|
+
等于把工具绑死在一台机器上;用户有好几台服务器,端口放行情况各不相同。
|
|
51
|
+
|
|
52
|
+
现在恢复原来的语义:不传 `--ports` = 用包自己的表单默认端口;`--ports` 是**显式覆盖**,
|
|
53
|
+
不是自动兜底。同时删掉了 `SMOKE_PORT_CANDIDATES` 这类服务器专属常量。
|
|
54
|
+
|
|
55
|
+
### 保留了有价值的那一半(与服务器无关)
|
|
56
|
+
|
|
57
|
+
端口被占用时会失败——这本身是对的,但"容器没起来"这种信息太模糊。新增 lesson
|
|
58
|
+
**L008「宿主机端口已经被占用」**:`bind: address already in use` /
|
|
59
|
+
`Ports are not available` / `port is already allocated` 会被识别出来,并直接说明
|
|
60
|
+
"这不是包的缺陷,1Panel 安装时会提示换端口,SSH 冒烟用 `--ports` 指定"。
|
|
61
|
+
这是解释失败、帮你矫正部署,而不是替你改包。
|
|
62
|
+
|
|
63
|
+
### 边界写进 README
|
|
64
|
+
|
|
65
|
+
新增「它不做什么」一节:不替你挑宿主端口;不为某一台服务器调优(不预置任何
|
|
66
|
+
服务器专属常量,只根据当场读到的主机事实做事);不假装知道上游没写的东西;
|
|
67
|
+
不做需要判断的事。方向漂移时先看这一节。
|
|
68
|
+
|
|
69
|
+
`pytest` 300 passed,`p1 selftest` 10/10。
|
|
70
|
+
|
|
3
71
|
## 0.9.0 — 2026-09-20
|
|
4
72
|
|
|
5
73
|
冒烟测试的最后一道手工步骤:**端口**。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: 1panel-toolkit
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Generate, check, fix and runtime-verify 1Panel app packages from a single spec.
|
|
5
5
|
Author: idkan
|
|
6
6
|
License: MIT License
|
|
@@ -44,6 +44,11 @@ Description-Content-Type: text/markdown
|
|
|
44
44
|
它不绑定任何 agent 宿主:CLI 可以独立使用,也可以由 skill 包装后交给 coding
|
|
45
45
|
agent 调用。
|
|
46
46
|
|
|
47
|
+
**核心路径不需要面板、不需要 SSH、不需要任何密钥**:`resolve` → `recipe` → `gen` →
|
|
48
|
+
`check` → `depth` → `fix` 全部离线跑,输入是仓库地址(或本地检出),输出是一个包。
|
|
49
|
+
`deploy` / `uninstall` / `remote` / `panel` 是**可选的附加能力**,用面板 API key
|
|
50
|
+
帮你检查部署情况、把失败解释清楚——不配也行,核心不依赖它们。
|
|
51
|
+
|
|
47
52
|
## 安装
|
|
48
53
|
|
|
49
54
|
```bash
|
|
@@ -286,8 +291,8 @@ p1 fix <包目录> --check # 改完立刻重新校验
|
|
|
286
291
|
|
|
287
292
|
```bash
|
|
288
293
|
p1 remote check # 主机事实(内存/磁盘/已用端口/已有本地应用)
|
|
289
|
-
p1 remote smoke <包目录> #
|
|
290
|
-
p1 remote smoke <包目录> --ports 10000 #
|
|
294
|
+
p1 remote smoke <包目录> # 完整一轮冒烟(用包自己的默认端口)
|
|
295
|
+
p1 remote smoke <包目录> --ports 10000 # 显式覆盖宿主端口
|
|
291
296
|
p1 remote smoke <包目录> --env OV_VLM_API_KEY=sk-… # 用真实密钥覆盖表单默认值
|
|
292
297
|
```
|
|
293
298
|
|
|
@@ -295,10 +300,9 @@ p1 remote smoke <包目录> --env OV_VLM_API_KEY=sk-… # 用真实密钥覆盖
|
|
|
295
300
|
不会碰到你已装的包),按安装表单的默认值渲染 `.env`,`docker compose up -d`,
|
|
296
301
|
然后轮询到"HTTP 有应答"或"healthcheck 明确 healthy"为止,抓日志、清理。
|
|
297
302
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
"容器没起来"的形式失败,看起来像包的问题。
|
|
303
|
+
不传 `--ports` 时用的是**包自己的表单默认端口**——冒烟要验的就是这个包,不是
|
|
304
|
+
"在这台机器上换个端口能不能跑"。端口被占用时失败信息会点名 `L008`:
|
|
305
|
+
那不是包的缺陷,1Panel 安装时会提示换一个端口,SSH 冒烟用 `--ports` 指定即可。
|
|
302
306
|
|
|
303
307
|
判定分三类,退出码不同,便于接 CI:
|
|
304
308
|
|
|
@@ -461,3 +465,21 @@ p1 deploy ./out/app --param ... --uninstall # 5. 走面板 API 正式安装并
|
|
|
461
465
|
没有官方 Docker 证据就不猜镜像、端口、卷、UID/GID。高风险运行时权限先保留并
|
|
462
466
|
标注风险,不为了扫描器好看而删。云服务器上做运行时验证时只用已确认放行的端口
|
|
463
467
|
段,避免把安全组问题误判成打包问题。
|
|
468
|
+
|
|
469
|
+
## 它不做什么
|
|
470
|
+
|
|
471
|
+
这条界线值得写下来,因为它划错时工具会开始骗人:
|
|
472
|
+
|
|
473
|
+
- **不把在线步骤变成必需。** 面板与 SSH 是可选的辅助;核心(进 GitHub 地址、出包)
|
|
474
|
+
完全离线可用,已经有一条测试盯着这件事(配置指向不存在的文件时,
|
|
475
|
+
`gen` / `check` / `form audit` / `depth` 必须照常通过)。
|
|
476
|
+
- **不替你挑宿主端口。** 端口是包自己的决定(表单默认值),冲突由 1Panel 在安装表单里
|
|
477
|
+
提示、由 Compose 报错。工具不会为了"跑通"而在服务器上另找一个空闲端口来装——
|
|
478
|
+
那样测的就不是这个包了。`remote smoke --ports` 是**显式覆盖**,不是自动兜底。
|
|
479
|
+
- **不为某一台服务器调优。** 端口区间、安全组放行情况、镜像缓存都随机器和时间变化;
|
|
480
|
+
工具只根据它当场读到的这台主机的事实做事(`p1 remote check`),不预置任何
|
|
481
|
+
服务器专属常量。
|
|
482
|
+
- **不假装知道上游没写的东西。** 镜像、容器端口、挂载点没有证据就拒绝生成;
|
|
483
|
+
应用自己的变量名要么来自证据、要么由你给。
|
|
484
|
+
- **不做需要判断的事。** 哪些字段用户真必须填、标签怎么翻译、要不要接面板依赖,
|
|
485
|
+
工具列清单、给依据,但不替你决定(`p1 form audit` + `--plan` 就是为这条设计的)。
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
它不绑定任何 agent 宿主:CLI 可以独立使用,也可以由 skill 包装后交给 coding
|
|
11
11
|
agent 调用。
|
|
12
12
|
|
|
13
|
+
**核心路径不需要面板、不需要 SSH、不需要任何密钥**:`resolve` → `recipe` → `gen` →
|
|
14
|
+
`check` → `depth` → `fix` 全部离线跑,输入是仓库地址(或本地检出),输出是一个包。
|
|
15
|
+
`deploy` / `uninstall` / `remote` / `panel` 是**可选的附加能力**,用面板 API key
|
|
16
|
+
帮你检查部署情况、把失败解释清楚——不配也行,核心不依赖它们。
|
|
17
|
+
|
|
13
18
|
## 安装
|
|
14
19
|
|
|
15
20
|
```bash
|
|
@@ -252,8 +257,8 @@ p1 fix <包目录> --check # 改完立刻重新校验
|
|
|
252
257
|
|
|
253
258
|
```bash
|
|
254
259
|
p1 remote check # 主机事实(内存/磁盘/已用端口/已有本地应用)
|
|
255
|
-
p1 remote smoke <包目录> #
|
|
256
|
-
p1 remote smoke <包目录> --ports 10000 #
|
|
260
|
+
p1 remote smoke <包目录> # 完整一轮冒烟(用包自己的默认端口)
|
|
261
|
+
p1 remote smoke <包目录> --ports 10000 # 显式覆盖宿主端口
|
|
257
262
|
p1 remote smoke <包目录> --env OV_VLM_API_KEY=sk-… # 用真实密钥覆盖表单默认值
|
|
258
263
|
```
|
|
259
264
|
|
|
@@ -261,10 +266,9 @@ p1 remote smoke <包目录> --env OV_VLM_API_KEY=sk-… # 用真实密钥覆盖
|
|
|
261
266
|
不会碰到你已装的包),按安装表单的默认值渲染 `.env`,`docker compose up -d`,
|
|
262
267
|
然后轮询到"HTTP 有应答"或"healthcheck 明确 healthy"为止,抓日志、清理。
|
|
263
268
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
"容器没起来"的形式失败,看起来像包的问题。
|
|
269
|
+
不传 `--ports` 时用的是**包自己的表单默认端口**——冒烟要验的就是这个包,不是
|
|
270
|
+
"在这台机器上换个端口能不能跑"。端口被占用时失败信息会点名 `L008`:
|
|
271
|
+
那不是包的缺陷,1Panel 安装时会提示换一个端口,SSH 冒烟用 `--ports` 指定即可。
|
|
268
272
|
|
|
269
273
|
判定分三类,退出码不同,便于接 CI:
|
|
270
274
|
|
|
@@ -427,3 +431,21 @@ p1 deploy ./out/app --param ... --uninstall # 5. 走面板 API 正式安装并
|
|
|
427
431
|
没有官方 Docker 证据就不猜镜像、端口、卷、UID/GID。高风险运行时权限先保留并
|
|
428
432
|
标注风险,不为了扫描器好看而删。云服务器上做运行时验证时只用已确认放行的端口
|
|
429
433
|
段,避免把安全组问题误判成打包问题。
|
|
434
|
+
|
|
435
|
+
## 它不做什么
|
|
436
|
+
|
|
437
|
+
这条界线值得写下来,因为它划错时工具会开始骗人:
|
|
438
|
+
|
|
439
|
+
- **不把在线步骤变成必需。** 面板与 SSH 是可选的辅助;核心(进 GitHub 地址、出包)
|
|
440
|
+
完全离线可用,已经有一条测试盯着这件事(配置指向不存在的文件时,
|
|
441
|
+
`gen` / `check` / `form audit` / `depth` 必须照常通过)。
|
|
442
|
+
- **不替你挑宿主端口。** 端口是包自己的决定(表单默认值),冲突由 1Panel 在安装表单里
|
|
443
|
+
提示、由 Compose 报错。工具不会为了"跑通"而在服务器上另找一个空闲端口来装——
|
|
444
|
+
那样测的就不是这个包了。`remote smoke --ports` 是**显式覆盖**,不是自动兜底。
|
|
445
|
+
- **不为某一台服务器调优。** 端口区间、安全组放行情况、镜像缓存都随机器和时间变化;
|
|
446
|
+
工具只根据它当场读到的这台主机的事实做事(`p1 remote check`),不预置任何
|
|
447
|
+
服务器专属常量。
|
|
448
|
+
- **不假装知道上游没写的东西。** 镜像、容器端口、挂载点没有证据就拒绝生成;
|
|
449
|
+
应用自己的变量名要么来自证据、要么由你给。
|
|
450
|
+
- **不做需要判断的事。** 哪些字段用户真必须填、标签怎么翻译、要不要接面板依赖,
|
|
451
|
+
工具列清单、给依据,但不替你决定(`p1 form audit` + `--plan` 就是为这条设计的)。
|
|
@@ -111,7 +111,7 @@ Install the CLI first: `uv tool install 1panel-toolkit` (or `pip install
|
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
113
|
p1 remote check # host facts, free ports
|
|
114
|
-
p1 remote smoke <package-dir> #
|
|
114
|
+
p1 remote smoke <package-dir> # up, probe, logs, tear down
|
|
115
115
|
p1 deploy <package-dir> --param PANEL_APP_PORT_HTTP=10000 --probe --uninstall --cleanup-local
|
|
116
116
|
```
|
|
117
117
|
|
|
@@ -420,6 +420,8 @@ def cmd_deploy(args: argparse.Namespace) -> int:
|
|
|
420
420
|
"remote_dir": result.remote_dir,
|
|
421
421
|
"probe": result.probe,
|
|
422
422
|
"probe_ok": result.probe_ok,
|
|
423
|
+
"lessons": [item.get("id") for item in result.lessons],
|
|
424
|
+
"needs_input": result.needs_input,
|
|
423
425
|
},
|
|
424
426
|
ensure_ascii=False,
|
|
425
427
|
indent=2,
|
|
@@ -718,8 +720,6 @@ def cmd_remote(args: argparse.Namespace) -> int:
|
|
|
718
720
|
"detail": result.detail,
|
|
719
721
|
"containers": result.container_states,
|
|
720
722
|
"ports": result.published_ports,
|
|
721
|
-
"chosen_ports": result.chosen_ports,
|
|
722
|
-
"auto_ports": result.auto_ports,
|
|
723
723
|
"probe": result.probe,
|
|
724
724
|
"lessons": [item.get("id") for item in result.lessons],
|
|
725
725
|
"remote_dir": result.remote_dir,
|
|
@@ -1025,8 +1025,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1025
1025
|
"--ports",
|
|
1026
1026
|
action="append",
|
|
1027
1027
|
help="host port for the Nth PANEL_APP_PORT_* field (repeatable). "
|
|
1028
|
-
"Omit it
|
|
1029
|
-
"port is refused before anything is uploaded",
|
|
1028
|
+
"Omit it to use the package's own form defaults",
|
|
1030
1029
|
)
|
|
1031
1030
|
remote_smoke.add_argument("--timeout", type=int, default=420)
|
|
1032
1031
|
remote_smoke.add_argument(
|
|
@@ -25,7 +25,13 @@ from pathlib import Path
|
|
|
25
25
|
import yaml
|
|
26
26
|
|
|
27
27
|
from .panel import PanelClient, PanelError
|
|
28
|
-
from .remote import
|
|
28
|
+
from .remote import (
|
|
29
|
+
SshTarget,
|
|
30
|
+
_tarball,
|
|
31
|
+
match_lessons,
|
|
32
|
+
needs_user_input,
|
|
33
|
+
probe_host_port,
|
|
34
|
+
)
|
|
29
35
|
|
|
30
36
|
LOCAL_APP_ROOT = "/opt/1panel/resource/apps/local"
|
|
31
37
|
DEFAULT_INSTALL_TIMEOUT = 600
|
|
@@ -44,6 +50,8 @@ class DeployResult:
|
|
|
44
50
|
kept: bool = False
|
|
45
51
|
probe: str = ""
|
|
46
52
|
probe_ok: bool | None = None
|
|
53
|
+
lessons: list[dict] = field(default_factory=list)
|
|
54
|
+
needs_input: list[str] = field(default_factory=list)
|
|
47
55
|
|
|
48
56
|
def render(self) -> str:
|
|
49
57
|
lines = [f"{'PASS' if self.ok else 'FAIL'} deploy/{self.stage}"]
|
|
@@ -63,6 +71,12 @@ class DeployResult:
|
|
|
63
71
|
lines.append(f" {'探针' if self.probe_ok else '探针(失败)'}: {self.probe}")
|
|
64
72
|
if self.remote_dir:
|
|
65
73
|
lines.append(f" 远端: {self.remote_dir}")
|
|
74
|
+
for item in self.needs_input:
|
|
75
|
+
lines.append(f" 待填: {item}(不是包的缺陷,装的时候补上即可)")
|
|
76
|
+
for lesson in self.lessons:
|
|
77
|
+
lines.append(f" 根因提示: {lesson.get('id')} {lesson.get('title')}")
|
|
78
|
+
if lesson.get("fix"):
|
|
79
|
+
lines.append(f" → {lesson['fix']}")
|
|
66
80
|
if not self.ok and self.install_log:
|
|
67
81
|
lines.append(" --- 安装日志尾部 ---")
|
|
68
82
|
for line in self.install_log.strip().splitlines()[-20:]:
|
|
@@ -324,6 +338,20 @@ def deploy(
|
|
|
324
338
|
probe_detail, probe_ok, _ = probe_host_port(target, port)
|
|
325
339
|
if not probe_ok:
|
|
326
340
|
ok = False
|
|
341
|
+
# A failure that just says "status=error" sends people hunting. Read the
|
|
342
|
+
# container's own logs and reuse the lesson matcher the smoke test uses.
|
|
343
|
+
logs = ""
|
|
344
|
+
if not ok:
|
|
345
|
+
container = str((record or {}).get("container") or "")
|
|
346
|
+
if not container:
|
|
347
|
+
for line in containers:
|
|
348
|
+
if " " in line:
|
|
349
|
+
container = line.split(" ")[0]
|
|
350
|
+
break
|
|
351
|
+
if container:
|
|
352
|
+
logs = target.must(
|
|
353
|
+
f"docker logs --tail=120 {shlex.quote(container)} 2>&1 || true"
|
|
354
|
+
)
|
|
327
355
|
result = DeployResult(
|
|
328
356
|
ok=ok,
|
|
329
357
|
stage="verify" if ok else ("probe" if probe_detail else "verify"),
|
|
@@ -342,6 +370,9 @@ def deploy(
|
|
|
342
370
|
remote_dir=remote_dir,
|
|
343
371
|
probe=probe_detail,
|
|
344
372
|
probe_ok=probe_ok,
|
|
373
|
+
install_log=logs,
|
|
374
|
+
lessons=match_lessons(logs) if logs else [],
|
|
375
|
+
needs_input=needs_user_input(logs) if logs else [],
|
|
345
376
|
kept=not ok,
|
|
346
377
|
)
|
|
347
378
|
if uninstall_after and install_id:
|
|
@@ -28,11 +28,6 @@ from .loader import load_version
|
|
|
28
28
|
from .model import Version
|
|
29
29
|
|
|
30
30
|
SMOKE_ROOT = "/tmp/p1-smoke"
|
|
31
|
-
|
|
32
|
-
# Where a smoke test may bind a host port. These are the ports the cloud test
|
|
33
|
-
# host has shown to be open in its security group (see lesson L005); a filtered
|
|
34
|
-
# port makes the probe look like a packaging bug.
|
|
35
|
-
SMOKE_PORT_CANDIDATES = [10000, 10001, 10002, 10003, 10004, 15000, 15001]
|
|
36
31
|
DEFAULT_TIMEOUT = 420
|
|
37
32
|
|
|
38
33
|
|
|
@@ -208,8 +203,6 @@ class SmokeResult:
|
|
|
208
203
|
probe: str = ""
|
|
209
204
|
remote_dir: str = ""
|
|
210
205
|
kept: bool = False
|
|
211
|
-
chosen_ports: list[int] = field(default_factory=list)
|
|
212
|
-
auto_ports: bool = False
|
|
213
206
|
unverified: list[str] = field(default_factory=list)
|
|
214
207
|
health: list[str] = field(default_factory=list)
|
|
215
208
|
outcome: str = "pass" # pass | needs-input | fail
|
|
@@ -225,11 +218,7 @@ class SmokeResult:
|
|
|
225
218
|
if self.container_states:
|
|
226
219
|
lines.append(" 容器: " + "; ".join(self.container_states))
|
|
227
220
|
if self.published_ports:
|
|
228
|
-
|
|
229
|
-
lines.append(" 端口: " + ", ".join(self.published_ports) + suffix)
|
|
230
|
-
elif self.chosen_ports:
|
|
231
|
-
suffix = "(自动选择)" if self.auto_ports else ""
|
|
232
|
-
lines.append(" 端口: " + ", ".join(str(p) for p in self.chosen_ports) + suffix)
|
|
221
|
+
lines.append(" 端口: " + ", ".join(self.published_ports))
|
|
233
222
|
if self.probe:
|
|
234
223
|
lines.append(f" 探针: {self.probe}")
|
|
235
224
|
if self.health:
|
|
@@ -260,6 +249,10 @@ LOG_SIGNATURES = (
|
|
|
260
249
|
("Permission denied", "L004"),
|
|
261
250
|
("connection refused", "L002"),
|
|
262
251
|
("Connection refused", "L002"),
|
|
252
|
+
# A busy host port is not a package defect: the panel offers another one.
|
|
253
|
+
("address already in use", "L008"),
|
|
254
|
+
("Ports are not available", "L008"),
|
|
255
|
+
("port is already allocated", "L008"),
|
|
263
256
|
)
|
|
264
257
|
|
|
265
258
|
|
|
@@ -338,29 +331,10 @@ def smoke(
|
|
|
338
331
|
container_name = f"{app_key}-smoke"
|
|
339
332
|
overrides = dict(overrides or {})
|
|
340
333
|
fields = port_fields(version)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
#
|
|
344
|
-
|
|
345
|
-
# `ss -lnt` says which are free right now.
|
|
346
|
-
if fields:
|
|
347
|
-
facts = check_host(target)
|
|
348
|
-
if requested:
|
|
349
|
-
busy = [port for port in requested if port in facts.used_ports]
|
|
350
|
-
if busy:
|
|
351
|
-
raise RemoteError(
|
|
352
|
-
f"端口 {busy} 在 {target.host} 上已被占用;"
|
|
353
|
-
"`p1 remote check` 会列出空闲候选,或换一组 --ports"
|
|
354
|
-
)
|
|
355
|
-
chosen = requested
|
|
356
|
-
else:
|
|
357
|
-
chosen = facts.free(SMOKE_PORT_CANDIDATES)[: len(fields)]
|
|
358
|
-
if len(chosen) < len(fields):
|
|
359
|
-
raise RemoteError(
|
|
360
|
-
f"主机上没有足够的空闲候选端口(需要 {len(fields)} 个,"
|
|
361
|
-
f"候选 {SMOKE_PORT_CANDIDATES});用 --ports 指定"
|
|
362
|
-
)
|
|
363
|
-
for index, value in enumerate(chosen):
|
|
334
|
+
# The port is the package's own decision: `--ports` overrides the install
|
|
335
|
+
# form's default, and without it the smoke run uses exactly what the package
|
|
336
|
+
# ships. Inventing a free port here would test a configuration no user gets.
|
|
337
|
+
for index, value in enumerate(ports or []):
|
|
364
338
|
if index < len(fields):
|
|
365
339
|
overrides[fields[index]] = str(value)
|
|
366
340
|
env_text = render_env(version, overrides, container_name)
|
|
@@ -440,8 +414,6 @@ def smoke(
|
|
|
440
414
|
container_states=states,
|
|
441
415
|
published_ports=published,
|
|
442
416
|
probe=probe,
|
|
443
|
-
chosen_ports=chosen,
|
|
444
|
-
auto_ports=bool(fields) and not requested,
|
|
445
417
|
remote_dir=remote_dir,
|
|
446
418
|
kept=keep or not ok,
|
|
447
419
|
unverified=unverified,
|
|
@@ -83,3 +83,13 @@ lessons:
|
|
|
83
83
|
cases:
|
|
84
84
|
- "1Panel v2.2.4:key 与 core.db 的 ApiKey 逐字符一致仍被拒;改为签名后 /api/v2/dashboard/base/os 返回 code 200"
|
|
85
85
|
applies_when: 用面板 API 做任何自动化(安装应用、读容器、抓日志)。
|
|
86
|
+
|
|
87
|
+
- id: L008
|
|
88
|
+
title: 宿主机端口已经被占用
|
|
89
|
+
detector: none
|
|
90
|
+
severity: warn
|
|
91
|
+
symptom: "容器起不来,日志或 compose 输出里是 `bind: address already in use` /
|
|
92
|
+
`Ports are not available` / `port is already allocated`。"
|
|
93
|
+
root_cause: 包的表单默认端口在这台机器上已经有人用(面板自身、别的应用、或上一次没清干净的容器)。
|
|
94
|
+
fix: 这不是包的缺陷——1Panel 安装时会在表单里提示端口占用并让你换一个;SSH 冒烟可以先用 `p1 ports <host> <候选端口>` 看清主机状态,再用 `p1 remote smoke --ports <空闲端口>` 指定。别为了跑通而改包里的默认值。
|
|
95
|
+
applies_when: 首次冒烟或首次安装时。
|
|
@@ -1,11 +1,54 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
8
|
+
from p1toolkit import cli
|
|
7
9
|
from p1toolkit import config as config_module
|
|
8
10
|
|
|
11
|
+
SPEC = {
|
|
12
|
+
"key": "demo-cfg",
|
|
13
|
+
"name": "Demo Config",
|
|
14
|
+
"description": "A demo application used by the tests.",
|
|
15
|
+
"shortDescEn": "A demo application used by the tests.",
|
|
16
|
+
"version": "1.0.0",
|
|
17
|
+
"type": "tool",
|
|
18
|
+
"tags": ["Tool"],
|
|
19
|
+
"port": 10000,
|
|
20
|
+
"services": [
|
|
21
|
+
{
|
|
22
|
+
"name": "demo",
|
|
23
|
+
"image": "nginx:1.27-alpine",
|
|
24
|
+
"container_port": 8080,
|
|
25
|
+
"mounts": [{"host": "./data", "target": "/app/data"}],
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_the_package_path_needs_no_panel_and_no_config(
|
|
32
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Panel and SSH are an optional aid: GitHub in, package out, no credentials.
|
|
35
|
+
|
|
36
|
+
Point the config at a path that does not exist (and holds no key) and walk
|
|
37
|
+
the core path anyway - gen, check, form audit and depth must all pass.
|
|
38
|
+
"""
|
|
39
|
+
monkeypatch.setenv("P1_CONFIG", str(tmp_path / "absent.toml"))
|
|
40
|
+
spec_path = tmp_path / "spec.json"
|
|
41
|
+
spec_path.write_text(json.dumps(SPEC), encoding="utf-8")
|
|
42
|
+
out = tmp_path / "out"
|
|
43
|
+
|
|
44
|
+
assert cli.main(["gen", str(spec_path), "--out", str(out), "--check"]) == 0
|
|
45
|
+
capsys.readouterr()
|
|
46
|
+
package = out / "demo-cfg"
|
|
47
|
+
assert cli.main(["check", str(package)]) == 0
|
|
48
|
+
assert cli.main(["form", "audit", str(package)]) == 0
|
|
49
|
+
assert cli.main(["depth", str(package)]) == 0
|
|
50
|
+
assert "判定:" in capsys.readouterr().out
|
|
51
|
+
|
|
9
52
|
|
|
10
53
|
def test_mask_hides_secret_values() -> None:
|
|
11
54
|
masked = config_module.mask("panel.api_key", "DLqcLYuuQzTUGPn2Yjnjlmm3z9nzHn7w")
|
|
@@ -268,3 +268,77 @@ def test_cli_probe_flag_is_optional_valued() -> None:
|
|
|
268
268
|
assert parser.parse_args(["deploy", "pkg", "--probe"]).probe == "auto"
|
|
269
269
|
assert parser.parse_args(["deploy", "pkg", "--probe", "15000"]).probe == "15000"
|
|
270
270
|
assert parser.parse_args(["deploy", "pkg"]).probe is None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --------------------------------------------------------------------------
|
|
274
|
+
# a failed install explains itself
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _FailingPanel(_FakePanel):
|
|
279
|
+
def request(self, path, method="GET", payload=None, timeout=None):
|
|
280
|
+
if path == "/apps/installed/search":
|
|
281
|
+
self.calls.append((path, method, payload))
|
|
282
|
+
return {
|
|
283
|
+
"items": [
|
|
284
|
+
{
|
|
285
|
+
"id": 77,
|
|
286
|
+
"name": "demo",
|
|
287
|
+
"status": "error",
|
|
288
|
+
"container": "1Panel-localdemo-FAIL",
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
return super().request(path, method, payload, timeout)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class _LogSsh:
|
|
296
|
+
def __init__(self, logs: str) -> None:
|
|
297
|
+
self.logs = logs
|
|
298
|
+
self.commands: list[str] = []
|
|
299
|
+
|
|
300
|
+
def must(self, command: str, timeout: int | None = None) -> str:
|
|
301
|
+
self.commands.append(command)
|
|
302
|
+
return self.logs if "docker logs" in command else ""
|
|
303
|
+
|
|
304
|
+
def run(self, command, input_bytes=None, timeout=None):
|
|
305
|
+
self.commands.append(command)
|
|
306
|
+
return 0, "", ""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _deploy_with_logs(tmp_path: Path, monkeypatch, logs: str):
|
|
310
|
+
from p1toolkit import deploy as deploy_module
|
|
311
|
+
|
|
312
|
+
package = _package(tmp_path)
|
|
313
|
+
ssh = _LogSsh(logs)
|
|
314
|
+
monkeypatch.setattr(deploy_module, "upload", lambda t, r, k: f"/remote/{k}")
|
|
315
|
+
monkeypatch.setattr(deploy_module.time, "sleep", lambda _: None)
|
|
316
|
+
result = deploy_module.deploy(ssh, _FailingPanel(), package, name="demo", timeout=1)
|
|
317
|
+
return result, ssh
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_a_failed_install_reports_the_root_cause_lesson(tmp_path: Path, monkeypatch) -> None:
|
|
321
|
+
result, ssh = _deploy_with_logs(
|
|
322
|
+
tmp_path,
|
|
323
|
+
monkeypatch,
|
|
324
|
+
"Error response from daemon: bind: address already in use",
|
|
325
|
+
)
|
|
326
|
+
assert not result.ok
|
|
327
|
+
assert [item.get("id") for item in result.lessons] == ["L008"]
|
|
328
|
+
rendered = result.render()
|
|
329
|
+
assert "根因提示: L008" in rendered
|
|
330
|
+
assert "1Panel 安装时会" in rendered
|
|
331
|
+
assert any("docker logs" in command for command in ssh.commands)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_a_missing_secret_is_called_out_as_not_a_package_bug(
|
|
335
|
+
tmp_path: Path, monkeypatch
|
|
336
|
+
) -> None:
|
|
337
|
+
result, _ = _deploy_with_logs(
|
|
338
|
+
tmp_path,
|
|
339
|
+
monkeypatch,
|
|
340
|
+
"ValueError: Credential 0 (default): requires 'api_key' to be set",
|
|
341
|
+
)
|
|
342
|
+
assert not result.ok
|
|
343
|
+
assert result.needs_input
|
|
344
|
+
assert "不是包的缺陷" in result.render()
|
|
@@ -8,13 +8,10 @@ from conftest import write_package
|
|
|
8
8
|
from p1toolkit.loader import load_version
|
|
9
9
|
from p1toolkit.remote import (
|
|
10
10
|
HostFacts,
|
|
11
|
-
RemoteError,
|
|
12
11
|
SshTarget,
|
|
13
|
-
SMOKE_PORT_CANDIDATES,
|
|
14
12
|
_first_http_code,
|
|
15
13
|
_probe,
|
|
16
14
|
needs_user_input,
|
|
17
|
-
smoke,
|
|
18
15
|
_unverifiable,
|
|
19
16
|
_tarball,
|
|
20
17
|
check_host,
|
|
@@ -59,6 +56,19 @@ def test_match_lessons_maps_log_signatures() -> None:
|
|
|
59
56
|
assert match_lessons("all good") == []
|
|
60
57
|
|
|
61
58
|
|
|
59
|
+
def test_a_busy_host_port_is_explained_not_treated_as_a_package_bug() -> None:
|
|
60
|
+
"""The panel offers another port; the package's default is not the problem."""
|
|
61
|
+
logs = (
|
|
62
|
+
'Error response from daemon: driver failed programming external '
|
|
63
|
+
'connectivity on endpoint demo: bind: address already in use'
|
|
64
|
+
)
|
|
65
|
+
hits = match_lessons(logs)
|
|
66
|
+
assert [item["id"] for item in hits] == ["L008"]
|
|
67
|
+
lesson = hits[0]
|
|
68
|
+
assert "端口" in lesson["title"]
|
|
69
|
+
assert "1Panel 安装时会在表单里提示" in lesson["fix"]
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
def test_missing_credentials_are_not_a_package_bug() -> None:
|
|
63
73
|
logs = (
|
|
64
74
|
"Invalid value for 'vlm': Value error, Credential 0 (default): "
|
|
@@ -162,85 +172,6 @@ class _FakeTarget(SshTarget):
|
|
|
162
172
|
return self.output
|
|
163
173
|
|
|
164
174
|
|
|
165
|
-
class _SmokeTarget(SshTarget):
|
|
166
|
-
"""Answers host facts and the upload/probe commands a smoke run issues."""
|
|
167
|
-
|
|
168
|
-
def __init__(self, used_ports: list[int]) -> None:
|
|
169
|
-
super().__init__(host="fake")
|
|
170
|
-
self.used = used_ports
|
|
171
|
-
self.commands: list[str] = []
|
|
172
|
-
self.uploaded_env = ""
|
|
173
|
-
|
|
174
|
-
def must(self, command: str, timeout: float | None = None) -> str:
|
|
175
|
-
self.commands.append(command)
|
|
176
|
-
if "hostname:" in command: # the HOST_SCRIPT
|
|
177
|
-
return "\n".join(
|
|
178
|
-
[
|
|
179
|
-
"hostname:iZfake",
|
|
180
|
-
"os:Ubuntu 24.04",
|
|
181
|
-
"docker:29.5.1 / v5.1.3",
|
|
182
|
-
"mem:853 1613",
|
|
183
|
-
"disk:30",
|
|
184
|
-
"cpus:2",
|
|
185
|
-
"ports:" + ",".join(str(port) for port in self.used),
|
|
186
|
-
"apps:",
|
|
187
|
-
]
|
|
188
|
-
)
|
|
189
|
-
if "curl" in command:
|
|
190
|
-
return "200 rc=0"
|
|
191
|
-
return "OPEN"
|
|
192
|
-
|
|
193
|
-
def run(self, command, input_bytes=None, timeout=None):
|
|
194
|
-
self.commands.append(command)
|
|
195
|
-
if command.startswith("cat > ") and input_bytes:
|
|
196
|
-
self.uploaded_env = input_bytes.decode("utf-8")
|
|
197
|
-
if "docker compose ps" in command or "compose ps" in command:
|
|
198
|
-
return 0, "demo-smoke|running|running|0.0.0.0:10000->80/tcp", ""
|
|
199
|
-
return 0, "", ""
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _smoke_package(tmp_path: Path) -> Path:
|
|
203
|
-
return write_package(tmp_path / "demo", key="demo")
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def test_smoke_picks_a_free_candidate_when_ports_are_omitted(tmp_path: Path) -> None:
|
|
207
|
-
package = _smoke_package(tmp_path)
|
|
208
|
-
target = _SmokeTarget(used_ports=[10000, 10001])
|
|
209
|
-
result = smoke(target, package, timeout=5, ready_timeout=1)
|
|
210
|
-
assert result.chosen_ports == [10002, 10003, 10004][:1]
|
|
211
|
-
assert result.auto_ports is True
|
|
212
|
-
assert "PANEL_APP_PORT_HTTP=10002" in target.uploaded_env
|
|
213
|
-
assert "(自动选择)" in result.render()
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def test_smoke_refuses_a_port_that_is_already_taken(tmp_path: Path) -> None:
|
|
217
|
-
package = _smoke_package(tmp_path)
|
|
218
|
-
target = _SmokeTarget(used_ports=[10000])
|
|
219
|
-
with pytest.raises(RemoteError) as excinfo:
|
|
220
|
-
smoke(target, package, ports=[10000], timeout=5, ready_timeout=1)
|
|
221
|
-
message = str(excinfo.value)
|
|
222
|
-
assert "10000" in message and "已被占用" in message
|
|
223
|
-
# Nothing was uploaded: the check happens before the round trip.
|
|
224
|
-
assert target.uploaded_env == ""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def test_smoke_keeps_the_ports_you_chose(tmp_path: Path) -> None:
|
|
228
|
-
package = _smoke_package(tmp_path)
|
|
229
|
-
target = _SmokeTarget(used_ports=[10000])
|
|
230
|
-
result = smoke(target, package, ports=[15000], timeout=5, ready_timeout=1)
|
|
231
|
-
assert result.chosen_ports == [15000]
|
|
232
|
-
assert result.auto_ports is False
|
|
233
|
-
assert "PANEL_APP_PORT_HTTP=15000" in target.uploaded_env
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def test_smoke_reports_a_host_without_free_candidates(tmp_path: Path) -> None:
|
|
237
|
-
package = _smoke_package(tmp_path)
|
|
238
|
-
target = _SmokeTarget(used_ports=list(SMOKE_PORT_CANDIDATES))
|
|
239
|
-
with pytest.raises(RemoteError) as excinfo:
|
|
240
|
-
smoke(target, package, timeout=5, ready_timeout=1)
|
|
241
|
-
assert "空闲候选端口" in str(excinfo.value)
|
|
242
|
-
|
|
243
|
-
|
|
244
175
|
def test_check_host_parses_the_report() -> None:
|
|
245
176
|
output = "\n".join(
|
|
246
177
|
[
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|