1panel-toolkit 0.3.0__tar.gz → 0.3.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 (78) hide show
  1. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/CHANGELOG.md +69 -0
  2. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/PKG-INFO +3 -3
  3. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/README.md +2 -2
  4. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/docs/generation-depth.md +4 -1
  5. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/pyproject.toml +4 -1
  6. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/__init__.py +1 -1
  7. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/cli.py +8 -5
  8. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/depth.py +28 -11
  9. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/evidence.py +26 -0
  10. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/resolve/__init__.py +45 -31
  11. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/resolve/analyze.py +29 -0
  12. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/lessons.yaml +2 -1
  13. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_config.py +4 -4
  14. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_depth.py +50 -1
  15. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_evidence.py +19 -0
  16. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_resolve.py +70 -0
  17. 1panel_toolkit-0.3.0/_debug_resolve.err +0 -0
  18. 1panel_toolkit-0.3.0/_debug_resolve.log +0 -21
  19. 1panel_toolkit-0.3.0/_debug_resolve.py +0 -18
  20. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/.gitignore +0 -0
  21. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/LICENSE +0 -0
  22. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/docs/patterns.md +0 -0
  23. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/examples/demo-db-spec.json +0 -0
  24. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/examples/demo-spec.json +0 -0
  25. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/skill/SKILL.md +0 -0
  26. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/__init__.py +0 -0
  27. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/compose.py +0 -0
  28. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/forms.py +0 -0
  29. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/hygiene.py +0 -0
  30. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/i18n.py +0 -0
  31. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/lessons.py +0 -0
  32. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/structure.py +0 -0
  33. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/checks/substance.py +0 -0
  34. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/config.py +0 -0
  35. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/dependencies.py +0 -0
  36. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/deploy.py +0 -0
  37. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/errors.py +0 -0
  38. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/fix.py +0 -0
  39. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/gen.py +0 -0
  40. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/lifecycle.py +0 -0
  41. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/loader.py +0 -0
  42. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/logo.py +0 -0
  43. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/model.py +0 -0
  44. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/net.py +0 -0
  45. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/panel.py +0 -0
  46. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/patterns/__init__.py +0 -0
  47. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/patterns/catalog.py +0 -0
  48. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/patterns/data/patterns.json +0 -0
  49. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/patterns/extract.py +0 -0
  50. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/remote.py +0 -0
  51. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/report.py +0 -0
  52. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/resolve/sources.py +0 -0
  53. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/__init__.py +0 -0
  54. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/decisions.yaml +0 -0
  55. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/dependencies.yaml +0 -0
  56. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/envkeys.yaml +0 -0
  57. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/form-design.yaml +0 -0
  58. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/i18n.yaml +0 -0
  59. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/rules/data/store.yaml +0 -0
  60. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/src/p1toolkit/selftest.py +0 -0
  61. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/conftest.py +0 -0
  62. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_checks.py +0 -0
  63. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_corpus.py +0 -0
  64. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_dependencies.py +0 -0
  65. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_deploy.py +0 -0
  66. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_fix.py +0 -0
  67. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_forms.py +0 -0
  68. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_gen.py +0 -0
  69. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_github_source.py +0 -0
  70. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_lifecycle.py +0 -0
  71. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_net.py +0 -0
  72. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_panel.py +0 -0
  73. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_patterns.py +0 -0
  74. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_regressions.py +0 -0
  75. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_remote.py +0 -0
  76. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_remote_cli.py +0 -0
  77. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_rules_data.py +0 -0
  78. {1panel_toolkit-0.3.0 → 1panel_toolkit-0.3.2}/tests/test_selftest.py +0 -0
