1panel-toolkit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 1panel_toolkit-0.3.0.dist-info/METADATA +407 -0
- 1panel_toolkit-0.3.0.dist-info/RECORD +47 -0
- 1panel_toolkit-0.3.0.dist-info/WHEEL +4 -0
- 1panel_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
- 1panel_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- p1toolkit/__init__.py +3 -0
- p1toolkit/checks/__init__.py +21 -0
- p1toolkit/checks/compose.py +188 -0
- p1toolkit/checks/forms.py +129 -0
- p1toolkit/checks/hygiene.py +73 -0
- p1toolkit/checks/i18n.py +140 -0
- p1toolkit/checks/lessons.py +111 -0
- p1toolkit/checks/structure.py +140 -0
- p1toolkit/checks/substance.py +55 -0
- p1toolkit/cli.py +890 -0
- p1toolkit/config.py +114 -0
- p1toolkit/dependencies.py +218 -0
- p1toolkit/deploy.py +302 -0
- p1toolkit/depth.py +414 -0
- p1toolkit/errors.py +18 -0
- p1toolkit/evidence.py +523 -0
- p1toolkit/fix.py +394 -0
- p1toolkit/gen.py +728 -0
- p1toolkit/lifecycle.py +143 -0
- p1toolkit/loader.py +110 -0
- p1toolkit/logo.py +174 -0
- p1toolkit/model.py +71 -0
- p1toolkit/net.py +81 -0
- p1toolkit/panel.py +154 -0
- p1toolkit/patterns/__init__.py +250 -0
- p1toolkit/patterns/catalog.py +271 -0
- p1toolkit/patterns/data/patterns.json +724 -0
- p1toolkit/patterns/extract.py +402 -0
- p1toolkit/remote.py +554 -0
- p1toolkit/report.py +96 -0
- p1toolkit/resolve/__init__.py +554 -0
- p1toolkit/resolve/analyze.py +329 -0
- p1toolkit/resolve/sources.py +306 -0
- p1toolkit/rules/__init__.py +99 -0
- p1toolkit/rules/data/decisions.yaml +132 -0
- p1toolkit/rules/data/dependencies.yaml +113 -0
- p1toolkit/rules/data/envkeys.yaml +19 -0
- p1toolkit/rules/data/form-design.yaml +59 -0
- p1toolkit/rules/data/i18n.yaml +354 -0
- p1toolkit/rules/data/lessons.yaml +84 -0
- p1toolkit/rules/data/store.yaml +171 -0
- p1toolkit/selftest.py +211 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""docker-compose.yml shape and its closure against the install form."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
from .. import rules
|
|
9
|
+
from ..model import Package, Version
|
|
10
|
+
from ..report import Report
|
|
11
|
+
|
|
12
|
+
# Deliberately does not require a closing brace: `${A:-${B}}` is legal Compose,
|
|
13
|
+
# and a strict pattern would swallow the inner reference.
|
|
14
|
+
ENV_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)([^}$]*)")
|
|
15
|
+
IMAGE_LINE = re.compile(r"^\s*image:\s*(.+?)\s*$")
|
|
16
|
+
PORT_LINE = re.compile(r"^\s*-\s*[\"']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}([^\"']*)[\"']?:")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def env_refs(value: Any) -> Iterator[tuple[str, str]]:
|
|
20
|
+
"""Yield ``(name, trailing)`` for every ``${NAME...}`` in a structure."""
|
|
21
|
+
if isinstance(value, str):
|
|
22
|
+
for match in ENV_REF.finditer(value):
|
|
23
|
+
yield match.group(1), match.group(2)
|
|
24
|
+
elif isinstance(value, dict):
|
|
25
|
+
for item in value.values():
|
|
26
|
+
yield from env_refs(item)
|
|
27
|
+
elif isinstance(value, list):
|
|
28
|
+
for item in value:
|
|
29
|
+
yield from env_refs(item)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def declared_envkeys(version: Version) -> set[str]:
|
|
33
|
+
keys: set[str] = set()
|
|
34
|
+
for field in version.form_fields:
|
|
35
|
+
if field.get("envKey"):
|
|
36
|
+
keys.add(str(field["envKey"]))
|
|
37
|
+
child = field.get("child")
|
|
38
|
+
if isinstance(child, dict) and child.get("envKey"):
|
|
39
|
+
keys.add(str(child["envKey"]))
|
|
40
|
+
return keys
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(package: Package, report: Report, strict: bool = False) -> None:
|
|
44
|
+
store = rules.store()
|
|
45
|
+
implicit = rules.implicit_envkeys()
|
|
46
|
+
generic = set(store.get("generic_service_names") or [])
|
|
47
|
+
for version in package.versions:
|
|
48
|
+
if not version.compose:
|
|
49
|
+
continue
|
|
50
|
+
name = f"{version.name}/docker-compose.yml"
|
|
51
|
+
if store.get("compose_forbids_top_level_version") and "version" in version.compose:
|
|
52
|
+
report.fail(
|
|
53
|
+
"C001",
|
|
54
|
+
"top-level version: is deprecated in 1Panel compose files; remove it",
|
|
55
|
+
name,
|
|
56
|
+
)
|
|
57
|
+
_check_images(version, report, name, strict)
|
|
58
|
+
_check_env_closure(version, report, name, implicit)
|
|
59
|
+
_check_services(version, report, name, generic)
|
|
60
|
+
_check_ports(version, report, name)
|
|
61
|
+
_check_networks(version, report, name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _check_images(version: Version, report: Report, name: str, strict: bool) -> None:
|
|
65
|
+
for number, line in enumerate(version.compose_text.splitlines(), start=1):
|
|
66
|
+
match = IMAGE_LINE.match(line)
|
|
67
|
+
if not match:
|
|
68
|
+
continue
|
|
69
|
+
value = match.group(1)
|
|
70
|
+
if not (value.startswith('"') and value.endswith('"')) and not (
|
|
71
|
+
value.startswith("'") and value.endswith("'")
|
|
72
|
+
):
|
|
73
|
+
text = "image should be double-quoted to avoid YAML/digest parsing edge cases"
|
|
74
|
+
(report.fail if strict else report.warn)("C002", text, name, number)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _check_env_closure(
|
|
78
|
+
version: Version, report: Report, name: str, implicit: set[str]
|
|
79
|
+
) -> None:
|
|
80
|
+
declared = declared_envkeys(version)
|
|
81
|
+
seen: dict[str, str] = {}
|
|
82
|
+
for ref, trailing in env_refs(version.compose):
|
|
83
|
+
seen.setdefault(ref, trailing)
|
|
84
|
+
for ref, trailing in sorted(seen.items()):
|
|
85
|
+
if ref in declared or ref in implicit:
|
|
86
|
+
continue
|
|
87
|
+
if trailing.startswith(":-"):
|
|
88
|
+
# Deliberate: the generator keeps non-decision variables out of the
|
|
89
|
+
# install form and gives them a working default in the compose.
|
|
90
|
+
report.info(
|
|
91
|
+
"C003",
|
|
92
|
+
f"${{{ref}}} has no matching formFields envKey (a compose default is present)",
|
|
93
|
+
name,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
report.fail(
|
|
97
|
+
"C004",
|
|
98
|
+
f"${{{ref}}} has no matching formFields envKey and no compose default",
|
|
99
|
+
name,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_services(
|
|
104
|
+
version: Version, report: Report, name: str, generic: set[str]
|
|
105
|
+
) -> None:
|
|
106
|
+
expect_label = rules.store().get("service_label_created_by")
|
|
107
|
+
for service_name, service in version.services.items():
|
|
108
|
+
if not isinstance(service, dict):
|
|
109
|
+
continue
|
|
110
|
+
if service.get("container_name") != "${CONTAINER_NAME}" and not str(
|
|
111
|
+
service.get("container_name") or ""
|
|
112
|
+
).startswith("${CONTAINER_NAME}"):
|
|
113
|
+
report.warn(
|
|
114
|
+
"C010",
|
|
115
|
+
f"service {service_name}: container_name should be ${{CONTAINER_NAME}}"
|
|
116
|
+
" (or ${{CONTAINER_NAME}}-<suffix>)",
|
|
117
|
+
name,
|
|
118
|
+
)
|
|
119
|
+
labels = service.get("labels") or {}
|
|
120
|
+
if isinstance(labels, list):
|
|
121
|
+
labels = dict(
|
|
122
|
+
item.split("=", 1) for item in labels if isinstance(item, str) and "=" in item
|
|
123
|
+
)
|
|
124
|
+
if expect_label and str(labels.get("createdBy")) != str(expect_label):
|
|
125
|
+
report.warn(
|
|
126
|
+
"C011",
|
|
127
|
+
f'service {service_name}: missing labels.createdBy: "{expect_label}"',
|
|
128
|
+
name,
|
|
129
|
+
)
|
|
130
|
+
networks = service.get("networks") or []
|
|
131
|
+
if isinstance(networks, dict):
|
|
132
|
+
networks = list(networks)
|
|
133
|
+
if service_name in generic and len(networks) > 1:
|
|
134
|
+
report.warn(
|
|
135
|
+
"C012",
|
|
136
|
+
f"service {service_name}: generic name on a shared network can shadow "
|
|
137
|
+
"a same-named service; use an app-prefixed name or an alias",
|
|
138
|
+
name,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_ports(version: Version, report: Report, name: str) -> None:
|
|
143
|
+
prefix = str(rules.store().get("port_envkey_prefix"))
|
|
144
|
+
for service_name, service in version.services.items():
|
|
145
|
+
if not isinstance(service, dict):
|
|
146
|
+
continue
|
|
147
|
+
for mapping in service.get("ports") or []:
|
|
148
|
+
if not isinstance(mapping, str) or not mapping.startswith("${"):
|
|
149
|
+
continue
|
|
150
|
+
match = PORT_LINE.match(f" - {mapping}:")
|
|
151
|
+
if not match:
|
|
152
|
+
continue
|
|
153
|
+
env_key = match.group(1)
|
|
154
|
+
if not env_key.startswith(prefix):
|
|
155
|
+
report.warn(
|
|
156
|
+
"C020",
|
|
157
|
+
f"service {service_name}: published port uses {env_key}; 1Panel only "
|
|
158
|
+
f"tracks occupancy for {prefix}* envKeys",
|
|
159
|
+
name,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_networks(version: Version, report: Report, name: str) -> None:
|
|
164
|
+
networks = version.compose.get("networks") or {}
|
|
165
|
+
external = False
|
|
166
|
+
if isinstance(networks, dict):
|
|
167
|
+
for config in networks.values():
|
|
168
|
+
if isinstance(config, dict) and config.get("external"):
|
|
169
|
+
external = True
|
|
170
|
+
elif config is None:
|
|
171
|
+
continue
|
|
172
|
+
published = any(
|
|
173
|
+
isinstance(service, dict) and service.get("ports")
|
|
174
|
+
for service in version.services.values()
|
|
175
|
+
)
|
|
176
|
+
# `version.services.get("network_mode")` would look up a *service named*
|
|
177
|
+
# "network_mode"; the question is whether any service declares it.
|
|
178
|
+
uses_host_network = any(
|
|
179
|
+
isinstance(service, dict) and service.get("network_mode")
|
|
180
|
+
for service in version.services.values()
|
|
181
|
+
)
|
|
182
|
+
if published and not external and not uses_host_network:
|
|
183
|
+
report.warn(
|
|
184
|
+
"C030",
|
|
185
|
+
"no external network declared; a bridge-type app normally joins one so it "
|
|
186
|
+
"can be reached through 1Panel's entry",
|
|
187
|
+
name,
|
|
188
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Install-form design: keep the form a decision list, not a config dump."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
from .. import rules
|
|
8
|
+
from ..model import Package
|
|
9
|
+
from ..report import Report
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _budget() -> dict:
|
|
13
|
+
return rules.load("form-design").get("budget") or {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_secret(env_key: str) -> bool:
|
|
17
|
+
fragments = rules.load("form-design").get("secret_fragments") or []
|
|
18
|
+
upper = env_key.upper()
|
|
19
|
+
return any(fragment in upper for fragment in fragments)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prefix(env_key: str) -> str:
|
|
23
|
+
return env_key.rsplit("_", 1)[0] if "_" in env_key else env_key
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(package: Package, report: Report, strict: bool = False) -> None:
|
|
27
|
+
budget = _budget()
|
|
28
|
+
warn_at = int(budget.get("fields_warn") or 11)
|
|
29
|
+
fail_at = int(budget.get("fields_fail") or 20)
|
|
30
|
+
min_ratio = float(budget.get("min_required_ratio") or 0.6)
|
|
31
|
+
max_per_prefix = int(budget.get("max_fields_per_prefix") or 5)
|
|
32
|
+
|
|
33
|
+
for version in package.versions:
|
|
34
|
+
fields = version.form_fields
|
|
35
|
+
if not fields:
|
|
36
|
+
continue
|
|
37
|
+
label = f"{version.name}/data.yml"
|
|
38
|
+
total = len(fields)
|
|
39
|
+
required = [f for f in fields if f.get("required")]
|
|
40
|
+
ratio = len(required) / total
|
|
41
|
+
|
|
42
|
+
if total > fail_at:
|
|
43
|
+
report.fail(
|
|
44
|
+
"F001",
|
|
45
|
+
f"安装表单有 {total} 个字段(官方中位数 {budget.get('fields_median_official')},"
|
|
46
|
+
f"上限 {fail_at})。请把上游变量写进 compose 默认值,只暴露用户必须决定的项",
|
|
47
|
+
label,
|
|
48
|
+
)
|
|
49
|
+
elif total > warn_at:
|
|
50
|
+
report.warn(
|
|
51
|
+
"F001",
|
|
52
|
+
f"安装表单有 {total} 个字段,超过官方 p90({warn_at})。"
|
|
53
|
+
"确认每一项都是用户必须决定的,否则收进默认值",
|
|
54
|
+
label,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if total >= 6 and ratio < min_ratio:
|
|
58
|
+
report.warn(
|
|
59
|
+
"F002",
|
|
60
|
+
f"只有 {len(required)}/{total} 个字段标为必填({ratio:.0%},官方均值 88%);"
|
|
61
|
+
"必填/可选混在一起会让用户不知道该填哪个",
|
|
62
|
+
label,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
for field in fields:
|
|
66
|
+
env_key = str(field.get("envKey") or "")
|
|
67
|
+
default = str(field.get("default") or "").strip()
|
|
68
|
+
secret = is_secret(env_key)
|
|
69
|
+
if field.get("required") and not default and not secret:
|
|
70
|
+
report.warn(
|
|
71
|
+
"F003",
|
|
72
|
+
f"{env_key} 标了必填却没有默认值,而且不像密钥字段;"
|
|
73
|
+
"要么给默认值,要么降为可选",
|
|
74
|
+
label,
|
|
75
|
+
)
|
|
76
|
+
if not default and not secret and field.get("type") != "password":
|
|
77
|
+
report.warn(
|
|
78
|
+
"F004",
|
|
79
|
+
f"{env_key} 默认值为空;非密钥字段应当有可用默认值,"
|
|
80
|
+
"否则用户会卡在一个他无从判断的输入上",
|
|
81
|
+
label,
|
|
82
|
+
)
|
|
83
|
+
if secret and field.get("type") != "password":
|
|
84
|
+
report.warn(
|
|
85
|
+
"F005",
|
|
86
|
+
f"{env_key} 看起来是密钥,但 type 是 {field.get('type')!r};"
|
|
87
|
+
"密钥字段应为 password",
|
|
88
|
+
label,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
clusters = Counter(
|
|
92
|
+
_prefix(str(f.get("envKey") or "")) for f in fields if f.get("envKey")
|
|
93
|
+
)
|
|
94
|
+
for prefix, count in clusters.most_common():
|
|
95
|
+
if count > max_per_prefix:
|
|
96
|
+
report.warn(
|
|
97
|
+
"F006",
|
|
98
|
+
f"{prefix}_* 有 {count} 个字段(官方仅 27/1617 个版本超过 5 个);"
|
|
99
|
+
"这类「提供商矩阵」应折叠成一个 select + 默认值",
|
|
100
|
+
label,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_check_fields_are_wired(version, report, label)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Panel-provided variables are consumed by the platform, not by the compose text.
|
|
107
|
+
_PLATFORM_PREFIXES = ("PANEL_DB_", "PANEL_REDIS_", "REDIS_", "PANEL_APP_PORT_")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_fields_are_wired(version, report: Report, label: str) -> None:
|
|
111
|
+
"""A form field nobody reads is a box the user fills in for nothing."""
|
|
112
|
+
haystack = version.compose_text
|
|
113
|
+
for name, path in version.files.items():
|
|
114
|
+
if name.startswith("scripts/") or name.endswith((".sh", ".conf", ".yaml", ".json")):
|
|
115
|
+
try:
|
|
116
|
+
haystack += "\n" + path.read_text(encoding="utf-8-sig", errors="replace")
|
|
117
|
+
except OSError:
|
|
118
|
+
continue
|
|
119
|
+
for field in version.form_fields:
|
|
120
|
+
env_key = str(field.get("envKey") or "")
|
|
121
|
+
if not env_key or env_key.startswith(_PLATFORM_PREFIXES):
|
|
122
|
+
continue
|
|
123
|
+
if env_key not in haystack:
|
|
124
|
+
report.warn(
|
|
125
|
+
"F007",
|
|
126
|
+
f"{env_key} 出现在安装表单里,但 compose 和脚本里都没有引用它;"
|
|
127
|
+
"填了也不会生效,要么接线要么移除",
|
|
128
|
+
label,
|
|
129
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""File hygiene: line endings, README shape, .env.sample closure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..loader import line_ending_issues
|
|
6
|
+
from ..model import Package
|
|
7
|
+
from ..report import Report
|
|
8
|
+
from .compose import env_refs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(package: Package, report: Report, strict: bool = False) -> None:
|
|
12
|
+
_check_text_files(package, report)
|
|
13
|
+
_check_readme(package, report)
|
|
14
|
+
_check_env_sample(package, report)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_text_files(package: Package, report: Report) -> None:
|
|
18
|
+
for rel, path in package.files.items():
|
|
19
|
+
problem = line_ending_issues(path)
|
|
20
|
+
if problem:
|
|
21
|
+
report.warn(
|
|
22
|
+
"H001",
|
|
23
|
+
f"{problem}; store files should be LF without BOM",
|
|
24
|
+
rel,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_readme(package: Package, report: Report) -> None:
|
|
29
|
+
readme = package.root / "README.md"
|
|
30
|
+
if not readme.exists():
|
|
31
|
+
report.warn("H010", "README.md is missing", "README.md")
|
|
32
|
+
return
|
|
33
|
+
text = readme.read_text(encoding="utf-8-sig", errors="replace")
|
|
34
|
+
if "## 产品介绍" not in text:
|
|
35
|
+
report.warn(
|
|
36
|
+
"H011", "README.md should follow the store style with a 产品介绍 section", "README.md"
|
|
37
|
+
)
|
|
38
|
+
if "## 主要功能" not in text and "## 访问说明" not in text:
|
|
39
|
+
report.info(
|
|
40
|
+
"H012",
|
|
41
|
+
"README.md has neither 主要功能 nor 访问说明; store READMEs usually include one",
|
|
42
|
+
"README.md",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_env_sample(package: Package, report: Report) -> None:
|
|
47
|
+
for version in package.versions:
|
|
48
|
+
if not version.compose:
|
|
49
|
+
continue
|
|
50
|
+
referenced = {name for name, _ in env_refs(version.compose)}
|
|
51
|
+
if not referenced:
|
|
52
|
+
continue
|
|
53
|
+
sample = version.env_sample
|
|
54
|
+
if not sample:
|
|
55
|
+
report.warn(
|
|
56
|
+
"H020",
|
|
57
|
+
f"{version.name}/.env.sample is missing; standalone compose users need it",
|
|
58
|
+
f"{version.name}/.env.sample",
|
|
59
|
+
)
|
|
60
|
+
continue
|
|
61
|
+
present = {
|
|
62
|
+
line.split("=", 1)[0].strip()
|
|
63
|
+
for line in sample.splitlines()
|
|
64
|
+
if "=" in line and not line.lstrip().startswith("#")
|
|
65
|
+
}
|
|
66
|
+
missing = sorted(referenced - present)
|
|
67
|
+
if missing:
|
|
68
|
+
report.warn(
|
|
69
|
+
"H021",
|
|
70
|
+
f"{version.name}/.env.sample does not list: {missing}",
|
|
71
|
+
f"{version.name}/.env.sample",
|
|
72
|
+
)
|
|
73
|
+
|
p1toolkit/checks/i18n.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Translation coverage and label shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .. import rules
|
|
9
|
+
from ..model import Package
|
|
10
|
+
from ..report import Report
|
|
11
|
+
|
|
12
|
+
_BOOL_LABEL = re.compile(r"^\s*(?:-\s*)?label:\s*(true|false)\s*$", re.IGNORECASE)
|
|
13
|
+
_LABEL_LINE = re.compile(r"^\s*(?:-\s*)?label:\s*(\S+)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _missing(data: Any, languages: list[str]) -> list[str]:
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
return list(languages)
|
|
19
|
+
return [lang for lang in languages if not str(data.get(lang) or "").strip()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(package: Package, report: Report, strict: bool = False) -> None:
|
|
23
|
+
required = rules.required_languages()
|
|
24
|
+
all_langs = rules.all_languages()
|
|
25
|
+
additional = package.additional
|
|
26
|
+
|
|
27
|
+
top_description = package.app.get("description")
|
|
28
|
+
if isinstance(top_description, dict):
|
|
29
|
+
report.fail(
|
|
30
|
+
"I001",
|
|
31
|
+
"root data.yml description must be a single-line string, not a language map",
|
|
32
|
+
"data.yml",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
description = additional.get("description")
|
|
36
|
+
if description is None:
|
|
37
|
+
report.warn("I002", "additionalProperties.description is missing", "data.yml")
|
|
38
|
+
else:
|
|
39
|
+
missing = _missing(description, required)
|
|
40
|
+
if missing:
|
|
41
|
+
report.fail(
|
|
42
|
+
"I003",
|
|
43
|
+
f"additionalProperties.description is missing languages: {missing}",
|
|
44
|
+
"data.yml",
|
|
45
|
+
)
|
|
46
|
+
if isinstance(description, dict):
|
|
47
|
+
absent = [lang for lang in all_langs if lang not in description]
|
|
48
|
+
if absent:
|
|
49
|
+
report.warn(
|
|
50
|
+
"I004",
|
|
51
|
+
f"description does not use the store's full language set; missing {absent}",
|
|
52
|
+
"data.yml",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
title = package.app.get("title")
|
|
56
|
+
short_zh = additional.get("shortDescZh")
|
|
57
|
+
if title and short_zh:
|
|
58
|
+
# title and shortDescZh are both the store's one-liner slot and must
|
|
59
|
+
# agree; 741/771 official apps do. The top-level description is allowed
|
|
60
|
+
# to be a longer sentence — about 4% of shipped apps differ there — so
|
|
61
|
+
# that mismatch is reported without blocking.
|
|
62
|
+
if str(title).strip() != str(short_zh).strip():
|
|
63
|
+
report.fail(
|
|
64
|
+
"I005",
|
|
65
|
+
"root title and additionalProperties.shortDescZh must be the same "
|
|
66
|
+
f"short text; got title={title!r} shortDescZh={short_zh!r}",
|
|
67
|
+
"data.yml",
|
|
68
|
+
)
|
|
69
|
+
elif top_description and str(top_description).strip() != str(short_zh).strip():
|
|
70
|
+
report.warn(
|
|
71
|
+
"I006",
|
|
72
|
+
"top-level description differs from shortDescZh; the store mostly "
|
|
73
|
+
f"repeats the same sentence (got description={top_description!r})",
|
|
74
|
+
"data.yml",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for version in package.versions:
|
|
78
|
+
label = f"{version.name}/data.yml"
|
|
79
|
+
for field in version.form_fields:
|
|
80
|
+
env_key = field.get("envKey")
|
|
81
|
+
label_map = field.get("label")
|
|
82
|
+
if isinstance(label_map, dict):
|
|
83
|
+
missing = _missing(label_map, required)
|
|
84
|
+
if missing:
|
|
85
|
+
report.warn(
|
|
86
|
+
"I010",
|
|
87
|
+
f"{env_key}: label is missing languages {missing}",
|
|
88
|
+
label,
|
|
89
|
+
)
|
|
90
|
+
elif field.get("labelZh") or field.get("labelEn"):
|
|
91
|
+
report.warn(
|
|
92
|
+
"I011",
|
|
93
|
+
f"{env_key}: only labelZh/labelEn present; add the full label map",
|
|
94
|
+
label,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
report.warn(
|
|
98
|
+
"I012", f"{env_key}: no label or labelZh/labelEn", label
|
|
99
|
+
)
|
|
100
|
+
_check_values(env_key, field, report, label)
|
|
101
|
+
_check_bool_labels(version.files.get("data.yml"), report)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_values(env_key: Any, field: dict[str, Any], report: Report, label: str) -> None:
|
|
105
|
+
values = field.get("values")
|
|
106
|
+
if field.get("type") in {"select", "apps"} and not values:
|
|
107
|
+
report.fail(
|
|
108
|
+
"I013", f"{env_key}: type {field.get('type')!r} requires values", label
|
|
109
|
+
)
|
|
110
|
+
for item in values or []:
|
|
111
|
+
if not isinstance(item, dict):
|
|
112
|
+
continue
|
|
113
|
+
item_label = item.get("label")
|
|
114
|
+
if isinstance(item_label, dict):
|
|
115
|
+
report.fail(
|
|
116
|
+
"I014",
|
|
117
|
+
f"{env_key}: values[].label must be a plain string, not a language map "
|
|
118
|
+
"(1Panel fails to unmarshal it)",
|
|
119
|
+
label,
|
|
120
|
+
)
|
|
121
|
+
if item_label is None or item.get("value") is None:
|
|
122
|
+
report.fail(
|
|
123
|
+
"I015", f"{env_key}: each values[] entry needs both label and value", label
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_bool_labels(path, report: Report) -> None:
|
|
128
|
+
"""YAML would parse an unquoted `label: true` as a bool; catch it in the raw text."""
|
|
129
|
+
if path is None or not path.exists():
|
|
130
|
+
return
|
|
131
|
+
for number, line in enumerate(
|
|
132
|
+
path.read_text(encoding="utf-8-sig", errors="replace").splitlines(), start=1
|
|
133
|
+
):
|
|
134
|
+
if _BOOL_LABEL.match(line):
|
|
135
|
+
report.fail(
|
|
136
|
+
"I016",
|
|
137
|
+
"values[].label must be quoted when it is true/false",
|
|
138
|
+
path,
|
|
139
|
+
number,
|
|
140
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Pre-flight detectors derived from real packaging failures.
|
|
2
|
+
|
|
3
|
+
Each detector corresponds to an entry in ``rules/data/lessons.yaml``. They are
|
|
4
|
+
deliberately conservative: a false positive here costs a reviewer two minutes,
|
|
5
|
+
a missed one costs a broken install.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .. import rules
|
|
14
|
+
from ..model import Package, Version
|
|
15
|
+
from ..report import Report
|
|
16
|
+
|
|
17
|
+
_IGNORED_ARTIFACTS = {".gitkeep", ".keep", ".DS_Store"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _lesson(lesson_id: str) -> dict:
|
|
21
|
+
for entry in rules.lessons():
|
|
22
|
+
if entry.get("id") == lesson_id:
|
|
23
|
+
return entry
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(package: Package, report: Report, strict: bool = False) -> None:
|
|
28
|
+
for version in package.versions:
|
|
29
|
+
if not version.compose:
|
|
30
|
+
continue
|
|
31
|
+
_first_run_config_missing(package, version, report)
|
|
32
|
+
_dependency_without_healthcheck(version, report)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _bind_host_paths(version: Version) -> list[str]:
|
|
36
|
+
hosts: list[str] = []
|
|
37
|
+
for service in version.services.values():
|
|
38
|
+
if not isinstance(service, dict):
|
|
39
|
+
continue
|
|
40
|
+
for volume in service.get("volumes") or []:
|
|
41
|
+
if not isinstance(volume, str) or ":" not in volume:
|
|
42
|
+
continue
|
|
43
|
+
host = volume.split(":", 1)[0].strip()
|
|
44
|
+
if host.startswith("./") or host.startswith("../"):
|
|
45
|
+
hosts.append(host)
|
|
46
|
+
return hosts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_of(version: Version, env_key: str) -> str | None:
|
|
50
|
+
for field in version.form_fields:
|
|
51
|
+
if field.get("envKey") == env_key:
|
|
52
|
+
value = field.get("default")
|
|
53
|
+
return str(value) if value is not None else None
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_host(version: Version, host: str) -> Path | None:
|
|
58
|
+
"""Resolve a bind host path, substituting form defaults for ${VAR}."""
|
|
59
|
+
def substitute(match: re.Match[str]) -> str:
|
|
60
|
+
return _default_of(version, match.group(1)) or ""
|
|
61
|
+
|
|
62
|
+
resolved = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}", substitute, host)
|
|
63
|
+
if not resolved or "${" in resolved:
|
|
64
|
+
return None
|
|
65
|
+
return (version.dir / resolved).resolve()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _first_run_config_missing(package: Package, version: Version, report: Report) -> None:
|
|
69
|
+
hosts = _bind_host_paths(version)
|
|
70
|
+
if not hosts:
|
|
71
|
+
return
|
|
72
|
+
# version.files keys are relative to the version directory
|
|
73
|
+
has_init = "scripts/init.sh" in version.files
|
|
74
|
+
if has_init:
|
|
75
|
+
return
|
|
76
|
+
shipped = 0
|
|
77
|
+
for host in hosts:
|
|
78
|
+
target = _resolve_host(version, host)
|
|
79
|
+
if target is None or not target.is_dir():
|
|
80
|
+
continue
|
|
81
|
+
if any(
|
|
82
|
+
child.name not in _IGNORED_ARTIFACTS for child in target.rglob("*") if child.is_file()
|
|
83
|
+
):
|
|
84
|
+
shipped += 1
|
|
85
|
+
if shipped:
|
|
86
|
+
return
|
|
87
|
+
lesson = _lesson("L001")
|
|
88
|
+
report.warn(
|
|
89
|
+
"L001",
|
|
90
|
+
f"{lesson.get('title', 'first-run config may be missing')} — "
|
|
91
|
+
f"{version.name}/ mounts {sorted(set(hosts))} with no files and no scripts/init.sh; "
|
|
92
|
+
"if the app needs a config file there it will crash-loop on first start",
|
|
93
|
+
f"{version.name}/docker-compose.yml",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _dependency_without_healthcheck(version: Version, report: Report) -> None:
|
|
98
|
+
for service_name, service in version.services.items():
|
|
99
|
+
if not isinstance(service, dict):
|
|
100
|
+
continue
|
|
101
|
+
depends = service.get("depends_on") or {}
|
|
102
|
+
names = list(depends) if isinstance(depends, dict) else list(depends)
|
|
103
|
+
for dependency in names:
|
|
104
|
+
target = version.services.get(dependency)
|
|
105
|
+
if isinstance(target, dict) and not target.get("healthcheck"):
|
|
106
|
+
report.info(
|
|
107
|
+
"L002",
|
|
108
|
+
f"{service_name} depends_on {dependency}, which has no healthcheck; "
|
|
109
|
+
"compose only orders container start, not readiness",
|
|
110
|
+
f"{version.name}/docker-compose.yml",
|
|
111
|
+
)
|