1panel-toolkit 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/CHANGELOG.md +37 -0
  2. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/PKG-INFO +11 -2
  3. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/README.md +10 -1
  4. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/pyproject.toml +1 -1
  5. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/__init__.py +1 -1
  6. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/compose.py +16 -0
  7. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/gen.py +15 -2
  8. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/resolve/__init__.py +83 -47
  9. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/resolve/analyze.py +61 -0
  10. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_checks.py +28 -0
  11. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_gen.py +45 -0
  12. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_resolve.py +96 -0
  13. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/.gitignore +0 -0
  14. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/LICENSE +0 -0
  15. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/docs/generation-depth.md +0 -0
  16. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/docs/patterns.md +0 -0
  17. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/examples/demo-db-spec.json +0 -0
  18. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/examples/demo-spec.json +0 -0
  19. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/skill/SKILL.md +0 -0
  20. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/__init__.py +0 -0
  21. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/forms.py +0 -0
  22. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/hygiene.py +0 -0
  23. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/i18n.py +0 -0
  24. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/lessons.py +0 -0
  25. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/structure.py +0 -0
  26. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/checks/substance.py +0 -0
  27. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/cli.py +0 -0
  28. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/config.py +0 -0
  29. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/dependencies.py +0 -0
  30. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/deploy.py +0 -0
  31. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/depth.py +0 -0
  32. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/errors.py +0 -0
  33. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/evidence.py +0 -0
  34. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/fix.py +0 -0
  35. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/lifecycle.py +0 -0
  36. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/loader.py +0 -0
  37. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/logo.py +0 -0
  38. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/model.py +0 -0
  39. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/net.py +0 -0
  40. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/panel.py +0 -0
  41. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/patterns/__init__.py +0 -0
  42. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/patterns/catalog.py +0 -0
  43. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/patterns/data/patterns.json +0 -0
  44. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/patterns/extract.py +0 -0
  45. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/remote.py +0 -0
  46. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/report.py +0 -0
  47. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/resolve/sources.py +0 -0
  48. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/__init__.py +0 -0
  49. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/decisions.yaml +0 -0
  50. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/dependencies.yaml +0 -0
  51. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/envkeys.yaml +0 -0
  52. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/form-design.yaml +0 -0
  53. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/i18n.yaml +0 -0
  54. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/lessons.yaml +0 -0
  55. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/rules/data/store.yaml +0 -0
  56. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/src/p1toolkit/selftest.py +0 -0
  57. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/conftest.py +0 -0
  58. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_config.py +0 -0
  59. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_corpus.py +0 -0
  60. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_dependencies.py +0 -0
  61. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_deploy.py +0 -0
  62. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_depth.py +0 -0
  63. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_evidence.py +0 -0
  64. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_fix.py +0 -0
  65. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_forms.py +0 -0
  66. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_github_source.py +0 -0
  67. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_lifecycle.py +0 -0
  68. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_net.py +0 -0
  69. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_panel.py +0 -0
  70. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_patterns.py +0 -0
  71. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_regressions.py +0 -0
  72. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_remote.py +0 -0
  73. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_remote_cli.py +0 -0
  74. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_rules_data.py +0 -0
  75. {1panel_toolkit-0.3.2 → 1panel_toolkit-0.4.0}/tests/test_selftest.py +0 -0