@@ -1,5 +1,74 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.3.2 — 2026-09-20
4
+
5
+ `p1 depth` 的判定阈值校准。用 `p1 fix` 收敛用户自己的四个包时发现的误判。
6
+
7
+ ### 判定:只缺一个维度不再是 thin
8
+
9
+ 原来的 `assess()` 是"三个多样性维度(对外端口 / 持久化挂载 / 应用自有变量)里
10
+ 缺任意一个 → thin"。于是 `xiaozhi-esp32-server` 被判成 `thin`——可它有 2 个对外端口、
11
+ 2 个挂载点(其中一个还是模型文件 `model.pt`)、`security_opt`、`init.sh`,只是把配置
12
+ 全放在挂载的 `data/.config.yaml` 里,本来就不该有应用变量。
13
+
14
+ 现在:**缺两个及以上才算薄**;只缺一个仍是 `adapted`,但会打一条 `·` 说明;
15
+ 与不相干官方包重合 ≥ 50% 仍是 `possibly-reskinned`。顺带 `p1 depth <包>`
16
+ 不再需要 `--corpus` 也会给判定(只有"最接近的官方包"那节需要语料)。
17
+
18
+ 四个真实包的判定:hindsight `adapted`、openviking `adapted`、
19
+ xiaozhi-esp32-server `adapted`(附一条"没有任何应用自有环境变量"的说明)、
20
+ xiaozhi-esp32-server-full `adapted`。
21
+
22
+ ### 验证
23
+
24
+ - 新增 2 条测试(缺一个维度 → adapted + note;缺两个 → thin),`pytest` 257 passed。
25
+ - 收敛后的四个包 `p1 check`:hindsight `0 fail, 2 warn`、openviking `0 fail, 1 warn`、
26
+ xiaozhi-esp32-server `0 fail, 1 warn`、xiaozhi-esp32-server-full `OK no issues`,
27
+ 与 README 表格一致;`p1 fix` 二次运行四个包都是"没有需要修复的内容"(幂等)。
28
+
29
+ ## 0.3.1 — 2026-09-20
30
+
31
+ 独立验证(另一个 session 的只读复现)挖出的三个真 bug,外加发布产物卫生。
32
+ 其中第 1 条从 0.1.0 起就在**每一个已发布版本**里,PyPI 上的 0.3.0 同样中招。
33
+
34
+ ### 修掉的 bug
35
+
36
+ - **`p1 resolve` 会被容器内挂载路径打崩**:`draft_spec` 里
37
+ `host, target = entry.split(":", 1)` 覆盖了同名函数参数,于是
38
+ `repo_key(target, ...)` 拿到的是 `/app/data` 这种容器路径而不是仓库路径。本地检出
39
+ 的主服务 compose 只要带 `volumes`,`p1 resolve` 就直接
40
+ `error: cannot interpret '/otel-lgtm/...'`、exit 2、一个文件都不写;运气"好"时
41
+ (容器路径恰好能被当成路径解析)则静默算出错误的 `key`。0.2.0 修过
42
+ `for target in mounts` 那处同名覆盖,漏了 `primary_volumes` 这处。
43
+ - **多服务 compose 的挂载点被并进主服务**:主服务 `/app/data`(host `~/.app`)与
44
+ sidecar `./Caddyfile:/etc/caddy/Caddyfile` 会被配成 `~/.app:/etc/caddy/Caddyfile`,
45
+ 等于把应用的宿主目录 bind 到另一个服务的容器路径上。这种包证据审计 0 fail、
46
+ `p1 check` 0 fail——因为那个容器路径确实在 D03 证据里,正是 D03 想拦的"挂错路径"。
47
+ 新增 `analyze.service_mounts()` 按服务归属解析;draft 只描述主服务,其它服务的
48
+ 挂载点写进 `notes`(并出现在 `decision-report.md`)交给人判断。
49
+ - **`mounts` 里缺 `target` 的条目被静默丢弃**:`mount_host` 写错会被阻塞,同样意图
50
+ 写进 `mounts[]` 写错却 rc=0、无 volume、无提示。现在按 D03 阻塞;半坏不坏的
51
+ `mounts` 列表也不会被"修好一半"。
52
+ - 本地检出的产品名不再是一整条路径:`local:D:\...\OpenViking-main` 以前会原样进
53
+ `name`,现在取目录名。
54
+
55
+ ### 产物卫生
56
+
57
+ - 发布物不再带真实服务器 IP(`cli.py` help 示例、`lessons.yaml` 的 L005 案例、
58
+ README 示例、`tests/test_config.py`)——统一换成文档用地址 `203.0.113.10`;
59
+ `tests/test_config.py` 里的私钥路径也换成占位值。
60
+ - `_debug_*` 与 `*.log` 加进构建排除列表,一次性排查脚本不会再被扫进 sdist。
61
+ 注意 `1panel-toolkit\_debug_resolve.py/.log/.err` 三个文件仍在磁盘上
62
+ (本会话的删除命令被策略拦下),可手动删除。
63
+
64
+ ### 验证
65
+
66
+ - 新增 5 条回归测试(容器路径不破坏 key、本地检出命名、按服务归属挂载、
67
+ `mounts` 缺 target 被拒、半坏 `mounts` 被拒),255 passed。
68
+ - 复现用例:带 `- ./x:/otel-lgtm/.../dash.yaml:ro` 的 5 行 compose,修复前
69
+ `p1 resolve` exit 2;修复后正常产出 spec 且 `key=repo`。openviking 真实检出:
70
+ 修复前 `mounts` 两条且 host 错配,修复后只剩主服务那条 + 一条说明。
71
+
3
72
  ## 0.3.0 — 2026-09-20
4
73
 
5
74
  补上 D09(生命周期脚本、首次启动配置)与 D10(依赖接入)两件"生成器一直在躲"的事。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 1panel-toolkit
3
- Version: 0.3.0
3
+ Version: 0.3.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
@@ -47,7 +47,7 @@ agent 调用。
47
47
  ## 安装
48
48
 
