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/cli.py
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, rules
|
|
11
|
+
from .checks import run_all
|
|
12
|
+
from . import config as config_module
|
|
13
|
+
from . import depth as depth_module
|
|
14
|
+
from .patterns import (
|
|
15
|
+
DATA_FILE,
|
|
16
|
+
build as build_patterns,
|
|
17
|
+
categories as pattern_categories,
|
|
18
|
+
find as find_patterns,
|
|
19
|
+
get as get_pattern,
|
|
20
|
+
load_bundled,
|
|
21
|
+
patterns as all_patterns,
|
|
22
|
+
render_markdown,
|
|
23
|
+
save as save_patterns,
|
|
24
|
+
)
|
|
25
|
+
from .loader import LoadError, load_package
|
|
26
|
+
from .net import probe, parse_ports
|
|
27
|
+
from .panel import PanelClient, PanelError, connect as connect_panel
|
|
28
|
+
from .report import FAIL, Report
|
|
29
|
+
from .resolve import SourceError as ResolveSourceError
|
|
30
|
+
from .resolve import resolve as resolve_target
|
|
31
|
+
from .resolve import write_outputs as write_resolve_outputs
|
|
32
|
+
from .remote import RemoteError, SshTarget, check_host, smoke as run_smoke
|
|
33
|
+
from .gen import GenResult, SpecError, generate as generate_package, load_spec
|
|
34
|
+
from .deploy import DeployError, deploy as run_deploy, remove_local, uninstall as run_uninstall
|
|
35
|
+
from .selftest import run as run_selftest
|
|
36
|
+
from .fix import fix as run_fix
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _iter_targets(path: Path) -> list[Path]:
|
|
40
|
+
"""A package root, or a directory holding several of them."""
|
|
41
|
+
if path.is_file():
|
|
42
|
+
raise NotADirectoryError(
|
|
43
|
+
f"{path} 是文件;请传应用包根目录(含 data.yml),例如 ./project/app/1panel-apps/app"
|
|
44
|
+
)
|
|
45
|
+
if (path / "data.yml").exists():
|
|
46
|
+
return [path]
|
|
47
|
+
found = [child for child in sorted(path.iterdir()) if (child / "data.yml").exists()]
|
|
48
|
+
return found or [path]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
52
|
+
target = Path(args.path).resolve()
|
|
53
|
+
if not target.exists():
|
|
54
|
+
print(f"error: no such path: {target}", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
reports: list[Report] = []
|
|
57
|
+
try:
|
|
58
|
+
roots = _iter_targets(target)
|
|
59
|
+
except NotADirectoryError as exc:
|
|
60
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
for package_root in roots:
|
|
63
|
+
try:
|
|
64
|
+
package = load_package(package_root)
|
|
65
|
+
except LoadError as exc:
|
|
66
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
reports.append(run_all(package, strict=args.strict))
|
|
69
|
+
|
|
70
|
+
if args.json:
|
|
71
|
+
import json
|
|
72
|
+
|
|
73
|
+
print(json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=2))
|
|
74
|
+
else:
|
|
75
|
+
for report in reports:
|
|
76
|
+
print(report.render())
|
|
77
|
+
print()
|
|
78
|
+
return 0 if all(r.ok for r in reports) else 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
82
|
+
data = rules.load(args.name)
|
|
83
|
+
if args.key:
|
|
84
|
+
data = data.get(args.key, {})
|
|
85
|
+
import json
|
|
86
|
+
|
|
87
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_ports(args: argparse.Namespace) -> int:
|
|
92
|
+
try:
|
|
93
|
+
ports = parse_ports(args.ports)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
results = probe(args.host, ports, timeout=args.timeout)
|
|
98
|
+
for result in results:
|
|
99
|
+
print(f"{result.port:>6} {result.state:<8} {result.detail}")
|
|
100
|
+
usable = [r.port for r in results if r.usable]
|
|
101
|
+
if usable:
|
|
102
|
+
print(f"\nusable for a new service (host reachable, nothing listening): {usable}")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
107
|
+
cfg = config_module.load()
|
|
108
|
+
if args.action == "set":
|
|
109
|
+
cfg.set(args.key, args.value)
|
|
110
|
+
path = cfg.save()
|
|
111
|
+
print(f"{args.key} -> {config_module.mask(args.key, args.value)} ({path})")
|
|
112
|
+
return 0
|
|
113
|
+
if args.action == "unset":
|
|
114
|
+
cfg.values.pop(args.key, None)
|
|
115
|
+
print(f"removed {args.key} ({cfg.save()})")
|
|
116
|
+
return 0
|
|
117
|
+
print(f"config: {cfg.path}")
|
|
118
|
+
if cfg.broken:
|
|
119
|
+
print(f"警告: 配置文件无法解析({cfg.broken});执行 `p1 config set ...` 会重写它")
|
|
120
|
+
if not cfg.values:
|
|
121
|
+
print("(empty)")
|
|
122
|
+
for key, value in cfg.masked().items():
|
|
123
|
+
print(f"{key} = {value}")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_panel(args: argparse.Namespace) -> int:
|
|
128
|
+
cfg = config_module.load()
|
|
129
|
+
try:
|
|
130
|
+
# `connect` probes v2 first and falls back to v1, so the same client
|
|
131
|
+
# works against either panel generation.
|
|
132
|
+
client = connect_panel(cfg.require("panel.url"), cfg.require("panel.api_key"))
|
|
133
|
+
except KeyError as exc:
|
|
134
|
+
print(f"error: {exc.args[0]}", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
try:
|
|
137
|
+
info = client.ping()
|
|
138
|
+
except PanelError as exc:
|
|
139
|
+
print(f"panel rejected the request: {exc}", file=sys.stderr)
|
|
140
|
+
print(
|
|
141
|
+
"check the API 接口 settings on the panel: the key must match and the "
|
|
142
|
+
"calling IP must be whitelisted.",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
return 1
|
|
146
|
+
print(f"panel ok: {cfg.get('panel.url')}")
|
|
147
|
+
for key in ("name", "version", "os", "arch"):
|
|
148
|
+
if info.get(key):
|
|
149
|
+
print(f" {key}: {info[key]}")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_patterns(args: argparse.Namespace) -> int:
|
|
154
|
+
import json
|
|
155
|
+
|
|
156
|
+
if args.action == "build":
|
|
157
|
+
data = build_patterns([Path(p).resolve() for p in args.corpus])
|
|
158
|
+
out = Path(args.out).resolve() if args.out else DATA_FILE
|
|
159
|
+
if args.out is None and not os.access(out.parent, os.W_OK):
|
|
160
|
+
print(
|
|
161
|
+
f"error: 默认输出位置 {out} 不可写(wheel 安装时在 site-packages 里);"
|
|
162
|
+
"请用 --out 指定目录",
|
|
163
|
+
file=sys.stderr,
|
|
164
|
+
)
|
|
165
|
+
return 2
|
|
166
|
+
save_patterns(data, out)
|
|
167
|
+
print(f"wrote {out}")
|
|
168
|
+
for source in data["generated_from"]:
|
|
169
|
+
print(
|
|
170
|
+
f" {source.get('label') or source.get('corpus', '?')}: "
|
|
171
|
+
f"{source['apps']} apps / {source['versions']} versions"
|
|
172
|
+
)
|
|
173
|
+
empty = [p["id"] for p in data["patterns"] if not p["examples"]]
|
|
174
|
+
if empty:
|
|
175
|
+
print(f" patterns with no example found: {empty}")
|
|
176
|
+
if args.markdown:
|
|
177
|
+
target = Path(args.markdown).resolve()
|
|
178
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
target.write_text(render_markdown(data), encoding="utf-8")
|
|
180
|
+
print(f"wrote {target}")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
data = load_bundled()
|
|
184
|
+
if args.action == "export":
|
|
185
|
+
target = Path(args.markdown).resolve()
|
|
186
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
target.write_text(render_markdown(data), encoding="utf-8")
|
|
188
|
+
print(f"wrote {target}")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
if args.action == "find":
|
|
192
|
+
selected = find_patterns(args.query, data)
|
|
193
|
+
elif args.action == "show":
|
|
194
|
+
pattern = get_pattern(args.pattern_id, data)
|
|
195
|
+
if pattern is None:
|
|
196
|
+
print(f"error: unknown pattern {args.pattern_id!r}", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
selected = [pattern]
|
|
199
|
+
else:
|
|
200
|
+
selected = all_patterns(data)
|
|
201
|
+
if args.category:
|
|
202
|
+
selected = [p for p in selected if p.category == args.category]
|
|
203
|
+
|
|
204
|
+
if args.json:
|
|
205
|
+
print(json.dumps([p.__dict__ for p in selected], ensure_ascii=False, indent=2))
|
|
206
|
+
return 0
|
|
207
|
+
if not selected:
|
|
208
|
+
print("no matching pattern")
|
|
209
|
+
return 1
|
|
210
|
+
|
|
211
|
+
if args.action == "show":
|
|
212
|
+
for pattern in selected:
|
|
213
|
+
print(f"# {pattern.id} — {pattern.title}")
|
|
214
|
+
print(f"\n用途:{pattern.intent}")
|
|
215
|
+
print(f"何时使用:{pattern.when_to_use}")
|
|
216
|
+
print(f"官方采用:{pattern.apps} 个应用 / {pattern.versions} 个版本")
|
|
217
|
+
for note in pattern.notes:
|
|
218
|
+
print(f"注意:{note}")
|
|
219
|
+
for index, example in enumerate(pattern.examples):
|
|
220
|
+
header = "示例" if index == 0 else f"备选示例 {index}"
|
|
221
|
+
print(f"\n--- {header}(来自 {example['app']}/{example['file']})---")
|
|
222
|
+
print(example["snippet"])
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
width = max((len(p.id) for p in selected), default=10)
|
|
226
|
+
for pattern in selected:
|
|
227
|
+
print(f"{pattern.id:<{width}} {pattern.apps:>4} apps {pattern.title}")
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_depth(args: argparse.Namespace) -> int:
|
|
232
|
+
import json
|
|
233
|
+
|
|
234
|
+
corpora = [Path(p).resolve() for p in (args.corpus or [])]
|
|
235
|
+
if args.package:
|
|
236
|
+
target = Path(args.package).resolve()
|
|
237
|
+
profile = depth_module.profile_app(target)
|
|
238
|
+
if profile is None:
|
|
239
|
+
print(f"error: {target} is not an app package root", file=sys.stderr)
|
|
240
|
+
return 2
|
|
241
|
+
corpus: list[depth_module.Profile] = []
|
|
242
|
+
if corpora:
|
|
243
|
+
corpus = depth_module.scan(corpora)
|
|
244
|
+
if args.json:
|
|
245
|
+
payload = {
|
|
246
|
+
"profile": depth_module.asdict(profile),
|
|
247
|
+
"neighbours": [
|
|
248
|
+
{"app": other.app, "score": round(score, 3), "shared": shared}
|
|
249
|
+
for score, other, shared in depth_module.nearest(profile, corpus)
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
253
|
+
return 0
|
|
254
|
+
print(f"# {profile.app}/{profile.version}")
|
|
255
|
+
for label, values in (
|
|
256
|
+
("镜像", profile.images),
|
|
257
|
+
("容器内端口", profile.port_pairs),
|
|
258
|
+
("容器内挂载点", profile.mount_targets),
|
|
259
|
+
("挂载来源", profile.mount_hosts),
|
|
260
|
+
("应用环境变量", profile.app_env_keys),
|
|
261
|
+
("表单字段", profile.form_env_keys),
|
|
262
|
+
("高风险/额外字段", profile.high_signal_keys + profile.service_extra_keys),
|
|
263
|
+
("脚本", profile.scripts),
|
|
264
|
+
("README 章节", profile.readme_sections),
|
|
265
|
+
):
|
|
266
|
+
print(f"\n{label}({len(values)}):")
|
|
267
|
+
for value in values[:12]:
|
|
268
|
+
print(f" - {value}")
|
|
269
|
+
if len(values) > 12:
|
|
270
|
+
print(f" ... 另外 {len(values) - 12} 项")
|
|
271
|
+
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
|
+
print("\n与官方语料最接近的包:")
|
|
277
|
+
found = depth_module.nearest(profile, corpus)
|
|
278
|
+
if not found:
|
|
279
|
+
print(" (没有明显相似的包,说明这份包的应用特有部分足够独立)")
|
|
280
|
+
for score, other, shared in found:
|
|
281
|
+
print(f" {score:.0%} {other.app}")
|
|
282
|
+
for item in shared[:6]:
|
|
283
|
+
print(f" {item}")
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
if not corpora:
|
|
287
|
+
print("error: --corpus is required", file=sys.stderr)
|
|
288
|
+
return 2
|
|
289
|
+
profiles = depth_module.scan(corpora, limit=args.limit)
|
|
290
|
+
report = depth_module.dimension_report(profiles, top=args.top)
|
|
291
|
+
if args.json:
|
|
292
|
+
print(depth_module.to_json(report))
|
|
293
|
+
return 0
|
|
294
|
+
print(f"扫描 {report['apps']} 个应用")
|
|
295
|
+
print("\n维度 不同取值 每应用均值 最常见取值")
|
|
296
|
+
for item in report["dimensions"]:
|
|
297
|
+
top = ", ".join(f"{v}×{c}" for v, c in item["top"][:3])
|
|
298
|
+
print(
|
|
299
|
+
f"{item['dimension']:<22} {item['distinct']:>7} {item['per_app']:>9} {top}"
|
|
300
|
+
)
|
|
301
|
+
print("\n服务数分布:", report["service_counts"])
|
|
302
|
+
print("带 healthcheck 的应用:", report["healthcheck"])
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_resolve(args: argparse.Namespace) -> int:
|
|
307
|
+
try:
|
|
308
|
+
result = resolve_target(args.target, fetch_budget=args.timeout)
|
|
309
|
+
except ResolveSourceError as exc:
|
|
310
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
311
|
+
return 2
|
|
312
|
+
|
|
313
|
+
if args.json:
|
|
314
|
+
print(result.to_json())
|
|
315
|
+
else:
|
|
316
|
+
print(render_resolve_text(result))
|
|
317
|
+
|
|
318
|
+
if args.out:
|
|
319
|
+
written = write_resolve_outputs(result, Path(args.out).resolve())
|
|
320
|
+
for name, path in written.items():
|
|
321
|
+
print(f"\nwrote {name}: {path}")
|
|
322
|
+
return 1 if result.blocked else 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_gen(args: argparse.Namespace) -> int:
|
|
326
|
+
import json as _json
|
|
327
|
+
|
|
328
|
+
spec_path = Path(args.spec).resolve()
|
|
329
|
+
if not spec_path.exists():
|
|
330
|
+
print(f"error: 找不到 spec: {spec_path}", file=sys.stderr)
|
|
331
|
+
return 2
|
|
332
|
+
try:
|
|
333
|
+
spec = load_spec(spec_path)
|
|
334
|
+
result = generate_package(
|
|
335
|
+
spec,
|
|
336
|
+
Path(args.out or spec_path.parent).resolve(),
|
|
337
|
+
force=args.force,
|
|
338
|
+
allow_unresolved=args.allow_unresolved,
|
|
339
|
+
)
|
|
340
|
+
except SpecError as exc:
|
|
341
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
342
|
+
return 2
|
|
343
|
+
|
|
344
|
+
if args.json:
|
|
345
|
+
print(
|
|
346
|
+
_json.dumps(
|
|
347
|
+
{
|
|
348
|
+
"root": str(result.root),
|
|
349
|
+
"files": result.files,
|
|
350
|
+
"exposed": result.exposed,
|
|
351
|
+
"hidden_env": result.hidden_env,
|
|
352
|
+
"warnings": result.warnings,
|
|
353
|
+
"audit": result.audit.to_dict() if result.audit else None,
|
|
354
|
+
},
|
|
355
|
+
ensure_ascii=False,
|
|
356
|
+
indent=2,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return 0
|
|
360
|
+
print(result.render())
|
|
361
|
+
if args.check:
|
|
362
|
+
print()
|
|
363
|
+
return cmd_check(argparse.Namespace(path=str(result.root), strict=args.strict, json=False))
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def cmd_deploy(args: argparse.Namespace) -> int:
|
|
368
|
+
import json as _json
|
|
369
|
+
|
|
370
|
+
cfg = config_module.load()
|
|
371
|
+
try:
|
|
372
|
+
panel = PanelClient(cfg.require("panel.url"), cfg.require("panel.api_key"))
|
|
373
|
+
ssh = _ssh_target(args)
|
|
374
|
+
except (KeyError, RemoteError) as exc:
|
|
375
|
+
print(f"error: {getattr(exc, 'args', [''])[0] or exc}", file=sys.stderr)
|
|
376
|
+
return 2
|
|
377
|
+
|
|
378
|
+
params: dict[str, str] = {}
|
|
379
|
+
for item in args.param or []:
|
|
380
|
+
key, sep, value = item.partition("=")
|
|
381
|
+
if not key or not sep:
|
|
382
|
+
print(f"error: --param 需要 KEY=VALUE 形式: {item}", file=sys.stderr)
|
|
383
|
+
return 2
|
|
384
|
+
params[key.strip()] = value
|
|
385
|
+
|
|
386
|
+
package = Path(args.package).resolve()
|
|
387
|
+
if not (package / "data.yml").exists():
|
|
388
|
+
print(f"error: {package} 不是应用包根目录(缺少 data.yml)", file=sys.stderr)
|
|
389
|
+
return 2
|
|
390
|
+
|
|
391
|
+
result = run_deploy(
|
|
392
|
+
ssh,
|
|
393
|
+
panel,
|
|
394
|
+
package,
|
|
395
|
+
name=args.name,
|
|
396
|
+
version=args.version,
|
|
397
|
+
params=params,
|
|
398
|
+
wait=not args.no_wait,
|
|
399
|
+
timeout=args.timeout,
|
|
400
|
+
uninstall_after=args.uninstall,
|
|
401
|
+
allow_port=args.allow_port,
|
|
402
|
+
)
|
|
403
|
+
if args.json:
|
|
404
|
+
print(
|
|
405
|
+
_json.dumps(
|
|
406
|
+
{
|
|
407
|
+
"ok": result.ok,
|
|
408
|
+
"stage": result.stage,
|
|
409
|
+
"app_key": result.app_key,
|
|
410
|
+
"install_id": result.install_id,
|
|
411
|
+
"message": result.message,
|
|
412
|
+
"containers": result.containers,
|
|
413
|
+
"remote_dir": result.remote_dir,
|
|
414
|
+
},
|
|
415
|
+
ensure_ascii=False,
|
|
416
|
+
indent=2,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
print(result.render())
|
|
421
|
+
if args.cleanup_local and result.ok:
|
|
422
|
+
remove_local(ssh, package.name)
|
|
423
|
+
print(f"已移除本地包目录 {package.name}")
|
|
424
|
+
return 0 if result.ok else 1
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
428
|
+
cfg = config_module.load()
|
|
429
|
+
try:
|
|
430
|
+
panel = PanelClient(cfg.require("panel.url"), cfg.require("panel.api_key"))
|
|
431
|
+
except KeyError as exc:
|
|
432
|
+
print(f"error: {exc.args[0]}", file=sys.stderr)
|
|
433
|
+
return 2
|
|
434
|
+
if args.install_id:
|
|
435
|
+
ids = [int(args.install_id)]
|
|
436
|
+
elif args.name:
|
|
437
|
+
ids = [int(item["id"]) for item in installed_apps_matching(panel, args.name)]
|
|
438
|
+
if not ids:
|
|
439
|
+
print(f"error: 找不到已安装的应用 {args.name!r}", file=sys.stderr)
|
|
440
|
+
return 1
|
|
441
|
+
elif args.all_apps:
|
|
442
|
+
# Uninstalling everything is never an accident-safe default: an empty
|
|
443
|
+
# `p1 uninstall` used to mean "remove every app on the panel".
|
|
444
|
+
targets = installed_apps_matching(panel, "")
|
|
445
|
+
if not targets:
|
|
446
|
+
print("面板上没有已安装的应用")
|
|
447
|
+
return 0
|
|
448
|
+
print("将要卸载以下应用:")
|
|
449
|
+
for item in targets:
|
|
450
|
+
print(f" #{item.get('id')} {item.get('name')} ({item.get('status')})")
|
|
451
|
+
if not args.yes:
|
|
452
|
+
print("确认请加 --yes(例如: p1 uninstall --all --yes)", file=sys.stderr)
|
|
453
|
+
return 2
|
|
454
|
+
ids = [int(item["id"]) for item in targets]
|
|
455
|
+
else:
|
|
456
|
+
print(
|
|
457
|
+
"error: 请指定应用名或 --install-id;卸载全部已安装应用请显式使用 --all --yes",
|
|
458
|
+
file=sys.stderr,
|
|
459
|
+
)
|
|
460
|
+
return 2
|
|
461
|
+
for install_id in ids:
|
|
462
|
+
try:
|
|
463
|
+
run_uninstall(panel, install_id)
|
|
464
|
+
except PanelError as exc:
|
|
465
|
+
print(f"error: 卸载 {install_id} 失败: {exc}", file=sys.stderr)
|
|
466
|
+
return 1
|
|
467
|
+
print(f"已卸载 installId={install_id}")
|
|
468
|
+
return 0
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def cmd_selftest(args: argparse.Namespace) -> int:
|
|
472
|
+
result = run_selftest()
|
|
473
|
+
if args.json:
|
|
474
|
+
import json as _json
|
|
475
|
+
|
|
476
|
+
print(
|
|
477
|
+
_json.dumps(
|
|
478
|
+
{
|
|
479
|
+
"ok": result.ok,
|
|
480
|
+
"checks": [
|
|
481
|
+
{"name": c.name, "ok": c.ok, "detail": c.detail}
|
|
482
|
+
for c in result.checks
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
ensure_ascii=False,
|
|
486
|
+
indent=2,
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
print(result.render())
|
|
491
|
+
return 0 if result.ok else 1
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def cmd_fix(args: argparse.Namespace) -> int:
|
|
495
|
+
target = Path(args.path).resolve()
|
|
496
|
+
if not target.is_dir():
|
|
497
|
+
print(f"error: 不是目录: {target}", file=sys.stderr)
|
|
498
|
+
return 2
|
|
499
|
+
try:
|
|
500
|
+
result = run_fix(target, dry_run=args.dry_run)
|
|
501
|
+
except FileNotFoundError as exc:
|
|
502
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
503
|
+
return 2
|
|
504
|
+
print(result.render())
|
|
505
|
+
if args.check:
|
|
506
|
+
print()
|
|
507
|
+
return cmd_check(
|
|
508
|
+
argparse.Namespace(path=str(target), strict=False, json=False)
|
|
509
|
+
)
|
|
510
|
+
return 0
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def installed_apps_matching(panel, name: str) -> list[dict]:
|
|
514
|
+
from .deploy import installed_apps
|
|
515
|
+
|
|
516
|
+
items = installed_apps(panel, name)
|
|
517
|
+
if not name:
|
|
518
|
+
return items
|
|
519
|
+
return [item for item in items if name in str(item.get("name") or "")]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _ssh_target(args: argparse.Namespace) -> SshTarget:
|
|
523
|
+
cfg = config_module.load()
|
|
524
|
+
host = args.host or cfg.get("ssh.host")
|
|
525
|
+
if not host:
|
|
526
|
+
raise RemoteError("没有指定 host:用 --host,或 `p1 config set ssh.host <ip>`")
|
|
527
|
+
return SshTarget(
|
|
528
|
+
host=host,
|
|
529
|
+
user=args.user or cfg.get("ssh.user") or "root",
|
|
530
|
+
key_path=args.key or cfg.get("ssh.key") or "",
|
|
531
|
+
port=int(args.port or cfg.get("ssh.port") or 22),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def cmd_remote(args: argparse.Namespace) -> int:
|
|
536
|
+
try:
|
|
537
|
+
target = _ssh_target(args)
|
|
538
|
+
except RemoteError as exc:
|
|
539
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
540
|
+
return 2
|
|
541
|
+
|
|
542
|
+
if args.action == "check":
|
|
543
|
+
try:
|
|
544
|
+
facts = check_host(target)
|
|
545
|
+
except RemoteError as exc:
|
|
546
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
547
|
+
return 2
|
|
548
|
+
print(f"主机 : {facts.hostname} ({facts.os_release})")
|
|
549
|
+
print(f"Docker : {facts.docker} / {facts.compose}")
|
|
550
|
+
print(f"资源 : {facts.cpus} vCPU, 可用内存 {facts.mem_available_mb} MB, "
|
|
551
|
+
f"磁盘剩余 {facts.disk_free_gb:.1f} GB")
|
|
552
|
+
print(f"已用端口 : {facts.used_ports}")
|
|
553
|
+
free = facts.free([10000, 10001, 10002, 10003, 10004, 15000, 15001])
|
|
554
|
+
print(f"空闲候选 : {free}")
|
|
555
|
+
print(f"本地应用 : {facts.local_apps}")
|
|
556
|
+
if facts.mem_available_mb < 700:
|
|
557
|
+
print("注意 : 可用内存偏低,冒烟测试请优先选小镜像")
|
|
558
|
+
return 0
|
|
559
|
+
|
|
560
|
+
if args.action == "smoke":
|
|
561
|
+
package = Path(args.package).resolve()
|
|
562
|
+
if not package.is_dir():
|
|
563
|
+
print(f"error: 不是目录: {package}", file=sys.stderr)
|
|
564
|
+
return 2
|
|
565
|
+
ports = [int(p) for p in (args.ports or [])]
|
|
566
|
+
overrides: dict[str, str] = {}
|
|
567
|
+
for item in args.env or []:
|
|
568
|
+
key, _, value = item.partition("=")
|
|
569
|
+
if not key or not _:
|
|
570
|
+
print(f"error: --env 需要 KEY=VALUE 形式: {item}", file=sys.stderr)
|
|
571
|
+
return 2
|
|
572
|
+
overrides[key.strip()] = value
|
|
573
|
+
try:
|
|
574
|
+
result = run_smoke(
|
|
575
|
+
target,
|
|
576
|
+
package,
|
|
577
|
+
version_name=args.version,
|
|
578
|
+
ports=ports,
|
|
579
|
+
overrides=overrides,
|
|
580
|
+
timeout=args.timeout,
|
|
581
|
+
ready_timeout=args.ready_timeout,
|
|
582
|
+
keep=args.keep,
|
|
583
|
+
prune_image=args.prune_image,
|
|
584
|
+
key=args.key_name,
|
|
585
|
+
)
|
|
586
|
+
except RemoteError as exc:
|
|
587
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
588
|
+
return 2
|
|
589
|
+
print(result.render())
|
|
590
|
+
if args.json:
|
|
591
|
+
import json as _json
|
|
592
|
+
|
|
593
|
+
print(
|
|
594
|
+
_json.dumps(
|
|
595
|
+
{
|
|
596
|
+
"ok": result.ok,
|
|
597
|
+
"stage": result.stage,
|
|
598
|
+
"detail": result.detail,
|
|
599
|
+
"containers": result.container_states,
|
|
600
|
+
"ports": result.published_ports,
|
|
601
|
+
"probe": result.probe,
|
|
602
|
+
"lessons": [item.get("id") for item in result.lessons],
|
|
603
|
+
"remote_dir": result.remote_dir,
|
|
604
|
+
"kept": result.kept,
|
|
605
|
+
},
|
|
606
|
+
ensure_ascii=False,
|
|
607
|
+
indent=2,
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
if result.ok:
|
|
611
|
+
return 0
|
|
612
|
+
return 3 if result.outcome == "needs-input" else 1
|
|
613
|
+
|
|
614
|
+
if args.action == "logs":
|
|
615
|
+
try:
|
|
616
|
+
out = target.must(
|
|
617
|
+
f"cd {args.remote_dir} && docker compose logs --tail=200 2>&1 | tail -200"
|
|
618
|
+
)
|
|
619
|
+
except RemoteError as exc:
|
|
620
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
621
|
+
return 2
|
|
622
|
+
print(out)
|
|
623
|
+
return 0
|
|
624
|
+
return 2
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def render_resolve_text(result) -> str:
|
|
628
|
+
from .resolve import EVIDENCED, INFERRED
|
|
629
|
+
|
|
630
|
+
icons = {EVIDENCED: "OK ", INFERRED: "WARN", "missing": "MISS"}
|
|
631
|
+
lines = [f"# {result.target}", f"来源: {result.analysis.source.label}", ""]
|
|
632
|
+
for note in result.analysis.source.notes:
|
|
633
|
+
lines.append(f"注意: {note}")
|
|
634
|
+
if result.analysis.source.notes:
|
|
635
|
+
lines.append("")
|
|
636
|
+
for decision in result.decisions:
|
|
637
|
+
mark = icons.get(decision.status, "????")
|
|
638
|
+
lines.append(f"{mark} {decision.id} {decision.question}")
|
|
639
|
+
lines.append(f" 答案: {decision.answer or '—'}")
|
|
640
|
+
if decision.evidence:
|
|
641
|
+
first = decision.evidence[0]
|
|
642
|
+
where = f"{first.file}:{first.line}" if first.line else first.file
|
|
643
|
+
extra = f" (+{len(decision.evidence) - 1})" if len(decision.evidence) > 1 else ""
|
|
644
|
+
lines.append(f" 证据: {where}{extra}")
|
|
645
|
+
gaps = [d for d in result.decisions if d.blocking and not d.ok]
|
|
646
|
+
lines.append("")
|
|
647
|
+
if gaps:
|
|
648
|
+
lines.append(
|
|
649
|
+
"阻塞: " + ", ".join(f"{d.id} {d.question}" for d in gaps) + " —— 缺少上游证据,不能靠猜"
|
|
650
|
+
)
|
|
651
|
+
else:
|
|
652
|
+
lines.append("没有阻塞项:D03/D05/D06 都有上游证据,可以进入生成阶段。")
|
|
653
|
+
return "\n".join(lines)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
657
|
+
parser = argparse.ArgumentParser(
|
|
658
|
+
prog="p1", description="Generate, check and verify 1Panel app packages."
|
|
659
|
+
)
|
|
660
|
+
parser.add_argument("--version", action="version", version=f"p1 {__version__}")
|
|
661
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
662
|
+
|
|
663
|
+
check = sub.add_parser("check", help="validate an app package directory")
|
|
664
|
+
check.add_argument("path", help="app package root, or a directory of packages")
|
|
665
|
+
check.add_argument(
|
|
666
|
+
"--strict",
|
|
667
|
+
action="store_true",
|
|
668
|
+
help="treat artifact-style conventions as failures",
|
|
669
|
+
)
|
|
670
|
+
check.add_argument("--json", action="store_true", help="machine-readable output")
|
|
671
|
+
check.set_defaults(func=cmd_check)
|
|
672
|
+
|
|
673
|
+
rule_cmd = sub.add_parser("rules", help="print bundled rule data")
|
|
674
|
+
rule_cmd.add_argument("name", help="rule data file, e.g. store / i18n / lessons")
|
|
675
|
+
rule_cmd.add_argument("--key", help="print only this top-level key")
|
|
676
|
+
rule_cmd.set_defaults(func=cmd_rules)
|
|
677
|
+
|
|
678
|
+
ports = sub.add_parser(
|
|
679
|
+
"ports",
|
|
680
|
+
help="classify host ports as open / closed / filtered before a smoke test",
|
|
681
|
+
)
|
|
682
|
+
ports.add_argument("host", help="target host, e.g. 47.113.177.168")
|
|
683
|
+
ports.add_argument(
|
|
684
|
+
"ports", help="comma separated ports or ranges, e.g. 10000,15000 or 10000-10010"
|
|
685
|
+
)
|
|
686
|
+
ports.add_argument("--timeout", type=float, default=5.0)
|
|
687
|
+
ports.set_defaults(func=cmd_ports)
|
|
688
|
+
|
|
689
|
+
cfg_cmd = sub.add_parser("config", help="read or write local p1 configuration")
|
|
690
|
+
cfg_sub = cfg_cmd.add_subparsers(dest="action")
|
|
691
|
+
cfg_show = cfg_sub.add_parser("show", help="print current configuration")
|
|
692
|
+
cfg_show.set_defaults(action="show", func=cmd_config)
|
|
693
|
+
cfg_set = cfg_sub.add_parser("set", help="set a key")
|
|
694
|
+
cfg_set.add_argument("key")
|
|
695
|
+
cfg_set.add_argument("value")
|
|
696
|
+
cfg_set.set_defaults(action="set", func=cmd_config)
|
|
697
|
+
cfg_unset = cfg_sub.add_parser("unset", help="remove a key")
|
|
698
|
+
cfg_unset.add_argument("key")
|
|
699
|
+
cfg_unset.set_defaults(action="unset", func=cmd_config)
|
|
700
|
+
cfg_cmd.set_defaults(action="show", func=cmd_config)
|
|
701
|
+
|
|
702
|
+
panel = sub.add_parser("panel", help="talk to the 1Panel instance")
|
|
703
|
+
panel_sub = panel.add_subparsers(dest="action", required=True)
|
|
704
|
+
panel_ping = panel_sub.add_parser("ping", help="verify credentials and reachability")
|
|
705
|
+
panel_ping.set_defaults(func=cmd_panel)
|
|
706
|
+
|
|
707
|
+
pat = sub.add_parser("patterns", help="browse the pattern library")
|
|
708
|
+
pat_sub = pat.add_subparsers(dest="action")
|
|
709
|
+
pat_build = pat_sub.add_parser("build", help="rebuild the library from a corpus")
|
|
710
|
+
pat_build.add_argument(
|
|
711
|
+
"--corpus", action="append", required=True, help="apps dir or app package root"
|
|
712
|
+
)
|
|
713
|
+
pat_build.add_argument("--out", help=f"output json (default {DATA_FILE})")
|
|
714
|
+
pat_build.add_argument("--markdown", help="also write a tutorial markdown file")
|
|
715
|
+
pat_build.set_defaults(action="build", func=cmd_patterns)
|
|
716
|
+
pat_list = pat_sub.add_parser("list", help="list patterns")
|
|
717
|
+
pat_list.add_argument("--category")
|
|
718
|
+
pat_list.add_argument("--json", action="store_true")
|
|
719
|
+
pat_list.set_defaults(action="list", func=cmd_patterns)
|
|
720
|
+
pat_show = pat_sub.add_parser("show", help="print one pattern with examples")
|
|
721
|
+
pat_show.add_argument("pattern_id")
|
|
722
|
+
pat_show.add_argument("--json", action="store_true")
|
|
723
|
+
pat_show.set_defaults(action="show", func=cmd_patterns)
|
|
724
|
+
pat_find = pat_sub.add_parser("find", help="keyword search")
|
|
725
|
+
pat_find.add_argument("query")
|
|
726
|
+
pat_find.add_argument("--json", action="store_true")
|
|
727
|
+
pat_find.set_defaults(action="find", func=cmd_patterns)
|
|
728
|
+
pat_export = pat_sub.add_parser("export", help="write the tutorial markdown")
|
|
729
|
+
pat_export.add_argument("--markdown", required=True)
|
|
730
|
+
pat_export.set_defaults(action="export", func=cmd_patterns)
|
|
731
|
+
pat.set_defaults(action="list", func=cmd_patterns)
|
|
732
|
+
|
|
733
|
+
depth = sub.add_parser(
|
|
734
|
+
"depth", help="measure how app-specific a package is (template vs adapted)"
|
|
735
|
+
)
|
|
736
|
+
depth.add_argument("package", nargs="?", help="app package root to inspect")
|
|
737
|
+
depth.add_argument(
|
|
738
|
+
"--corpus", action="append", help="apps dir or app package root (repeatable)"
|
|
739
|
+
)
|
|
740
|
+
depth.add_argument("--top", type=int, default=6, help="values to show per dimension")
|
|
741
|
+
depth.add_argument("--limit", type=int, help="stop after N apps (for a quick look)")
|
|
742
|
+
depth.add_argument("--json", action="store_true")
|
|
743
|
+
depth.set_defaults(func=cmd_depth)
|
|
744
|
+
|
|
745
|
+
res = sub.add_parser(
|
|
746
|
+
"resolve", help="gather upstream evidence for the 10 generation decisions"
|
|
747
|
+
)
|
|
748
|
+
res.add_argument(
|
|
749
|
+
"target",
|
|
750
|
+
help="local checkout path, owner/repo slug, or github.com URL",
|
|
751
|
+
)
|
|
752
|
+
res.add_argument("--out", help="write decision-report.md / draft-spec.json / resolve.json")
|
|
753
|
+
res.add_argument(
|
|
754
|
+
"--timeout",
|
|
755
|
+
type=float,
|
|
756
|
+
default=60.0,
|
|
757
|
+
help="seconds allowed for the raw-file fetches (default 60; "
|
|
758
|
+
"raw.githubusercontent.com is blocked from some networks)",
|
|
759
|
+
)
|
|
760
|
+
res.add_argument("--json", action="store_true")
|
|
761
|
+
res.set_defaults(func=cmd_resolve)
|
|
762
|
+
|
|
763
|
+
gen = sub.add_parser("gen", help="generate a package from a spec")
|
|
764
|
+
gen.add_argument("spec", help="spec JSON (see `p1 resolve --out` for a draft)")
|
|
765
|
+
gen.add_argument("--out", help="output directory, default is next to the spec")
|
|
766
|
+
gen.add_argument("--force", action="store_true", help="overwrite an existing package")
|
|
767
|
+
gen.add_argument(
|
|
768
|
+
"--allow-unresolved",
|
|
769
|
+
dest="allow_unresolved",
|
|
770
|
+
action="store_true",
|
|
771
|
+
help="generate even when a blocking decision has no upstream evidence "
|
|
772
|
+
"(recorded in decision-audit.json)",
|
|
773
|
+
)
|
|
774
|
+
gen.add_argument("--check", action="store_true", help="run `p1 check` right after")
|
|
775
|
+
gen.add_argument("--strict", action="store_true", help="strict mode for the follow-up check")
|
|
776
|
+
gen.add_argument("--json", action="store_true")
|
|
777
|
+
gen.set_defaults(func=cmd_gen)
|
|
778
|
+
|
|
779
|
+
deploy = sub.add_parser(
|
|
780
|
+
"deploy",
|
|
781
|
+
help="install a package through the 1Panel API (upload, sync, install, verify)",
|
|
782
|
+
)
|
|
783
|
+
deploy.add_argument("package", help="app package root")
|
|
784
|
+
deploy.add_argument("--host", help="ssh host, default from p1 config")
|
|
785
|
+
deploy.add_argument("--user", help="ssh user, default root")
|
|
786
|
+
deploy.add_argument("--key", help="ssh private key path")
|
|
787
|
+
deploy.add_argument("--port", help="ssh port, default 22")
|
|
788
|
+
deploy.add_argument("--name", help="install name, default is the package directory name")
|
|
789
|
+
deploy.add_argument("--version", default="latest", help="version directory, default latest")
|
|
790
|
+
deploy.add_argument(
|
|
791
|
+
"--param",
|
|
792
|
+
action="append",
|
|
793
|
+
help="install form value, e.g. --param PANEL_APP_PORT_HTTP=10000 (repeatable)",
|
|
794
|
+
)
|
|
795
|
+
deploy.add_argument("--no-wait", action="store_true", help="submit and return immediately")
|
|
796
|
+
deploy.add_argument("--timeout", type=int, default=600)
|
|
797
|
+
deploy.add_argument(
|
|
798
|
+
"--no-allow-port",
|
|
799
|
+
dest="allow_port",
|
|
800
|
+
action="store_false",
|
|
801
|
+
default=True,
|
|
802
|
+
help="do not let the panel open the firewall port",
|
|
803
|
+
)
|
|
804
|
+
deploy.add_argument(
|
|
805
|
+
"--uninstall", action="store_true", help="remove the app again after verifying"
|
|
806
|
+
)
|
|
807
|
+
deploy.add_argument(
|
|
808
|
+
"--cleanup-local",
|
|
809
|
+
dest="cleanup_local",
|
|
810
|
+
action="store_true",
|
|
811
|
+
help="remove the package from the panel's local app directory afterwards",
|
|
812
|
+
)
|
|
813
|
+
deploy.add_argument("--json", action="store_true")
|
|
814
|
+
deploy.set_defaults(func=cmd_deploy)
|
|
815
|
+
|
|
816
|
+
uninstall = sub.add_parser("uninstall", help="remove an installed app")
|
|
817
|
+
uninstall.add_argument("name", nargs="?", help="installed app name")
|
|
818
|
+
uninstall.add_argument("--install-id", dest="install_id", help="install record id")
|
|
819
|
+
uninstall.add_argument(
|
|
820
|
+
"--all", dest="all_apps", action="store_true", help="remove every installed app"
|
|
821
|
+
)
|
|
822
|
+
uninstall.add_argument("--yes", action="store_true", help="skip the --all confirmation")
|
|
823
|
+
uninstall.set_defaults(func=cmd_uninstall)
|
|
824
|
+
|
|
825
|
+
selftest = sub.add_parser(
|
|
826
|
+
"selftest", help="acceptance checks against this installation"
|
|
827
|
+
)
|
|
828
|
+
selftest.add_argument("--json", action="store_true")
|
|
829
|
+
selftest.set_defaults(func=cmd_selftest)
|
|
830
|
+
|
|
831
|
+
fix = sub.add_parser(
|
|
832
|
+
"fix", help="apply what `p1 check` found to a package (idempotent)"
|
|
833
|
+
)
|
|
834
|
+
fix.add_argument("path", help="app package root")
|
|
835
|
+
fix.add_argument(
|
|
836
|
+
"--dry-run", dest="dry_run", action="store_true", help="show changes only"
|
|
837
|
+
)
|
|
838
|
+
fix.add_argument("--check", action="store_true", help="run `p1 check` afterwards")
|
|
839
|
+
fix.set_defaults(func=cmd_fix)
|
|
840
|
+
|
|
841
|
+
remote = sub.add_parser("remote", help="run a package against a real 1Panel host over SSH")
|
|
842
|
+
remote.add_argument("--host", help="host or IP (or set ssh.host in p1 config)")
|
|
843
|
+
remote.add_argument("--user", help="ssh user, default root")
|
|
844
|
+
remote.add_argument("--key", help="ssh private key path")
|
|
845
|
+
remote.add_argument("--port", help="ssh port, default 22")
|
|
846
|
+
remote_sub = remote.add_subparsers(dest="action", required=True)
|
|
847
|
+
remote_check = remote_sub.add_parser("check", help="report host facts and free ports")
|
|
848
|
+
remote_check.set_defaults(func=cmd_remote)
|
|
849
|
+
remote_smoke = remote_sub.add_parser(
|
|
850
|
+
"smoke", help="upload one version, bring it up, probe it, tear it down"
|
|
851
|
+
)
|
|
852
|
+
remote_smoke.add_argument("package", help="app package root")
|
|
853
|
+
remote_smoke.add_argument("--version", help="version directory, default latest")
|
|
854
|
+
remote_smoke.add_argument(
|
|
855
|
+
"--ports",
|
|
856
|
+
action="append",
|
|
857
|
+
help="host port for the Nth PANEL_APP_PORT_* field (repeatable)",
|
|
858
|
+
)
|
|
859
|
+
remote_smoke.add_argument("--timeout", type=int, default=420)
|
|
860
|
+
remote_smoke.add_argument(
|
|
861
|
+
"--env",
|
|
862
|
+
action="append",
|
|
863
|
+
help="override a form value, e.g. --env OV_VLM_API_KEY=sk-... (repeatable)",
|
|
864
|
+
)
|
|
865
|
+
remote_smoke.add_argument(
|
|
866
|
+
"--ready-timeout", dest="ready_timeout", type=int, default=90,
|
|
867
|
+
help="seconds to wait for the port to answer, default 90",
|
|
868
|
+
)
|
|
869
|
+
remote_smoke.add_argument("--keep", action="store_true", help="do not tear down afterwards")
|
|
870
|
+
remote_smoke.add_argument(
|
|
871
|
+
"--prune-image", dest="prune_image", action="store_true",
|
|
872
|
+
help="also remove the images the smoke test pulled",
|
|
873
|
+
)
|
|
874
|
+
remote_smoke.add_argument("--key-name", dest="key_name", help="override the container name prefix")
|
|
875
|
+
remote_smoke.add_argument("--json", action="store_true")
|
|
876
|
+
remote_smoke.set_defaults(func=cmd_remote)
|
|
877
|
+
remote_logs = remote_sub.add_parser("logs", help="read logs from a kept smoke directory")
|
|
878
|
+
remote_logs.add_argument("remote_dir")
|
|
879
|
+
remote_logs.set_defaults(func=cmd_remote)
|
|
880
|
+
return parser
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def main(argv: list[str] | None = None) -> int:
|
|
884
|
+
parser = build_parser()
|
|
885
|
+
args = parser.parse_args(argv)
|
|
886
|
+
return args.func(args)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
if __name__ == "__main__":
|
|
890
|
+
raise SystemExit(main())
|