@@ -1,5 +1,42 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.4.0 — 2026-09-20
4
+
5
+ 多服务包:draft spec 从"只有主服务"变成"compose 里每个服务各一条"。
6
+
7
+ ### 为什么
8
+
9
+ 0.3.1 修了挂载点跨服务错配,但 `image` / `container_port` / `env` 仍然是跨服务聚合的:
10
+ `draft_spec` 取 `images[0]`、`ports[0]`,并把所有服务的环境变量合并成一份。一个
11
+ "应用 + 自带数据库"的 compose 会因此生成**一个** spec service,里面塞着 sidecar 的
12
+ 镜像、端口、甚至数据库密码。openviking(openviking + caddy)实测就是这种形态:
13
+ caddy 的 `OV_ACME_EMAIL` 会出现在主服务上,反过来 caddy 只拿到自己的变量。
14
+
15
+ ### 改了什么
16
+
17
+ - 新增 `analyze.service_facts()`:按服务取出镜像、端口、bind 挂载、环境变量、
18
+ `depends_on`、运行时字段(security_opt / user / devices …)与 healthcheck。
19
+ - `draft_spec` 为每个服务生成一条 spec(compose 顺序,第一条仍是主服务、拿
20
+ `PANEL_APP_PORT_HTTP`,其余服务的容器名带后缀)。Dockerfile 里的变量只归主服务,
21
+ 且不会重复落到已经声明过它的服务上;sidecar 的凭据不会被并进应用服务。
22
+ - 多服务时加一条 note:这些服务已按归属写进 draft,如果面板上已有对应应用
23
+ (postgresql / redis / mysql),考虑改用 `dependencies` 走面板托管(D10)。
24
+ - **healthcheck**:上游的 healthcheck 块(`test` / `interval` / …)会原样带进 spec 和
25
+ 生成的 compose;上游只有"存在 healthcheck"这一事实时,draft 只写一条提示,
26
+ 不再输出 `healthcheck: true`——那不是定义,Docker 会直接解析失败。新增规则
27
+ **C005** 拦住手写的非映射 healthcheck。
28
+ - 表单字段顺序:端口字段统一排在前面,多服务时不会被另一个服务的变量隔开。
29
+
30
+ ### 实测
31
+
32
+ - openviking(openviking + caddy):draft 从 1 条(镜像/端口/挂载/变量全混)变成
33
+ 2 条各自正确——openviking 拿到自己的镜像、1933、`/app/.openviking`、
34
+ Dockerfile 变量与真实 healthcheck 块;caddy 拿到 `caddy:2`、1934、
35
+ `./Caddyfile:/etc/caddy/Caddyfile`、`depends_on: [openviking]` 与自己的变量。
36
+ `p1 gen --check` → `0 fail`。
37
+ - 新增 9 条测试(多服务归属、sidecar 凭据不串味、healthcheck 块 vs 标志、
38
+ C005、多服务生成),`pytest` 265 passed。
39
+
3
40
  ## 0.3.2 — 2026-09-20
4
41
 
5
42
  `p1 depth` 的判定阈值校准。用 `p1 fix` 收敛用户自己的四个包时发现的误判。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 1panel-toolkit
3
- Version: 0.3.2
3
+ Version: 0.4.0
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
@@ -105,7 +105,8 @@ p1 selftest # 验收:这个安装能不能用
105
105
  `values[].label` 必须是纯字符串、bool label 必须加引号。
106
106
 
107
107
  规则组 `C0xx` 管 compose:顶层 `version:`、image 引号、`${VAR}` 与表单 envKey
108
- 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突。
108
+ 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突、
109
+ `healthcheck` 必须是映射(`C005`,`healthcheck: true` 会让 Docker 直接解析失败)。
109
110
 
110
111
  规则组 `H0xx` 管文件卫生:LF/BOM、README store 风格、`.env.sample` 闭合。
111
112
 
@@ -175,6 +176,11 @@ README,抽取出端口、挂载点、环境变量、运行用户、额外运
175
176
  落进 draft spec——人工确认过的值必须能到达生成器,而不是停在报告里。产出三份文件:
176
177
  `decision-report.md`、`draft-spec.json`、`resolve.json`。
177
178
 
179
+ **多服务 compose 按服务归属**:compose 里每个服务在 draft spec 里各占一条,镜像、容器端口、
180
+ bind 挂载、环境变量、`depends_on`、运行时字段都只跟着自己的服务走。Dockerfile 里的变量归
181
+ 主服务;sidecar(自带的数据库/缓存)的密码不会被并到应用服务上,反过来也一样。多服务时还会给
182
+ 一条提示:如果面板上已经有对应的应用,考虑改用 `dependencies` 走面板托管(D10)。
183
+
178
184
  实测:`halo-dev/halo` 的挂载点在 Dockerfile 里没有 `VOLUME`,但 `HALO_WORK_DIR`
179
185
  默认值是 `/root/.halo2`;镜像只在 README 里出现——它把两条都标成 `inferred`
180
186
  并给出出处,而不是停下来或者瞎猜。
@@ -250,6 +256,9 @@ p1 fix <包目录> --check # 改完立刻重新校验
250
256
  | openviking | 1 fail + 6 warn | **0 fail + 1 warn** | 8 处 |
251
257
  | xiaozhi-esp32-server | 1 fail + 13 warn | **0 fail + 1 warn** | 14 处 |