49
49
  ```bash
50
- uv tool install 1panel-toolkit
50
+ uv tool install 1panel-toolkit # PyPI 上当前是 0.3.0
51
51
  p1 --version
52
52
  p1 selftest # 验收:规则数据、写法库、生成→校验链路是否都正常
53
53
  ```
@@ -74,7 +74,7 @@ p1 check <app-package-dir>
74
74
  p1 check <dir> --strict
75
75
  p1 check <dir> --json
76
76
  p1 rules lessons
77
- p1 ports 47.113.177.168 10000,15000,8080
77
+ p1 ports 203.0.113.10 10000,15000,8080
78
78
  p1 patterns list
79
79
  p1 patterns show compose/panel-db-env
80
80
  p1 patterns find 数据库
@@ -13,7 +13,7 @@ agent 调用。
13
13
  ## 安装
14
14
 
15
15
  ```bash
16
- uv tool install 1panel-toolkit
16
+ uv tool install 1panel-toolkit # PyPI 上当前是 0.3.0
17
17
  p1 --version
18
18
  p1 selftest # 验收:规则数据、写法库、生成→校验链路是否都正常
19
19
  ```
@@ -40,7 +40,7 @@ p1 check <app-package-dir>
40
40
  p1 check <dir> --strict
41
41
  p1 check <dir> --json
42
42
  p1 rules lessons
43
- p1 ports 47.113.177.168 10000,15000,8080
43
+ p1 ports 203.0.113.10 10000,15000,8080
44
44
  p1 patterns list
45
45
  p1 patterns show compose/panel-db-env
46
46
  p1 patterns find 数据库
@@ -99,7 +99,10 @@ p1 depth <包目录> --corpus <官方apps目录> # 单个包的判定 +
99
99
  判定逻辑基于"应用特有特征指纹"(容器内端口、挂载点、应用变量名、镜像、额外运行时
100
100
  字段),做 Jaccard 相似度:
101
101
 
102
- - 缺少端口 / 挂载 / 应用变量 → `thin`
102
+ - 三个"多样性维度"(对外端口 / 持久化挂载 / 应用自有变量)里**缺两个及以上** → `thin`
103
+ - 只缺一个 → 仍是 `adapted`,但会打一条 `·` 说明。应用完全可以把配置放在挂载的
104
+ 配置文件里(xiaozhi 的 `data/.config.yaml`、openviking 的 `ov.conf`),这时
105
+ "没有应用变量"是设计而不是薄——把这种情况判成 thin 会逼人往 compose 里塞无意义的变量
103
106
  - 与某个不相干的包重合度 ≥ 50% → `possibly-reskinned`
104
107
  - 都不满足 → `adapted`
105
108
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "1panel-toolkit"
7
- version = "0.3.0"
7
+ version = "0.3.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"
@@ -38,6 +38,9 @@ exclude = [
38
38
  ".pytest_cache/",
39
39
  "*.egg-info/",
40
40
  "*.pyc",
41
+ # Scratch files from debugging sessions must never ride along in the sdist.
42
+ "_debug_*",
43
+ "*.log",
41
44
  ]
42
45
 
43
46
  [tool.pytest.ini_options]
@@ -1,3 +1,3 @@
1
1
  """1panel-toolkit: one spec in, a verified 1Panel app package out."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -268,11 +268,14 @@ def cmd_depth(args: argparse.Namespace) -> int:
268
268
  print(f" - {value}")
269
269
  if len(values) > 12:
270
270
  print(f" ... 另外 {len(values) - 12} 项")
271
+ # The profile-based verdict needs no corpus; only the twin search does.
272
+ verdict = depth_module.assess(profile, corpus)
273
+ print(f"\n判定:{verdict.status}")
274
+ for reason in verdict.reasons:
275
+ print(f" ! {reason}")
276
+ for note in verdict.notes:
277
+ print(f" · {note}")
271
278
  if corpus:
272
- verdict = depth_module.assess(profile, corpus)
273
- print(f"\n判定:{verdict.status}")
274
- for reason in verdict.reasons:
275
- print(f" ! {reason}")
276
279
  print("\n与官方语料最接近的包:")
277
280
  found = depth_module.nearest(profile, corpus)
278
281
  if not found:
@@ -679,7 +682,7 @@ def build_parser() -> argparse.ArgumentParser:
679
682
  "ports",
680
683
  help="classify host ports as open / closed / filtered before a smoke test",
681
684
  )
682
- ports.add_argument("host", help="target host, e.g. 47.113.177.168")
685
+ ports.add_argument("host", help="target host, e.g. 203.0.113.10")
683
686
  ports.add_argument(
684
687
  "ports", help="comma separated ports or ranges, e.g. 10000,15000 or 10000-10010"
685
688
  )
@@ -366,6 +366,7 @@ class Verdict:
366
366
  status: str
367
367
  score: float
368
368
  reasons: list[str] = field(default_factory=list)
369
+ notes: list[str] = field(default_factory=list)
369
370
  twin: str | None = None
370
371
 
371
372
  @property
@@ -380,18 +381,28 @@ def assess(
380
381
 
381
382
  The dimensions that carry all the diversity in the store (container ports,
382
383
  mount targets, application variable names) are exactly the ones a template
383
- cannot answer. A package with thin values there, or one that is nearly
384
- identical to an unrelated app, is probably a reskin.
384
+ cannot answer. A package missing *one* of them is a judgement call and only
385
+ gets a note — an app can legitimately carry its configuration in a mounted
386
+ file instead of environment variables. Missing two or more means the
387
+ package is a shell; a near-identical unrelated app means it is a reskin.
385
388
  """
