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
p1toolkit/evidence.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Audit a generation spec against the decision ledger.
|
|
2
|
+
|
|
3
|
+
``p1 resolve`` answers the ten generation decisions from upstream evidence and
|
|
4
|
+
refuses to guess the blocking ones. The generator used to accept whatever spec
|
|
5
|
+
it was handed, so a draft carrying ``"status": "missing"`` still became a
|
|
6
|
+
package: it installs, then loses data on upgrade or never answers on its port.
|
|
7
|
+
|
|
8
|
+
This module carries resolve's discipline into ``p1 gen``. It reads the ledger
|
|
9
|
+
resolve wrote (``spec["decisions"]``), cross-checks it against what the spec is
|
|
10
|
+
about to generate, and reports findings per decision id. A blocking finding
|
|
11
|
+
stops the build unless the spec acknowledges the decision (``acknowledged``)
|
|
12
|
+
or the caller explicitly allows unresolved evidence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import rules
|
|
21
|
+
from .report import FAIL, INFO, WARN
|
|
22
|
+
|
|
23
|
+
EVIDENCED = "evidenced"
|
|
24
|
+
INFERRED = "inferred"
|
|
25
|
+
MISSING = "missing"
|
|
26
|
+
|
|
27
|
+
# Supplied by the generator or the panel, never by the application: these never
|
|
28
|
+
# need a D01/D02 evidence entry of their own.
|
|
29
|
+
GENERATED_ENV_KEYS = {"TZ", "CONTAINER_NAME"}
|
|
30
|
+
|
|
31
|
+
# The ledger itself, rather than one of the ten decisions.
|
|
32
|
+
LEDGER = "ledger"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Finding:
|
|
37
|
+
decision: str
|
|
38
|
+
level: str
|
|
39
|
+
message: str
|
|
40
|
+
hint: str = ""
|
|
41
|
+
|
|
42
|
+
def render(self) -> str:
|
|
43
|
+
line = f"{self.level.upper():4s} {self.decision:<6s} {self.message}"
|
|
44
|
+
if self.hint:
|
|
45
|
+
line += f"\n → {self.hint}"
|
|
46
|
+
return line
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Audit:
|
|
51
|
+
findings: list[Finding] = field(default_factory=list)
|
|
52
|
+
ledger: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
53
|
+
acknowledged: dict[str, str] = field(default_factory=dict)
|
|
54
|
+
allowed_unresolved: bool = False
|
|
55
|
+
|
|
56
|
+
def add(self, decision: str, level: str, message: str, hint: str = "") -> None:
|
|
57
|
+
self.findings.append(Finding(decision, level, message, hint))
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def failures(self) -> list[Finding]:
|
|
61
|
+
return [item for item in self.findings if item.level == FAIL]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def warnings(self) -> list[Finding]:
|
|
65
|
+
return [item for item in self.findings if item.level == WARN]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def ok(self) -> bool:
|
|
69
|
+
return not self.failures
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def blocked_decisions(self) -> list[str]:
|
|
73
|
+
return list(dict.fromkeys(item.decision for item in self.failures))
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"ok": self.ok,
|
|
78
|
+
"has_ledger": bool(self.ledger),
|
|
79
|
+
"allowed_unresolved": self.allowed_unresolved,
|
|
80
|
+
"acknowledged": dict(self.acknowledged),
|
|
81
|
+
"decisions": {
|
|
82
|
+
key: {
|
|
83
|
+
"status": value.get("status"),
|
|
84
|
+
"question": value.get("question"),
|
|
85
|
+
"answer": value.get("answer"),
|
|
86
|
+
}
|
|
87
|
+
for key, value in sorted(self.ledger.items())
|
|
88
|
+
},
|
|
89
|
+
"findings": [asdict(item) for item in self.findings],
|
|
90
|
+
"counts": {
|
|
91
|
+
"fail": len(self.failures),
|
|
92
|
+
"warn": len(self.warnings),
|
|
93
|
+
"info": len([i for i in self.findings if i.level == INFO]),
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def render(self, indent: str = " ") -> str:
|
|
98
|
+
if not self.findings:
|
|
99
|
+
return f"{indent}OK 没有需要人工确认的证据缺口"
|
|
100
|
+
ordered = sorted(self.findings, key=lambda item: (item.level != FAIL, item.decision))
|
|
101
|
+
lines: list[str] = []
|
|
102
|
+
for item in ordered:
|
|
103
|
+
lines.extend(indent + row for row in item.render().splitlines())
|
|
104
|
+
counts = self.to_dict()["counts"]
|
|
105
|
+
lines.append(
|
|
106
|
+
f"{indent}-> 证据审计: {counts['fail']} fail, {counts['warn']} warn, "
|
|
107
|
+
f"{counts['info']} info"
|
|
108
|
+
)
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
def violations(self) -> str:
|
|
112
|
+
"""The message that stops the build, one line per blocking decision."""
|
|
113
|
+
lines = [
|
|
114
|
+
"生成前证据审计没有通过,以下决策缺证据或与 spec 不一致;",
|
|
115
|
+
"猜一个默认值会让包装上就出问题,所以这里停下来:",
|
|
116
|
+
"",
|
|
117
|
+
]
|
|
118
|
+
for item in self.failures:
|
|
119
|
+
lines.append(item.render())
|
|
120
|
+
lines += [
|
|
121
|
+
"",
|
|
122
|
+
"三条出路:补上游证据后重跑 `p1 resolve`;把答案写进 spec;",
|
|
123
|
+
'或者在 spec 里写 "acknowledged": {"D03": "为什么可以不挂载"} 明确承担这个决定。',
|
|
124
|
+
"确知自己在做什么时,也可以用 `p1 gen --allow-unresolved` 强行生成。",
|
|
125
|
+
]
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
# ledger helpers
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ledger_of(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
135
|
+
raw = spec.get("decisions")
|
|
136
|
+
if not isinstance(raw, dict):
|
|
137
|
+
return {}
|
|
138
|
+
return {
|
|
139
|
+
str(key).upper(): value
|
|
140
|
+
for key, value in raw.items()
|
|
141
|
+
if isinstance(value, dict)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _answer_values(entry: dict[str, Any] | None) -> list[str]:
|
|
146
|
+
"""Split a ledger answer back into its values.
|
|
147
|
+
|
|
148
|
+
``resolve`` joins them with ", " and truncates long lists with "…(共 N 项)".
|
|
149
|
+
"""
|
|
150
|
+
if not entry:
|
|
151
|
+
return []
|
|
152
|
+
answer = str(entry.get("answer") or "").split("…")[0]
|
|
153
|
+
return [part.strip() for part in answer.split(",") if part.strip()]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _status(ledger: dict[str, dict[str, Any]], decision: str) -> str:
|
|
157
|
+
entry = ledger.get(decision) or {}
|
|
158
|
+
return str(entry.get("status") or "").lower()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _answer_text(entry: dict[str, Any] | None) -> str:
|
|
162
|
+
return str((entry or {}).get("answer") or "—")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _image_repo(image: str) -> str:
|
|
166
|
+
"""``docker.io/library/nginx:1.27-alpine`` -> ``nginx``."""
|
|
167
|
+
value = str(image).strip().split("@", 1)[0]
|
|
168
|
+
head, _, tail = value.rpartition("/")
|
|
169
|
+
last = tail.split(":", 1)[0]
|
|
170
|
+
value = f"{head}/{last}" if head else last
|
|
171
|
+
parts = value.split("/")
|
|
172
|
+
if len(parts) > 1 and ("." in parts[0] or ":" in parts[0] or parts[0] == "localhost"):
|
|
173
|
+
parts = parts[1:]
|
|
174
|
+
if len(parts) > 1 and parts[0] == "library":
|
|
175
|
+
parts = parts[1:]
|
|
176
|
+
return "/".join(parts).lower()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _mount_targets(service: dict[str, Any]) -> list[str]:
|
|
180
|
+
mounts = service.get("mounts")
|
|
181
|
+
if isinstance(mounts, list) and mounts:
|
|
182
|
+
targets = [
|
|
183
|
+
str(item["target"])
|
|
184
|
+
for item in mounts
|
|
185
|
+
if isinstance(item, dict) and item.get("target")
|
|
186
|
+
]
|
|
187
|
+
if targets:
|
|
188
|
+
return targets
|
|
189
|
+
if service.get("mount_targets"):
|
|
190
|
+
return [str(item) for item in service["mount_targets"]]
|
|
191
|
+
if service.get("mount_target"):
|
|
192
|
+
return [str(service["mount_target"])]
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _mount_host(service: dict[str, Any]) -> str:
|
|
197
|
+
host = service.get("mount_host")
|
|
198
|
+
if host:
|
|
199
|
+
return str(host)
|
|
200
|
+
hosts = service.get("mount_hosts")
|
|
201
|
+
if isinstance(hosts, list) and hosts:
|
|
202
|
+
return str(hosts[0])
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------------------
|
|
207
|
+
# checks
|
|
208
|
+
# --------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _audit_ledger(audit: Audit) -> None:
|
|
212
|
+
if not audit.ledger:
|
|
213
|
+
audit.add(
|
|
214
|
+
LEDGER,
|
|
215
|
+
INFO,
|
|
216
|
+
"spec 没有 decisions 账本(不是 `p1 resolve` 产出的),只做结构性检查",
|
|
217
|
+
"用 `p1 resolve <仓库> --out <目录>` 生成带证据的 draft-spec.json 更稳妥",
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
for definition in rules.decisions():
|
|
222
|
+
decision_id = str(definition.get("id") or "")
|
|
223
|
+
question = str(definition.get("question") or "")
|
|
224
|
+
blocking = bool(definition.get("blocking"))
|
|
225
|
+
entry = audit.ledger.get(decision_id)
|
|
226
|
+
if entry is None:
|
|
227
|
+
audit.add(decision_id, WARN, f"账本里没有 {decision_id} {question}")
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
status = str(entry.get("status") or "").lower()
|
|
231
|
+
reason = audit.acknowledged.get(decision_id)
|
|
232
|
+
if status == MISSING:
|
|
233
|
+
if reason:
|
|
234
|
+
audit.add(
|
|
235
|
+
decision_id,
|
|
236
|
+
INFO,
|
|
237
|
+
f"{decision_id} {question} 无上游证据,已按 spec 的 acknowledged 继续:{reason}",
|
|
238
|
+
)
|
|
239
|
+
elif blocking:
|
|
240
|
+
audit.add(
|
|
241
|
+
decision_id,
|
|
242
|
+
FAIL,
|
|
243
|
+
f"{decision_id} {question} 没有上游证据",
|
|
244
|
+
f"补证据,或在 spec 里写 \"acknowledged\": "
|
|
245
|
+
f"{{\"{decision_id}\": \"理由\"}};{definition.get('if_unknown') or ''}",
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
audit.add(
|
|
249
|
+
decision_id,
|
|
250
|
+
WARN,
|
|
251
|
+
f"{decision_id} {question} 无上游证据,"
|
|
252
|
+
f"按 if_unknown 处理:{definition.get('if_unknown') or '—'}",
|
|
253
|
+
)
|
|
254
|
+
elif status == INFERRED:
|
|
255
|
+
answer = entry.get("answer") or "—"
|
|
256
|
+
if blocking:
|
|
257
|
+
audit.add(
|
|
258
|
+
decision_id,
|
|
259
|
+
WARN,
|
|
260
|
+
f"{decision_id} {question} 只有间接证据({answer}),生成前请人工确认",
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
audit.add(
|
|
264
|
+
decision_id,
|
|
265
|
+
INFO,
|
|
266
|
+
f"{decision_id} {question} 为间接证据:{answer}",
|
|
267
|
+
)
|
|
268
|
+
elif status != EVIDENCED:
|
|
269
|
+
audit.add(
|
|
270
|
+
decision_id,
|
|
271
|
+
WARN,
|
|
272
|
+
f"账本里的 {decision_id}.status={entry.get('status')!r} 不是 "
|
|
273
|
+
"evidenced / inferred / missing 之一",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _audit_services(audit: Audit, spec: dict[str, Any]) -> None:
|
|
278
|
+
services = spec.get("services")
|
|
279
|
+
if not isinstance(services, list) or not services:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
d03 = _answer_values(audit.ledger.get("D03"))
|
|
283
|
+
d05 = _answer_values(audit.ledger.get("D05"))
|
|
284
|
+
d06 = _answer_values(audit.ledger.get("D06"))
|
|
285
|
+
|
|
286
|
+
for index, service in enumerate(services):
|
|
287
|
+
if not isinstance(service, dict):
|
|
288
|
+
continue
|
|
289
|
+
name = str(service.get("name") or f"services[{index}]")
|
|
290
|
+
image = str(service.get("image") or "").strip()
|
|
291
|
+
targets = _mount_targets(service)
|
|
292
|
+
host = _mount_host(service)
|
|
293
|
+
port = service.get("container_port")
|
|
294
|
+
|
|
295
|
+
# D06 — the image, and whether it still matches its evidence.
|
|
296
|
+
if not image:
|
|
297
|
+
# The ledger check may already have failed D06; say it once, then
|
|
298
|
+
# keep this as context instead of a second identical failure.
|
|
299
|
+
already_blocked = any(
|
|
300
|
+
item.decision == "D06" and item.level == FAIL for item in audit.findings
|
|
301
|
+
)
|
|
302
|
+
audit.add(
|
|
303
|
+
"D06",
|
|
304
|
+
INFO if already_blocked else FAIL,
|
|
305
|
+
f"{name}: 没有 image",
|
|
306
|
+
"镜像必须来自上游证据(README 的 docker run / 官方镜像页)",
|
|
307
|
+
)
|
|
308
|
+
elif d06 and _image_repo(image) not in {_image_repo(item) for item in d06}:
|
|
309
|
+
audit.add(
|
|
310
|
+
"D06",
|
|
311
|
+
WARN if not audit.acknowledged.get("D06") else INFO,
|
|
312
|
+
f"{name}: spec 的镜像 {image} 与 D06 证据({', '.join(d06)})不一致",
|
|
313
|
+
"确认是有意换镜像;第三方镜像要在 README 里写明来源与风险",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# D03 — a host path without a container path silently drops persistence.
|
|
317
|
+
if targets:
|
|
318
|
+
if d03:
|
|
319
|
+
unknown = [item for item in targets if item not in d03]
|
|
320
|
+
if unknown:
|
|
321
|
+
audit.add(
|
|
322
|
+
"D03",
|
|
323
|
+
WARN,
|
|
324
|
+
f"{name}: 挂载点 {unknown} 不在 D03 证据({', '.join(d03)})里",
|
|
325
|
+
"每个挂载点都要能指回上游 VOLUME / 官方 compose",
|
|
326
|
+
)
|
|
327
|
+
elif host:
|
|
328
|
+
if audit.acknowledged.get("D03"):
|
|
329
|
+
audit.add(
|
|
330
|
+
"D03",
|
|
331
|
+
INFO,
|
|
332
|
+
f"{name}: 声明了 mount_host 但没有容器内路径,"
|
|
333
|
+
f"已按 acknowledged 当作无持久化处理",
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
audit.add(
|
|
337
|
+
"D03",
|
|
338
|
+
FAIL,
|
|
339
|
+
f"{name}: 声明了 mount_host({host})却没有容器内挂载路径",
|
|
340
|
+
"在 mounts / mount_targets 里写出 target;"
|
|
341
|
+
"如果这个应用确实不持久化数据,删掉 mount_host 并确认 D03",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# D05 — the container port.
|
|
345
|
+
if port in (None, ""):
|
|
346
|
+
status = _status(audit.ledger, "D05")
|
|
347
|
+
if audit.acknowledged.get("D05"):
|
|
348
|
+
audit.add("D05", INFO, f"{name}: 没有 container_port,已按 acknowledged 继续")
|
|
349
|
+
elif status in (EVIDENCED, INFERRED):
|
|
350
|
+
audit.add(
|
|
351
|
+
"D05",
|
|
352
|
+
FAIL,
|
|
353
|
+
f"{name}: D05 证据是 {_answer_text(audit.ledger.get('D05'))},"
|
|
354
|
+
"但 spec 没有 container_port",
|
|
355
|
+
"端口必须来自上游 EXPOSE / 官方 compose,不能省略",
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
audit.add(
|
|
359
|
+
"D05",
|
|
360
|
+
WARN,
|
|
361
|
+
f"{name}: 没有 container_port,这个包不会映射端口",
|
|
362
|
+
'应用不对外提供端口(例如后台 worker)时,写 "public": false 说明是有意的',
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
value = str(port)
|
|
366
|
+
if d05 and value not in d05:
|
|
367
|
+
audit.add(
|
|
368
|
+
"D05",
|
|
369
|
+
WARN if not audit.acknowledged.get("D05") else INFO,
|
|
370
|
+
f"{name}: 容器端口 {value} 不在 D05 证据({', '.join(d05)})里",
|
|
371
|
+
"抄模板默认端口是已知翻车点(openviking 1933 曾被写成 8080)",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _audit_env_keys(audit: Audit, spec: dict[str, Any]) -> None:
|
|
375
|
+
"""D01: an invented variable name starts a container that ignores the value."""
|
|
376
|
+
if not audit.ledger:
|
|
377
|
+
return
|
|
378
|
+
known = set(_answer_values(audit.ledger.get("D01")))
|
|
379
|
+
known |= set(_answer_values(audit.ledger.get("D02")))
|
|
380
|
+
known.discard("")
|
|
381
|
+
if not known:
|
|
382
|
+
return
|
|
383
|
+
ignore = rules.implicit_envkeys() | GENERATED_ENV_KEYS
|
|
384
|
+
acknowledged = bool(audit.acknowledged.get("D01") or audit.acknowledged.get("D02"))
|
|
385
|
+
|
|
386
|
+
seen: set[str] = set()
|
|
387
|
+
for service in spec.get("services") or []:
|
|
388
|
+
if not isinstance(service, dict):
|
|
389
|
+
continue
|
|
390
|
+
for key in (service.get("env") or {}):
|
|
391
|
+
key = str(key)
|
|
392
|
+
if key in seen or key in ignore or key.startswith("PANEL_"):
|
|
393
|
+
continue
|
|
394
|
+
seen.add(key)
|
|
395
|
+
if key in known:
|
|
396
|
+
continue
|
|
397
|
+
audit.add(
|
|
398
|
+
"D01",
|
|
399
|
+
INFO if acknowledged else WARN,
|
|
400
|
+
f"环境变量 {key} 不在 D01/D02 证据({', '.join(sorted(known))})里",
|
|
401
|
+
"确认变量名拼写与上游一致;改错名字的变量容器照样起得来,只是不生效",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# Runtime fields whose presence is a claim about the upstream image. Writing
|
|
406
|
+
# one without evidence is how a package ends up fighting its own image (L004).
|
|
407
|
+
RUNTIME_KEYS = (
|
|
408
|
+
"user",
|
|
409
|
+
"privileged",
|
|
410
|
+
"cap_add",
|
|
411
|
+
"cap_drop",
|
|
412
|
+
"devices",
|
|
413
|
+
"security_opt",
|
|
414
|
+
"group_add",
|
|
415
|
+
"sysctls",
|
|
416
|
+
"network_mode",
|
|
417
|
+
"pid",
|
|
418
|
+
"ipc",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _audit_runtime(audit: Audit, spec: dict[str, Any]) -> None:
|
|
423
|
+
if not audit.ledger:
|
|
424
|
+
return
|
|
425
|
+
declared: list[str] = []
|
|
426
|
+
for service in spec.get("services") or []:
|
|
427
|
+
if not isinstance(service, dict):
|
|
428
|
+
continue
|
|
429
|
+
declared.extend(key for key in RUNTIME_KEYS if service.get(key))
|
|
430
|
+
if spec.get("data_owner"):
|
|
431
|
+
declared.append("data_owner")
|
|
432
|
+
if not declared:
|
|
433
|
+
return
|
|
434
|
+
status = _status(audit.ledger, "D07")
|
|
435
|
+
if status == EVIDENCED:
|
|
436
|
+
return
|
|
437
|
+
audit.add(
|
|
438
|
+
"D07",
|
|
439
|
+
INFO if audit.acknowledged.get("D07") else WARN,
|
|
440
|
+
f"spec 声明了运行时字段 {sorted(set(declared))},但 D07(上游运行时要求)"
|
|
441
|
+
f"在账本里是 {status or '缺失'}",
|
|
442
|
+
"运行用户/权限只以上游 Dockerfile、compose 为准;凭空 chown 或加 user 会让"
|
|
443
|
+
"挂载目录权限出错(lesson L004)",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _audit_dependency_and_scripts(audit: Audit, spec: dict[str, Any]) -> None:
|
|
448
|
+
"""The spec must not quietly disagree with what resolve found."""
|
|
449
|
+
if not audit.ledger:
|
|
450
|
+
return
|
|
451
|
+
acknowledged = bool(
|
|
452
|
+
audit.acknowledged.get("D09") or audit.acknowledged.get("D10")
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if spec.get("dependencies"):
|
|
456
|
+
status = _status(audit.ledger, "D10")
|
|
457
|
+
answer = _answer_text(audit.ledger.get("D10"))
|
|
458
|
+
if status != EVIDENCED:
|
|
459
|
+
audit.add(
|
|
460
|
+
"D10",
|
|
461
|
+
INFO if acknowledged else WARN,
|
|
462
|
+
f"spec 声明了面板依赖,但账本里 D10 是 {status or '缺失'}({answer})",
|
|
463
|
+
"确认上游确实支持外部数据库/缓存,并且依赖的 key 与面板上的应用一致",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
uses_scripts = bool(spec.get("scripts") or spec.get("init_script"))
|
|
467
|
+
ships_config = bool(spec.get("config_files"))
|
|
468
|
+
if not (uses_scripts or ships_config):
|
|
469
|
+
return
|
|
470
|
+
status = _status(audit.ledger, "D09")
|
|
471
|
+
answer = _answer_text(audit.ledger.get("D09"))
|
|
472
|
+
if status == EVIDENCED and not uses_scripts:
|
|
473
|
+
return
|
|
474
|
+
if status == MISSING and not acknowledged:
|
|
475
|
+
audit.add(
|
|
476
|
+
"D09",
|
|
477
|
+
WARN,
|
|
478
|
+
f"spec 带了 {'脚本' if uses_scripts else ''}"
|
|
479
|
+
f"{' 和' if uses_scripts and ships_config else ''}"
|
|
480
|
+
f"{'配置文件模板' if ships_config else ''},但账本里 D09 没有证据",
|
|
481
|
+
"确认上游首次启动真的需要它;不需要就别带(少了 init.sh 是 L001,"
|
|
482
|
+
"凭空多了脚本则是没必要的维护面)",
|
|
483
|
+
)
|
|
484
|
+
elif status == INFERRED and "暂定无" in answer:
|
|
485
|
+
audit.add(
|
|
486
|
+
"D09",
|
|
487
|
+
WARN,
|
|
488
|
+
f"spec 带了{'脚本' if uses_scripts else ''}"
|
|
489
|
+
f"{'配置文件模板' if ships_config else ''},但账本里 D09 的间接结论是「暂定无」",
|
|
490
|
+
"这次人工判断与 resolve 的推断不一致,确认后把 D09 的证据补进 spec",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def audit(spec: dict[str, Any], allowed_unresolved: bool = False) -> Audit:
|
|
495
|
+
"""Run every evidence check against a generation spec."""
|
|
496
|
+
result = Audit(
|
|
497
|
+
ledger=ledger_of(spec),
|
|
498
|
+
acknowledged={
|
|
499
|
+
str(key).upper(): str(value)
|
|
500
|
+
for key, value in (spec.get("acknowledged") or {}).items()
|
|
501
|
+
}
|
|
502
|
+
if isinstance(spec.get("acknowledged"), dict)
|
|
503
|
+
else {},
|
|
504
|
+
allowed_unresolved=allowed_unresolved,
|
|
505
|
+
)
|
|
506
|
+
_audit_ledger(result)
|
|
507
|
+
_audit_services(result, spec)
|
|
508
|
+
_audit_env_keys(result, spec)
|
|
509
|
+
_audit_runtime(result, spec)
|
|
510
|
+
_audit_dependency_and_scripts(result, spec)
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
__all__ = [
|
|
515
|
+
"Audit",
|
|
516
|
+
"EVIDENCED",
|
|
517
|
+
"Finding",
|
|
518
|
+
"INFERRED",
|
|
519
|
+
"LEDGER",
|
|
520
|
+
"MISSING",
|
|
521
|
+
"audit",
|
|
522
|
+
"ledger_of",
|
|
523
|
+
]
|