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.
Files changed (81) hide show
  1. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/CHANGELOG.md +68 -0
  2. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/PKG-INFO +29 -7
  3. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/README.md +28 -6
  4. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/pyproject.toml +1 -1
  5. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/skill/SKILL.md +1 -1
  6. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/__init__.py +1 -1
  7. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/cli.py +3 -4
  8. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/deploy.py +32 -1
  9. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/remote.py +9 -37
  10. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/lessons.yaml +10 -0
  11. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_config.py +43 -0
  12. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_deploy.py +74 -0
  13. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_remote.py +13 -82
  14. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/.gitignore +0 -0
  15. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/LICENSE +0 -0
  16. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/docs/generation-depth.md +0 -0
  17. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/docs/patterns.md +0 -0
  18. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/examples/demo-db-spec.json +0 -0
  19. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/examples/demo-spec.json +0 -0
  20. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/__init__.py +0 -0
  21. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/compose.py +0 -0
  22. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/forms.py +0 -0
  23. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/hygiene.py +0 -0
  24. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/i18n.py +0 -0
  25. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/lessons.py +0 -0
  26. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/structure.py +0 -0
  27. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/checks/substance.py +0 -0
  28. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/config.py +0 -0
  29. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/dependencies.py +0 -0
  30. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/depth.py +0 -0
  31. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/errors.py +0 -0
  32. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/evidence.py +0 -0
  33. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/fix.py +0 -0
  34. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/form.py +0 -0
  35. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/gen.py +0 -0
  36. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/lifecycle.py +0 -0
  37. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/loader.py +0 -0
  38. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/logo.py +0 -0
  39. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/model.py +0 -0
  40. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/net.py +0 -0
  41. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/panel.py +0 -0
  42. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/__init__.py +0 -0
  43. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/catalog.py +0 -0
  44. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/data/patterns.json +0 -0
  45. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/patterns/extract.py +0 -0
  46. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/recipes.py +0 -0
  47. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/report.py +0 -0
  48. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/resolve/__init__.py +0 -0
  49. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/resolve/analyze.py +0 -0
  50. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/resolve/sources.py +0 -0
  51. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/__init__.py +0 -0
  52. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/decisions.yaml +0 -0
  53. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/dependencies.yaml +0 -0
  54. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/envkeys.yaml +0 -0
  55. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/form-design.yaml +0 -0
  56. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/form-review.yaml +0 -0
  57. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/i18n.yaml +0 -0
  58. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/recipes.yaml +0 -0
  59. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/rules/data/store.yaml +0 -0
  60. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/src/p1toolkit/selftest.py +0 -0
  61. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/conftest.py +0 -0
  62. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_checks.py +0 -0
  63. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_corpus.py +0 -0
  64. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_dependencies.py +0 -0
  65. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_depth.py +0 -0
  66. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_evidence.py +0 -0
  67. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_fix.py +0 -0
  68. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_form.py +0 -0
  69. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_forms.py +0 -0
  70. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_gen.py +0 -0
  71. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_github_source.py +0 -0
  72. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_lifecycle.py +0 -0
  73. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_net.py +0 -0
  74. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_panel.py +0 -0
  75. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_patterns.py +0 -0
  76. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_recipes.py +0 -0
  77. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_regressions.py +0 -0
  78. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_remote_cli.py +0 -0
  79. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_resolve.py +0 -0
  80. {1panel_toolkit-0.9.0 → 1panel_toolkit-0.9.2}/tests/test_rules_data.py +0 -0
  81. {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.0
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
- **端口不用你挑**:不传 `--ports` 时它会先读主机事实,从实测放行的候选区间
299
- (`10000-10004 / 15000 / 15001`)里选第一个空闲端口,并在报告里标注"(自动选择)";
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
- **端口不用你挑**:不传 `--ports` 时它会先读主机事实,从实测放行的候选区间
265
- (`10000-10004 / 15000 / 15001`)里选第一个空闲端口,并在报告里标注"(自动选择)";
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` 就是为这条设计的)。
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "1panel-toolkit"
7
- version = "0.9.0"
7
+ version = "0.9.2"
8
8
  description = "Generate, check, fix and runtime-verify 1Panel app packages from a single spec."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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> # port auto-picked; up, probe, logs, tear down
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
 
@@ -1,3 +1,3 @@
1
1
  """1panel-toolkit: one spec in, a verified 1Panel app package out."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.9.2"
@@ -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 and the first free candidate is picked on the host; an occupied "
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 SshTarget, _tarball, probe_host_port
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
- suffix = "(自动选择)" if self.auto_ports else ""
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
- requested = [int(value) for value in (ports or [])]
342
- chosen: list[int] = []
343
- # Picking the host port is a decision the toolkit can make for you: the
344
- # candidates are the ports the security group has been shown to allow, and
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