386
389
  reasons: list[str] = []
390
+ notes: list[str] = []
387
391
  if not profile.images:
388
392
  reasons.append("compose 里没有可识别的镜像")
389
- if not profile.port_pairs:
390
- reasons.append("没有对外端口映射")
391
- if not profile.mount_targets:
392
- reasons.append("没有持久化挂载点(除非这个应用真的无状态)")
393
- if not profile.app_env_keys:
394
- reasons.append("没有任何应用自有环境变量(多数应用都有配置项)")
393
+ missing = [
394
+ text
395
+ for present, text in (
396
+ (profile.port_pairs, "没有对外端口映射"),
397
+ (profile.mount_targets, "没有持久化挂载点(除非这个应用真的无状态)"),
398
+ (profile.app_env_keys, "没有任何应用自有环境变量(多数应用都有配置项)"),
399
+ )
400
+ if not present
401
+ ]
402
+ if len(missing) >= 2:
403
+ reasons.extend(missing)
404
+ else:
405
+ notes.extend(missing)
395
406
  twin = None
396
407
  score = 0.0
397
408
  found = nearest(profile, corpus, threshold=twin_threshold, limit=1)
@@ -404,10 +415,16 @@ def assess(
404
415
  )
405
416
 
406
417
  if not reasons:
407
- return Verdict(status="adapted", score=score)
418
+ return Verdict(status="adapted", score=score, notes=notes)
408
419
  if twin:
409
- return Verdict(status="possibly-reskinned", score=score, reasons=reasons, twin=twin)
410
- return Verdict(status="thin", score=score, reasons=reasons)
420
+ return Verdict(
421
+ status="possibly-reskinned",
422
+ score=score,
423
+ reasons=reasons,
424
+ notes=notes,
425
+ twin=twin,
426
+ )
427
+ return Verdict(status="thin", score=score, reasons=reasons, notes=notes)
411
428
 
412
429
 
413
430
  def to_json(data: dict[str, Any]) -> str:
@@ -193,6 +193,23 @@ def _mount_targets(service: dict[str, Any]) -> list[str]:
193
193
  return []
194
194
 
195
195
 
196
+ def _mount_gaps(service: dict[str, Any]) -> list[str]:
197
+ """``mounts`` entries the generator would silently drop.
198
+
199
+ A host path with no container path is refused (see below); the same mistake
200
+ written as a ``mounts`` entry used to vanish without a word.
201
+ """
202
+ gaps: list[str] = []
203
+ mounts = service.get("mounts")
204
+ if isinstance(mounts, list):
205
+ for index, item in enumerate(mounts):
206
+ if not isinstance(item, dict):
207
+ gaps.append(f"mounts[{index}] 不是对象")
208
+ elif not item.get("target"):
209
+ gaps.append(f"mounts[{index}] 没有 target(host={item.get('host') or '—'})")
210
+ return gaps
211
+
212
+
196
213
  def _mount_host(service: dict[str, Any]) -> str:
197
214
  host = service.get("mount_host")
198
215
  if host:
@@ -314,6 +331,15 @@ def _audit_services(audit: Audit, spec: dict[str, Any]) -> None:
314
331
  )
315
332
 
316
333
  # D03 — a host path without a container path silently drops persistence.
334
+ gaps = _mount_gaps(service)
335
+ if gaps and not audit.acknowledged.get("D03"):
336
+ audit.add(
337
+ "D03",
338
+ FAIL,
339
+ f"{name}: {';'.join(gaps)}",
340
+ "挂载点必须写出容器内路径;这条会被生成器静默丢弃,"
341
+ "装出来的包看着有挂载、实际没有",
342
+ )
317
343
  if targets:
318
344
  if d03:
319
345
  unknown = [item for item in targets if item not in d03]
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any
15
15
 
16
16
  from .. import rules