252
258
 
259
+ (2026-09-20 在真实包上的实测记录;这些包随后已就地修复,
260
+ `xiaozhi-esp32-server-full` 现在是 `0 fail / 0 warn`。)
261
+
253
262
  静态校验和生成的 YAML 都不能告诉你"容器起不起得来、端口通不通、应用找不找得到配置文件"。
254
263
 
255
264
  ```bash
@@ -71,7 +71,8 @@ p1 selftest # 验收:这个安装能不能用
71
71
  `values[].label` 必须是纯字符串、bool label 必须加引号。
72
72
 
73
73
  规则组 `C0xx` 管 compose:顶层 `version:`、image 引号、`${VAR}` 与表单 envKey
74
- 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突。
74
+ 闭合、`createdBy` 标签、端口 envKey 前缀、外部网络、同名服务 DNS 冲突、
75
+ `healthcheck` 必须是映射(`C005`,`healthcheck: true` 会让 Docker 直接解析失败)。
75
76
 
76
77
  规则组 `H0xx` 管文件卫生:LF/BOM、README store 风格、`.env.sample` 闭合。
77
78
 
@@ -141,6 +142,11 @@ README,抽取出端口、挂载点、环境变量、运行用户、额外运
141
142
  落进 draft spec——人工确认过的值必须能到达生成器,而不是停在报告里。产出三份文件:
142
143
  `decision-report.md`、`draft-spec.json`、`resolve.json`。
143
144
 
145
+ **多服务 compose 按服务归属**:compose 里每个服务在 draft spec 里各占一条,镜像、容器端口、
146
+ bind 挂载、环境变量、`depends_on`、运行时字段都只跟着自己的服务走。Dockerfile 里的变量归
147
+ 主服务;sidecar(自带的数据库/缓存)的密码不会被并到应用服务上,反过来也一样。多服务时还会给
148
+ 一条提示:如果面板上已经有对应的应用,考虑改用 `dependencies` 走面板托管(D10)。
149
+
144
150
  实测:`halo-dev/halo` 的挂载点在 Dockerfile 里没有 `VOLUME`,但 `HALO_WORK_DIR`
145
151
  默认值是 `/root/.halo2`;镜像只在 README 里出现——它把两条都标成 `inferred`
146
152
  并给出出处,而不是停下来或者瞎猜。
@@ -216,6 +222,9 @@ p1 fix <包目录> --check # 改完立刻重新校验
216
222
  | openviking | 1 fail + 6 warn | **0 fail + 1 warn** | 8 处 |
217
223
  | xiaozhi-esp32-server | 1 fail + 13 warn | **0 fail + 1 warn** | 14 处 |
218
224
 
225
+ (2026-09-20 在真实包上的实测记录;这些包随后已就地修复,
226
+ `xiaozhi-esp32-server-full` 现在是 `0 fail / 0 warn`。)
227
+
219
228
  静态校验和生成的 YAML 都不能告诉你"容器起不起得来、端口通不通、应用找不找得到配置文件"。
220
229
 
221
230
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "1panel-toolkit"
7
- version = "0.3.2"
7
+ version = "0.4.0"
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"
@@ -1,3 +1,3 @@
1
1
  """1panel-toolkit: one spec in, a verified 1Panel app package out."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.4.0"
@@ -59,6 +59,7 @@ def run(package: Package, report: Report, strict: bool = False) -> None:
59
59
  _check_services(version, report, name, generic)
60
60
  _check_ports(version, report, name)
61
61
  _check_networks(version, report, name)
62
+ _check_healthcheck(version, report, name)
62
63
 
63
64
 
64
65
  def _check_images(version: Version, report: Report, name: str, strict: bool) -> None:
@@ -186,3 +187,18 @@ def _check_networks(version: Version, report: Report, name: str) -> None:
186
187
  "can be reached through 1Panel's entry",
187
188
  name,
188
189
  )
190
+
191
+
192
+ def _check_healthcheck(version: Version, report: Report, name: str) -> None:
193
+ """`healthcheck:` must be a mapping; `healthcheck: true` breaks the parse."""
194
+ for service_name, service in version.services.items():
195
+ if not isinstance(service, dict) or "healthcheck" not in service:
196
+ continue
197
+ healthcheck = service["healthcheck"]
198
+ if not isinstance(healthcheck, dict):
199
+ report.fail(
200
+ "C005",
201
+ f"service {service_name}: healthcheck 必须是映射(test/interval/…),"
202
+ f"现在是 {type(healthcheck).__name__};Docker 解析不过",
203
+ name,
204
+ )
@@ -208,6 +208,9 @@ def build_fields(
208
208
  dependency wiring (which is rendered literally, not as a default).
209
209
  """
210
210
  fields: list[dict] = []
211
+ # Ports first: with more than one service the port fields would otherwise be
212
+ # scattered between another service's variables.
213
+ port_fields: list[dict] = []
211
214
  hidden: dict[str, list[str]] = {}
212
215
  # Keys the generator adds on its own; they must not be double counted as
213
216
  # "hidden upstream variables" when the upstream compose also sets them.
@@ -219,7 +222,7 @@ def build_fields(
219
222
  port = service.get("container_port")
220
223
  if port:
221
224
  env_key = "PANEL_APP_PORT_HTTP" if port_index == 0 else f"PANEL_APP_PORT_{port_index}"
222
- fields.append(
225
+ port_fields.append(
223
226
  {
224
227
  "envKey": env_key,
225
228
  "type": "number",
@@ -252,6 +255,7 @@ def build_fields(
252
255
  entry["description"] = item["description"]
253
256
  fields.append(entry)
254
257
 
258
+ fields = port_fields + fields
255
259
  hidden = {
256
260
  service: [key for key in keys if key not in reserved]
257
261
  for service, keys in hidden.items()
@@ -370,7 +374,6 @@ def build_compose(
370
374
  if env_lines:
371
375
  entry["environment"] = env_lines
372
376
  for extra in (
373
- "healthcheck",
374
377
  "security_opt",
375
378
  "cap_add",
376
379
  "cap_drop",
@@ -392,6 +395,16 @@ def build_compose(
392
395
  ):
393
396
  if service.get(extra):
394
397
  entry[extra] = service[extra]
398
+ # `healthcheck` is a mapping, never a flag: a boolean from a spec (or an
399
+ # evidence flag) must not reach the compose file, Docker rejects it.
400
+ healthcheck = service.get("healthcheck")
401
+ if isinstance(healthcheck, dict):
402
+ entry["healthcheck"] = healthcheck
403
+ elif healthcheck:
404
+ warnings.append(
405
+ f"{name}: healthcheck 不是映射({healthcheck!r}),已忽略;"
406
+ "探针要写成 {test: [...], interval: 10s, ...}"
407
+ )
395
408
  entry["labels"] = {"createdBy": "Apps"}
396
409
  services[name] = entry
397
410
 
@@ -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, service_mounts
17
+ from .analyze import Analysis, Evidence, analyze, service_facts, service_mounts
18
18
  from .sources import (
19
19
  DEFAULT_FETCH_BUDGET,
20
20
  GitHub,
@@ -316,60 +316,96 @@ def draft_spec(target: str, analysis: Analysis, decisions: list[Decision]) -> di
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)
319
- # Runtime knobs upstream asks for (security_opt, cap_add, devices, ...) must
320
- # survive into the generated compose: silently dropping one breaks a feature.
321
- carried_keys = (
322
- "security_opt",
323
- "cap_add",
324
- "cap_drop",
325
- "devices",
326
- "user",
327
- "sysctls",
328
- "ulimits",
329
- "shm_size",
330
- "stop_grace_period",
331
- "command",
332
- )
333
- primary_service = services.get(primary)
334
- primary_service = primary_service if isinstance(primary_service, dict) else {}
335
- carried = {
336
- key: primary_service[key] for key in carried_keys if primary_service.get(key) is not None
337
- }
338
-
339
319
  evidence: dict[str, str] = {"repository": source.label}
340
320
  for kind, key in (("compose", "compose_file"), ("dockerfile", "dockerfile"), ("readme", "readme")):
341
321
  if kind in source.paths:
342
322
  evidence[key] = source.paths[kind]
343
323
 
344
- service_spec: dict[str, Any] = {
345
- "name": primary or "app",
346
- "image": images[0] if images else "",
347
- "container_port": int(ports[0]) if ports and ports[0].isdigit() else None,
348
- "mount_targets": mounts,
349
- "mounts": mount_pairs,
350
- "env": env_map,
351
- "env_keys": analysis.values("env_key"),
352
- "healthcheck": bool(analysis.evidence.get("healthcheck")),
353
- **carried,
354
- }
355
- # A host path with no container path is not a decision, it is a dangling
356
- # default: `p1 gen` refuses it, so only carry a host path when something is
357
- # actually mounted.
358
- if mounts:
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
- }
324
+ # One spec service per compose service, each carrying its own image, port,
325
+ # mounts, variables and runtime knobs. The first one (compose order) owns
326
+ # `PANEL_APP_PORT_HTTP`; the others get suffixed container names.
327
+ facts = service_facts(analysis.compose)
328
+ # Keys the compose already attributes to some service. Anything else in the
329
+ # Dockerfile-derived map belongs to the image the primary service runs; a
330
+ # sidecar's own variables must never leak back into it.
331
+ claimed = {key for fact in facts.values() for key in fact["env"]}
332
+ primary_extra = {key: value for key, value in env_map.items() if key not in claimed}
364
333
  notes = list(source.notes) + list(analysis.notes)
365
- if other_mounts:
334
+ services_spec: list[dict[str, Any]] = []
335
+ for index, (name, fact) in enumerate(facts.items()):
336
+ is_primary = index == 0
337
+ service_mounts_ = fact["mounts"]
338
+ if is_primary and not service_mounts_ and mounts:
339
+ # Weak evidence (a path-valued env default such as HALO_WORK_DIR)
340
+ # belongs to the app image, so it only lands on the primary service.
341
+ service_mounts_ = [(hint_host, target) for target in mounts]
342
+ spec_service: dict[str, Any] = {
343
+ "name": name,
344
+ "image": fact["image"] or (images[0] if is_primary and images else ""),
345
+ "container_port": fact["container_port"]
346
+ or (
347
+ int(ports[0])
348
+ if is_primary and ports and ports[0].isdigit()
349
+ else None
350
+ ),
351
+ "mount_targets": [target for _, target in service_mounts_],
352
+ "mounts": [
353
+ {"host": host, "target": target} for host, target in service_mounts_
354
+ ],
355
+ "env": {
356
+ **fact["env"],
357
+ **(primary_extra if is_primary else {}),
358
+ },
359
+ "env_keys": sorted(set(fact["env"]) | (set(primary_extra) if is_primary else set())),
360
+ **fact["runtime"],
361
+ }
362
+ # `healthcheck:` must be a mapping. Upstream having one is a fact worth
363
+ # reporting, but writing `healthcheck: true` produces a compose file
364
+ # Docker refuses to parse — so it is a note, not a spec field.
365
+ if isinstance(fact.get("healthcheck_block"), dict):
366
+ spec_service["healthcheck"] = fact["healthcheck_block"]
367
+ elif fact["healthcheck"]:
368
+ healthcheck_note = (
369
+ f"{name}: 上游声明了 healthcheck,但 draft 里没有它的定义(EXPOSE/CMD 推导不出来),"
370
+ "打包时请把探针命令补进 spec 的 healthcheck"
371
+ )
372
+ notes.append(healthcheck_note)
373
+ if fact["depends_on"]:
374
+ spec_service["depends_on"] = fact["depends_on"]
375
+ # A host path with no container path is not a decision, it is a dangling
376
+ # default: `p1 gen` refuses it, so only carry a host path when something
377
+ # is actually mounted.
378
+ if service_mounts_:
379
+ spec_service["mount_host"] = service_mounts_[0][0]
380
+ services_spec.append(spec_service)
381
+
382
+ if not services_spec:
383
+ services_spec = [
384
+ {
385
+ "name": primary or "app",
386
+ "image": images[0] if images else "",
387
+ "container_port": int(ports[0]) if ports and ports[0].isdigit() else None,
388
+ "mount_targets": mounts,
389
+ "mounts": mount_pairs,
390
+ "env": env_map,
391
+ "env_keys": analysis.values("env_key"),
392
+ "healthcheck": bool(analysis.evidence.get("healthcheck")),
393
+ }
394
+ ]
395
+ if mounts:
396
+ services_spec[0]["mount_host"] = mount_pairs[0]["host"] if mount_pairs else hint_host
397
+
398
+ if len(services_spec) > 1:
366
399
  described = "、".join(
367
- f"{name}({', '.join(target for _, target in pairs)})"
368
- for name, pairs in sorted(other_mounts.items())
400
+ f"{item['name']}"
401
+ + (f"({', '.join(target for _, target in facts[item['name']]['mounts'])})" if facts.get(item["name"], {}).get("mounts") else "")
402
+ for item in services_spec[1:]
369
403
  )
370
404
  notes.append(
371
- f"compose 里还有其它服务的挂载点:{described};本 draft 只描述主服务 "
372
- f"{primary or 'app'},这些挂载点要不要并进这个包由人工判断"
405
+ f"compose 里还有 {len(services_spec) - 1} 个服务:{described};"
406
+ "它们已按服务归属一并写进 draft。如果面板上已经有对应的应用"
407
+ "(postgresql / redis / mysql 等),考虑把它们改成 spec 的 dependencies 走"
408
+ "面板托管(D10),而不是在包里再塞一个 sidecar"
373
409
  )
374
410
 
375
411
  spec: dict[str, Any] = {
@@ -386,7 +422,7 @@ def draft_spec(target: str, analysis: Analysis, decisions: list[Decision]) -> di
386
422
  "port": 10000,
387
423
  "architectures": ["amd64"],
388
424
  "source_evidence": evidence,
389
- "services": [service_spec],
425
+ "services": services_spec,
390
426
  "decisions": {
391
427
  d.id: {
392
428
  "question": d.question,
@@ -220,6 +220,67 @@ def service_mounts(compose: Any) -> dict[str, list[tuple[str, str]]]:
220
220
  return result
221
221
 
222
222
 
223
+ RUNTIME_KEYS = (
224
+ "security_opt",
225
+ "cap_add",
226
+ "cap_drop",
227
+ "devices",
228
+ "user",
229
+ "sysctls",
230
+ "ulimits",
231
+ "shm_size",
232
+ "stop_grace_period",
233
+ "command",
234
+ )
235
+
236
+
237
+ def service_facts(compose: Any) -> dict[str, dict[str, Any]]:
238
+ """Everything the draft spec needs, attributed to the service it belongs to.
239
+
240
+ Aggregating these across services is how a sidecar's image, port or secret
241
+ ends up wired into the application service: the store has plenty of compose
242
+ files with an app plus a bundled database, and the two must stay apart.
243
+ """
244
+ if not isinstance(compose, dict):
245
+ return {}
246
+ services = compose.get("services")
247
+ if not isinstance(services, dict):
248
+ return {}
249
+ mounts = service_mounts(compose)
250
+ result: dict[str, dict[str, Any]] = {}
251
+ for name, service in services.items():
252
+ if not isinstance(service, dict):
253
+ continue
254
+ ports = [
255
+ int(port)
256
+ for port in (_container_port(mapping) for mapping in service.get("ports") or [])
257
+ if port
258
+ ]
259
+ env: dict[str, str] = {}
260
+ for key, value in _env_map(service):
261
+ # A value that is itself a variable reference stays empty: the panel
262
+ # supplies it, and inventing a default would point at nothing.
263
+ env[key] = "" if "${" in value else value
264
+ depends = service.get("depends_on") or []
265
+ if isinstance(depends, dict):
266
+ depends = list(depends)
267
+ result[str(name)] = {
268
+ "name": str(name),
269
+ "image": str(service.get("image") or "").strip(),
270
+ "container_port": ports[0] if ports else None,
271
+ "ports": ports,
272
+ "mounts": mounts.get(str(name), []),
273
+ "env": env,
274
+ "runtime": {key: service[key] for key in RUNTIME_KEYS if service.get(key) is not None},
275
+ "healthcheck": bool(service.get("healthcheck")),
276
+ "healthcheck_block": service.get("healthcheck")
277
+ if isinstance(service.get("healthcheck"), dict)
278
+ else None,
279
+ "depends_on": [str(item) for item in depends],
280
+ }
281
+ return result
282
+
283
+
223
284
  def _env_map(service: dict[str, Any]) -> list[tuple[str, str]]:
224
285
  env = service.get("environment")
225
286
  if isinstance(env, dict):
@@ -18,6 +18,34 @@ def test_good_package_has_no_failures(good_package: Path) -> None:
18
18
  assert report.ok, report.render()
19
19
 
20
20
 
21
+ def test_a_boolean_healthcheck_is_a_failure(tmp_path: Path) -> None:
22
+ """C005: `healthcheck: true` makes Docker refuse to parse the file."""
23
+ root = write_package(
24
+ tmp_path / "demo",
25
+ compose=COMPOSE.replace(
26
+ " labels:\n createdBy: \"Apps\"\n",
27
+ " healthcheck: true\n labels:\n createdBy: \"Apps\"\n",
28
+ ),
29
+ )
30
+ report = run_all(load_package(root))
31
+ assert "C005" in {issue.rule_id for issue in report.failures}
32
+ assert not report.ok
33
+
34
+
35
+ def test_a_healthcheck_mapping_passes_the_check(tmp_path: Path) -> None:
36
+ root = write_package(
37
+ tmp_path / "demo",
38
+ compose=COMPOSE.replace(
39
+ " labels:\n createdBy: \"Apps\"\n",
40
+ " healthcheck:\n"
41
+ " test: [\"CMD\", \"wget\", \"-qO-\", \"http://localhost\"]\n"
42
+ " interval: 30s\n"
43
+ " labels:\n createdBy: \"Apps\"\n",
44
+ ),
45
+ )
46
+ assert "C005" not in rules_hit(root)
47
+
48
+
21
49
  def test_top_level_form_fields_fail(tmp_path: Path) -> None:
22
50
  root = write_package(
23
51
  tmp_path / "demo",
@@ -199,6 +199,51 @@ def test_falsy_extras_are_not_written(tmp_path: Path) -> None:
199
199
  assert "healthcheck" not in text
200
200
 
201
201
 
202
+ def test_a_true_healthcheck_flag_is_dropped_with_a_warning(tmp_path: Path) -> None:
203
+ """`healthcheck: true` is not a definition; Docker refuses to parse it."""
204
+ spec = dict(SPEC)
205
+ spec["services"] = [dict(SPEC["services"][0], healthcheck=True)]
206
+ result = generate(spec, tmp_path)
207
+ text = (result.root / "1.4.2" / "docker-compose.yml").read_text(encoding="utf-8")
208
+ assert "healthcheck" not in text
209
+ assert any("healthcheck 不是映射" in warning for warning in result.warnings)
210
+
211
+
212
+ def test_a_healthcheck_mapping_is_written_through(tmp_path: Path) -> None:
213
+ healthcheck = {"test": ["CMD", "curl", "-f", "http://localhost/health"], "interval": "30s"}
214
+ spec = dict(SPEC)
215
+ spec["services"] = [dict(SPEC["services"][0], healthcheck=healthcheck)]
216
+ result = generate(spec, tmp_path)
217
+ text = (result.root / "1.4.2" / "docker-compose.yml").read_text(encoding="utf-8")
218
+ assert "healthcheck:" in text and "curl" in text
219
+
220
+
221
+ def test_a_multi_service_spec_generates_a_valid_package(tmp_path: Path) -> None:
222
+ spec = dict(SPEC)
223
+ spec["services"] = [
224
+ SPEC["services"][0],
225
+ {
226
+ "name": "db",
227
+ "image": "postgres:16-alpine",
228
+ "container_port": 5432,
229
+ "depends_on": [],
230
+ "env": {"POSTGRES_PASSWORD": ""},
231
+ "healthcheck": {
232
+ "test": ["CMD-SHELL", "pg_isready -U postgres"],
233
+ "interval": "10s",
234
+ },
235
+ },
236
+ ]
237
+ result = generate(spec, tmp_path)
238
+ text = (result.root / "1.4.2" / "docker-compose.yml").read_text(encoding="utf-8")
239
+ assert '"${CONTAINER_NAME}-db"' in text
240
+ assert '"postgres:16-alpine"' in text
241
+ assert "${PANEL_APP_PORT_1}:5432" in text
242
+ assert result.exposed[:2] == ["PANEL_APP_PORT_HTTP", "PANEL_APP_PORT_1"]
243
+ report = run_all(load_package(result.root))
244
+ assert report.ok, report.render()
245
+
246
+
202
247
  def test_load_spec_reports_bad_json(tmp_path: Path) -> None:
203
248
  path = tmp_path / "bad.json"
204
249
  path.write_text("{not json", encoding="utf-8")
@@ -267,6 +267,102 @@ def test_local_checkout_never_names_the_product_after_its_path(tmp_path: Path) -
267
267
  assert "\\\\" not in spec["name"] and "tmp" not in spec["key"]
268
268
 
269
269
 
270
+ def test_each_service_keeps_its_own_image_port_and_env(tmp_path: Path) -> None:
271
+ """A bundled database must not lend its image, port or password to the app."""
272
+ root = tmp_path / "repo"
273
+ root.mkdir()
274
+ (root / "Dockerfile").write_text(
275
+ "FROM python:3.13-slim\n"
276
+ "ENV APP_TOKEN=\n"
277
+ "EXPOSE 8080\n",
278
+ encoding="utf-8",
279
+ )
280
+ (root / "docker-compose.yml").write_text(
281
+ "services:\n"
282
+ " app:\n"
283
+ " image: example/app:1.0\n"
284
+ " environment:\n"
285
+ " - APP_MODE=prod\n"
286
+ " ports:\n"
287
+ ' - "8080:8080"\n'
288
+ " depends_on:\n"
289
+ " - db\n"
290
+ " db:\n"
291
+ " image: postgres:16-alpine\n"
292
+ " environment:\n"
293
+ " - POSTGRES_PASSWORD=secret\n"
294
+ " ports:\n"
295
+ ' - "5432:5432"\n',
296
+ encoding="utf-8",
297
+ )
298
+ spec = resolve(str(root)).spec
299
+ by_name = {item["name"]: item for item in spec["services"]}
300
+ assert list(by_name) == ["app", "db"]
301
+
302
+ app = by_name["app"]
303
+ assert app["image"] == "example/app:1.0"
304
+ assert app["container_port"] == 8080
305
+ assert app["depends_on"] == ["db"]
306
+ # Dockerfile variables belong to the image the app runs.
307
+ assert "APP_TOKEN" in app["env"]
308
+ assert "APP_MODE" in app["env"]
309
+ # The sidecar's credentials must not leak into the app service.
310
+ assert "POSTGRES_PASSWORD" not in app["env"]
311
+
312
+ db = by_name["db"]
313
+ assert db["image"] == "postgres:16-alpine"
314
+ assert db["container_port"] == 5432
315
+ assert "POSTGRES_PASSWORD" in db["env"]
316
+ assert "APP_TOKEN" not in db["env"]
317
+
318
+
319
+ def test_a_sidecar_is_reported_with_the_panel_alternative(tmp_path: Path) -> None:
320
+ root = tmp_path / "repo"
321
+ root.mkdir()
322
+ (root / "docker-compose.yml").write_text(
323
+ "services:\n"
324
+ " app:\n"
325
+ " image: example/app:1.0\n"
326
+ " ports:\n"
327
+ ' - "8080:8080"\n'
328
+ " cache:\n"
329
+ " image: redis:7\n",
330
+ encoding="utf-8",
331
+ )
332
+ notes = resolve(str(root)).spec["notes"]
333
+ assert any("cache" in note and "dependencies" in note for note in notes)
334
+
335
+
336
+ def test_healthcheck_block_is_carried_but_a_bare_flag_is_only_a_note(
337
+ tmp_path: Path,
338
+ ) -> None:
339
+ root = tmp_path / "repo"
340
+ root.mkdir()
341
+ (root / "docker-compose.yml").write_text(
342
+ "services:\n"
343
+ " app:\n"
344
+ " image: example/app:1.0\n"
345
+ " healthcheck:\n"
346
+ ' test: ["CMD", "curl", "-f", "http://localhost/health"]\n'
347
+ " interval: 30s\n"
348
+ " flagged:\n"
349
+ " image: example/other:1.0\n"
350
+ " healthcheck: true\n",
351
+ encoding="utf-8",
352
+ )
353
+ spec = resolve(str(root)).spec
354
+ by_name = {item["name"]: item for item in spec["services"]}
355
+ assert by_name["app"]["healthcheck"]["test"] == [
356
+ "CMD",
357
+ "curl",
358
+ "-f",
359
+ "http://localhost/health",
360
+ ]
361
+ # `healthcheck: true` is not a definition; it must not reach the spec as one.
362
+ assert "healthcheck" not in by_name["flagged"]
363
+ assert any("flagged" in note and "healthcheck" in note for note in spec["notes"])
364
+
365
+
270
366
  def test_each_service_keeps_its_own_mounts(tmp_path: Path) -> None:
271
367
  """A sidecar's config mount must not be paired with the app's data host.
272
368
 
File without changes