17
- from .analyze import Analysis, Evidence, analyze
17
+ from .analyze import Analysis, Evidence, analyze, service_mounts
18
18
  from .sources import (
19
19
  DEFAULT_FETCH_BUDGET,
20
20
  GitHub,
@@ -281,38 +281,38 @@ def draft_spec(target: str, analysis: Analysis, decisions: list[Decision]) -> di
281
281
  # value never reaches the generator.
282
282
  images = analysis.values("image") or analysis.values("image_hint")
283
283
  ports = analysis.values("container_port")
284
- mounts = (
285
- analysis.values("mount_target")
286
- or analysis.values("mount_target_hint")
287
- )
288
- host_mounts = analysis.values("mount_host")
289
284
  services = analysis.compose.get("services") or {}
290
285
  primary = next(iter(services), "")
291
- # Pair each container path with its own host path, in the order they appear:
292
- # a service can mount a data directory and a single file from two places.
293
- mount_pairs: list[dict[str, str]] = []
294
- seen_targets: set[str] = set()
295
- primary_volumes = (
296
- services.get(primary, {}).get("volumes") if isinstance(services.get(primary), dict) else None
297
- )
298
- for entry in primary_volumes or []:
299
- if not isinstance(entry, str) or ":" not in entry:
300
- continue
301
- host, target = entry.split(":", 1)
302
- target = target.split(":")[0].strip()
303
- if target.startswith("/") and target not in seen_targets:
304
- seen_targets.add(target)
305
- mount_pairs.append({"host": host.strip(), "target": target})
286
+ # Pair each container path with its own host path, and only within one
287
+ # service: a compose file can mount a data directory for the app and a
288
+ # config file for a sidecar, and mixing them binds the wrong directory into
289
+ # the wrong service. The draft describes the primary service; mounts that
290
+ # belong to other services are reported for a human to decide.
291
+ per_service = service_mounts(analysis.compose)
292
+ primary_mounts = per_service.get(primary, [])
293
+ mount_pairs: list[dict[str, str]] = [
294
+ {"host": host, "target": container_path} for host, container_path in primary_mounts
295
+ ]
296
+ seen_targets: set[str] = {container_path for _, container_path in primary_mounts}
297
+ # The primary service's own container paths. Sidecar mounts are deliberately
298
+ # not included: they belong to another service and another host path.
299
+ mounts = [container_path for _, container_path in primary_mounts]
300
+ # Weak evidence (a path-valued env default such as HALO_WORK_DIR) has no host
301
+ # of its own. It only fills in when the primary service mounts nothing, and
302
+ # it gets the primary service's first host, never a sidecar's.
303
+ if not mounts:
304
+ mounts = analysis.values("mount_target_hint")
305
+ hint_host = primary_mounts[0][0] if primary_mounts else "./data"
306
306
  for container_path in mounts:
307
307
  if container_path not in seen_targets:
308
308
  seen_targets.add(container_path)
309
- mount_pairs.append(
310
- {
311
- "host": host_mounts[0] if host_mounts else "./data",
312
- "target": container_path,
313
- }
314
- )
315
- repo_name = str(source.label).split("/")[-1].split("@")[0] or "app"
309
+ mount_pairs.append({"host": hint_host, "target": container_path})
310
+ # The product name, never the local checkout path.
311
+ label = str(source.label)
312
+ if label.startswith("local:"):
313
+ repo_name = Path(label.split(":", 1)[1]).name or "app"
314
+ else:
315
+ repo_name = label.split("/")[-1].split("@")[0] or "app"
316
316
  # Values discovered for the application's own variables become compose
317
317
  # defaults; the generator decides later which of them a user must see.
318
318
  env_map: dict[str, Any] = env_map_of(analysis)
@@ -356,8 +356,20 @@ def draft_spec(target: str, analysis: Analysis, decisions: list[Decision]) -> di
356
356
  # default: `p1 gen` refuses it, so only carry a host path when something is
357
357
  # actually mounted.
358
358
  if mounts:
359
- service_spec["mount_host"] = (
360
- mount_pairs[0]["host"] if mount_pairs else (host_mounts[0] if host_mounts else "./data")
359
+ service_spec["mount_host"] = mount_pairs[0]["host"] if mount_pairs else hint_host
360
+
361
+ other_mounts = {
362
+ name: pairs for name, pairs in per_service.items() if name != primary and pairs
363
+ }
364
+ notes = list(source.notes) + list(analysis.notes)
365
+ if other_mounts:
366
+ described = "、".join(
367
+ f"{name}({', '.join(target for _, target in pairs)})"
368
+ for name, pairs in sorted(other_mounts.items())
369
+ )
370
+ notes.append(
371
+ f"compose 里还有其它服务的挂载点:{described};本 draft 只描述主服务 "
372
+ f"{primary or 'app'},这些挂载点要不要并进这个包由人工判断"
361
373
  )
362
374
 
363
375
  spec: dict[str, Any] = {
@@ -392,7 +404,7 @@ def draft_spec(target: str, analysis: Analysis, decisions: list[Decision]) -> di
392
404
  "architectures 默认 amd64:容器镜像仓库在本机不可达,无法读取 manifest",
393
405
  "type/tags 暂定 tool/Tool,需按应用实际形态确认",
394
406
  ],
395
- "notes": list(source.notes) + list(analysis.notes),
407
+ "notes": notes,
396
408
  }
397
409
  hints = dependency_hints(analysis, env_map)
398
410
  if hints:
@@ -408,6 +420,8 @@ def render_report(target: str, analysis: Analysis, decisions: list[Decision]) ->
408
420
  lines.append(f"- {key}: {value}")
409
421
  for note in source.notes:
410
422
  lines.append(f"- 注意:{note}")
423
+ for note in analysis.notes:
424
+ lines.append(f"- 注意:{note}")
411
425
  lines.append("")
412
426
  lines.append("| 决策 | 状态 | 答案 | 证据 |")
413
427
  lines.append("| --- | --- | --- | --- |")
@@ -191,6 +191,35 @@ def _volume_split(entry: Any) -> tuple[str, str] | None:
191
191
  return parts[0].strip(), parts[1].strip()
192
192
 
193
193
 
194
+ def service_mounts(compose: Any) -> dict[str, list[tuple[str, str]]]:
195
+ """Bind mounts per service: ``{service: [(host, container_path), ...]}``.
196
+
197
+ Attribution matters. A compose file can mount a data directory for the app
198
+ and a config file for a sidecar; pairing every container path with the
199
+ first host path produces a package that binds the wrong directory into the
200
+ wrong service.
201
+ """
202
+ if not isinstance(compose, dict):
203
+ return {}
204
+ services = compose.get("services")
205
+ if not isinstance(services, dict):
206
+ return {}
207
+ result: dict[str, list[tuple[str, str]]] = {}
208
+ for name, service in services.items():
209
+ if not isinstance(service, dict):
210
+ continue
211
+ pairs: list[tuple[str, str]] = []
212
+ for volume in service.get("volumes") or []:
213
+ split = _volume_split(volume)
214
+ # Only bind mounts with a container path; named volumes and paths
215
+ # relative to the container are a different decision.
216
+ if split and split[1].startswith("/") and split not in pairs:
217
+ pairs.append(split)
218
+ if pairs:
219
+ result[str(name)] = pairs
220
+ return result
221
+
222
+
194
223
  def _env_map(service: dict[str, Any]) -> list[tuple[str, str]]:
195
224
  env = service.get("environment")
196
225
  if isinstance(env, dict):
@@ -58,7 +58,8 @@ lessons:
58
58
  root_cause: 云厂商安全组只放行了部分端口,表单默认端口落在未放行区间。
59
59
  fix: "先跑 `p1 ports <host> <候选端口>`:立即被拒绝(closed)= 安全组已放行且无人监听,可直接用;超时(filtered)= 被上游拦掉,换端口。冒烟测试只用 closed/open 的端口。"
60
60
  cases:
61
- - "47.113.177.168:10000/15000/12345/18888 返回 RST(放行且空闲),9999/8080 超时(被安全组拦截)"
61
+ - "某云主机实测:部分端口返回 RST(安全组已放行且无人监听),另一些端口超时(被安全组拦截),
62
+ 与打包无关"
62
63
  applies_when: 在云服务器上做运行时验证。
63
64
 
64
65
  - id: L006
@@ -45,13 +45,13 @@ def test_windows_paths_survive_a_round_trip(tmp_path: Path, monkeypatch) -> None
45
45
  """A backslash is a TOML escape; writing one raw corrupts the whole file."""
46
46
  monkeypatch.setenv("P1_CONFIG", str(tmp_path / "config.toml"))
47
47
  cfg = config_module.load()
48
- cfg.set("ssh.key", r"C:\Users\idkan\.ssh\p1_1panel_cloud")
49
- cfg.set("ssh.host", "47.113.177.168")
48
+ cfg.set("ssh.key", r"C:\Users\example\.ssh\panel_key")
49
+ cfg.set("ssh.host", "203.0.113.10")
50
50
  cfg.save()
51
51
 
52
52
  reloaded = config_module.load()
53
- assert reloaded.get("ssh.key") == r"C:\Users\idkan\.ssh\p1_1panel_cloud"
54
- assert reloaded.get("ssh.host") == "47.113.177.168"
53
+ assert reloaded.get("ssh.key") == r"C:\Users\example\.ssh\panel_key"
54
+ assert reloaded.get("ssh.host") == "203.0.113.10"
55
55
 
56
56
 
57
57
  def test_quotes_in_values_survive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -64,6 +64,56 @@ def test_adapted_package_passes(tmp_path: Path) -> None:
64
64
  assert assess(profile, []).status == "adapted"
65
65
 
66
66
 
67
+ def test_missing_one_dimension_is_a_note_not_a_verdict(tmp_path: Path) -> None:
68
+ """An app can carry its config in a mounted file instead of env vars.
69
+
70
+ xiaozhi-esp32-server is exactly that shape: two ports, a model-file mount,
71
+ security_opt, an init script, and zero application variables because
72
+ everything lives in `data/.config.yaml`. Calling that "thin" is wrong.
73
+ """
74
+ compose = (
75
+ "services:\n"
76
+ " app:\n"
77
+ ' image: "ghcr.io/example/app:1.0"\n'
78
+ " container_name: ${CONTAINER_NAME}\n"
79
+ " networks:\n"
80
+ " - 1panel-network\n"
81
+ " ports:\n"
82
+ ' - "${PANEL_APP_PORT_HTTP}:8000"\n'
83
+ " volumes:\n"
84
+ " - ./data:/opt/app/data\n"
85
+ "networks:\n"
86
+ " 1panel-network:\n"
87
+ " external: true\n"
88
+ )
89
+ profile = profile_app(write_package(tmp_path / "demo", key="demo", compose=compose))
90
+ verdict = assess(profile, [])
91
+ assert verdict.status == "adapted"
92
+ assert not verdict.reasons
93
+ assert any("环境变量" in note for note in verdict.notes)
94
+
95
+
96
+ def test_two_missing_dimensions_are_still_thin(tmp_path: Path) -> None:
97
+ compose = (
98
+ "services:\n"
99
+ " app:\n"
100
+ ' image: "ghcr.io/example/app:1.0"\n'
101
+ " container_name: ${CONTAINER_NAME}\n"
102
+ " networks:\n"
103
+ " - 1panel-network\n"
104
+ " ports:\n"
105
+ ' - "${PANEL_APP_PORT_HTTP}:8000"\n'
106
+ "networks:\n"
107
+ " 1panel-network:\n"
108
+ " external: true\n"
109
+ )
110
+ profile = profile_app(write_package(tmp_path / "demo", key="demo", compose=compose))
111
+ verdict = assess(profile, [])
112
+ assert verdict.shallow
113
+ assert any("挂载" in reason for reason in verdict.reasons)
114
+ assert any("环境变量" in reason for reason in verdict.reasons)
115
+
116
+
67
117
  def test_similarity_finds_a_reskin(tmp_path: Path) -> None:
68
118
  first = profile_app(write_package(tmp_path / "a", key="a"))
69
119
  second = profile_app(write_package(tmp_path / "b", key="b"))
@@ -131,4 +181,3 @@ def test_templateable_dimensions_documented() -> None:
131
181
  templatable = data.get("templatable") or []
132
182
  assert templatable
133
183
  assert any("重启策略" in str(item.get("dimension")) for item in templatable)
134
-
@@ -136,6 +136,25 @@ def test_dangling_mount_host_is_refused() -> None:
136
136
  assert "D03" in result.blocked_decisions
137
137
 
138
138
 
139
+ def test_mount_entry_without_a_target_is_refused() -> None:
140
+ """The same mistake written into `mounts` used to vanish without a word."""
141
+ spec = make_spec()
142
+ spec["services"][0]["mounts"] = [{"host": "./data"}]
143
+ result = audit(spec)
144
+ assert not result.ok
145
+ assert "D03" in result.blocked_decisions
146
+ assert any("mounts[0]" in item.message for item in result.failures)
147
+
148
+
149
+ def test_partially_broken_mounts_are_refused_not_trimmed() -> None:
150
+ spec = make_spec()
151
+ spec["services"][0]["mounts"] = [
152
+ {"host": "./data", "target": "/app/data"},
153
+ {"host": "./extra"},
154
+ ]
155
+ assert not audit(spec).ok
156
+
157
+
139
158
  def test_port_declared_in_the_ledger_but_missing_from_the_spec_is_refused() -> None:
140
159
  spec = make_spec()
141
160
  spec["services"][0].pop("container_port")
@@ -230,6 +230,76 @@ def test_draft_spec_has_no_dangling_mount_host(tmp_path: Path) -> None:
230
230
  assert "mount_host" not in service
231
231
 
232
232
 
233
+ def test_a_container_path_volume_does_not_break_the_repository_key(
234
+ tmp_path: Path,
235
+ ) -> None:
236
+ """A volume's container path used to overwrite `draft_spec`'s `target`
237
+ argument, so `repo_key` received `/otel-lgtm/...` and resolve died with
238
+ "cannot interpret ..." before writing anything."""
239
+ root = tmp_path / "repo"
240
+ root.mkdir()
241
+ (root / "docker-compose.yml").write_text(
242
+ "services:\n"
243
+ " grafana:\n"
244
+ " image: grafana/otel-lgtm:latest\n"
245
+ " volumes:\n"
246
+ " - ./x:/otel-lgtm/grafana/conf/provisioning/dashboards/dash.yaml:ro\n",
247
+ encoding="utf-8",
248
+ )
249
+ result = resolve(str(root))
250
+ assert result.spec["key"] == "repo"
251
+ assert result.spec["name"] == "repo"
252
+ assert result.spec["services"][0]["mounts"] == [
253
+ {
254
+ "host": "./x",
255
+ "target": "/otel-lgtm/grafana/conf/provisioning/dashboards/dash.yaml",
256
+ }
257
+ ]
258
+
259
+
260
+ def test_local_checkout_never_names_the_product_after_its_path(tmp_path: Path) -> None:
261
+ root = tmp_path / "My-App-main"
262
+ root.mkdir()
263
+ (root / "Dockerfile").write_text("FROM alpine\nEXPOSE 8080\n", encoding="utf-8")
264
+ spec = resolve(str(root)).spec
265
+ assert spec["name"] == "My-App-main"
266
+ assert spec["key"] == "my-app-main"
267
+ assert "\\\\" not in spec["name"] and "tmp" not in spec["key"]
268
+
269
+
270
+ def test_each_service_keeps_its_own_mounts(tmp_path: Path) -> None:
271
+ """A sidecar's config mount must not be paired with the app's data host.
272
+
273
+ Getting this wrong produces a package that binds the app's data directory
274
+ into the sidecar's container path, and every static check passes.
275
+ """
276
+ root = tmp_path / "repo"
277
+ root.mkdir()
278
+ (root / "docker-compose.yml").write_text(
279
+ "services:\n"
280
+ " app:\n"
281
+ " image: example/app:1.0\n"
282
+ " ports:\n"
283
+ ' - "1933:1933"\n'
284
+ " volumes:\n"
285
+ " - ~/.app:/app/data\n"
286
+ " caddy:\n"
287
+ " image: caddy:2\n"
288
+ " volumes:\n"
289
+ " - ./Caddyfile:/etc/caddy/Caddyfile:ro\n",
290
+ encoding="utf-8",
291
+ )
292
+ spec = resolve(str(root)).spec
293
+ service = spec["services"][0]
294
+ assert service["mounts"] == [{"host": "~/.app", "target": "/app/data"}]
295
+ assert service["mount_targets"] == ["/app/data"]
296
+ assert service["mount_host"] == "~/.app"
297
+ # The sidecar's mount is reported, not silently merged.
298
+ assert any("caddy" in note and "/etc/caddy/Caddyfile" in note for note in spec["notes"])
299
+ report = render_report("repo", resolve(str(root)).analysis, resolve(str(root)).decisions)
300
+ assert "/etc/caddy/Caddyfile" in report
301
+
302
+
233
303
  def test_dependency_hints_point_at_the_upstream_variable_names(tmp_path: Path) -> None:
234
304
  """D10 的线索:把上游那几个数据库变量指出来,人不用自己抄。"""
235
305
  root = tmp_path / "repo"
File without changes
@@ -1,21 +0,0 @@
1
- GET https://api.github.com/repos/halo-dev/halo
2
- -> 0.5 s
3
- GET https://api.github.com/repos/halo-dev/halo/git/trees/main?recursive=1
4
- -> 2.2 s
5
- GET https://api.github.com/repos/halo-dev/halo/releases/latest
6
- -> 0.5 s
7
- GET https://raw.githubusercontent.com/halo-dev/halo/main/Dockerfile
8
- -> 20.5 s
9
- GET https://raw.githubusercontent.com/halo-dev/halo/main/README.md
10
- -> 60.5 s
11
- GET https://raw.githubusercontent.com/halo-dev/halo/main/docs/index/README.md
12
- -> 60.0 s
13
- GET https://raw.githubusercontent.com/halo-dev/halo/main/docs/notification/README.md
14
- -> 36.4 s
15
- GET https://raw.githubusercontent.com/halo-dev/halo/main/ui/tsconfig.json
16
- -> 16.2 s
17
- GET https://raw.githubusercontent.com/halo-dev/halo/main/openspec/config.yaml
18
- -> 0.9 s
19
- GET https://raw.githubusercontent.com/halo-dev/halo/main/ui/tsconfig.app.json
20
- -> 0.9 s
21
- total 198.7 s
@@ -1,18 +0,0 @@
1
- import sys, time
2
- sys.path.insert(0, "src")
3
- from p1toolkit.resolve import sources
4
-
5
- orig_get = sources.GitHub._get
6
- def traced(self, url, limit=sources.MAX_FILE_BYTES):
7
- started = time.time()
8
- print("GET", url[:110], flush=True)
9
- try:
10
- return orig_get(self, url, limit)
11
- finally:
12
- print(" ->", round(time.time() - started, 1), "s", flush=True)
13
-
14
- sources.GitHub._get = traced
15
- from p1toolkit.resolve import resolve
16
- started = time.time()
17
- result = resolve("halo-dev/halo")
18
- print("total", round(time.time() - started, 1), "s", flush=True)
File without changes