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.
Files changed (47) hide show
  1. 1panel_toolkit-0.3.0.dist-info/METADATA +407 -0
  2. 1panel_toolkit-0.3.0.dist-info/RECORD +47 -0
  3. 1panel_toolkit-0.3.0.dist-info/WHEEL +4 -0
  4. 1panel_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
  5. 1panel_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. p1toolkit/__init__.py +3 -0
  7. p1toolkit/checks/__init__.py +21 -0
  8. p1toolkit/checks/compose.py +188 -0
  9. p1toolkit/checks/forms.py +129 -0
  10. p1toolkit/checks/hygiene.py +73 -0
  11. p1toolkit/checks/i18n.py +140 -0
  12. p1toolkit/checks/lessons.py +111 -0
  13. p1toolkit/checks/structure.py +140 -0
  14. p1toolkit/checks/substance.py +55 -0
  15. p1toolkit/cli.py +890 -0
  16. p1toolkit/config.py +114 -0
  17. p1toolkit/dependencies.py +218 -0
  18. p1toolkit/deploy.py +302 -0
  19. p1toolkit/depth.py +414 -0
  20. p1toolkit/errors.py +18 -0
  21. p1toolkit/evidence.py +523 -0
  22. p1toolkit/fix.py +394 -0
  23. p1toolkit/gen.py +728 -0
  24. p1toolkit/lifecycle.py +143 -0
  25. p1toolkit/loader.py +110 -0
  26. p1toolkit/logo.py +174 -0
  27. p1toolkit/model.py +71 -0
  28. p1toolkit/net.py +81 -0
  29. p1toolkit/panel.py +154 -0
  30. p1toolkit/patterns/__init__.py +250 -0
  31. p1toolkit/patterns/catalog.py +271 -0
  32. p1toolkit/patterns/data/patterns.json +724 -0
  33. p1toolkit/patterns/extract.py +402 -0
  34. p1toolkit/remote.py +554 -0
  35. p1toolkit/report.py +96 -0
  36. p1toolkit/resolve/__init__.py +554 -0
  37. p1toolkit/resolve/analyze.py +329 -0
  38. p1toolkit/resolve/sources.py +306 -0
  39. p1toolkit/rules/__init__.py +99 -0
  40. p1toolkit/rules/data/decisions.yaml +132 -0
  41. p1toolkit/rules/data/dependencies.yaml +113 -0
  42. p1toolkit/rules/data/envkeys.yaml +19 -0
  43. p1toolkit/rules/data/form-design.yaml +59 -0
  44. p1toolkit/rules/data/i18n.yaml +354 -0
  45. p1toolkit/rules/data/lessons.yaml +84 -0
  46. p1toolkit/rules/data/store.yaml +171 -0
  47. p1toolkit/selftest.py +211 -0
p1toolkit/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Local configuration: where the panel lives and how to talk to it.
2
+
3
+ Secrets never live in the repository. They live in a user-level file
4
+ (``%APPDATA%\\p1\\config.toml`` on Windows, ``~/.config/p1/config.toml``
5
+ elsewhere) or in the environment, which also makes CI usage straightforward.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ ENV_PREFIX = "P1_"
16
+
17
+
18
+ def config_path() -> Path:
19
+ override = os.environ.get("P1_CONFIG")
20
+ if override:
21
+ return Path(override).expanduser()
22
+ if os.name == "nt":
23
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
24
+ else:
25
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
26
+ return base / "p1" / "config.toml"
27
+
28
+
29
+ @dataclass
30
+ class Config:
31
+ values: dict[str, str] = field(default_factory=dict)
32
+ path: Path = field(default_factory=config_path)
33
+ source: str = "file"
34
+ broken: str = ""
35
+
36
+ def get(self, key: str, default: str | None = None) -> str | None:
37
+ env_key = ENV_PREFIX + key.upper().replace(".", "_")
38
+ if env_key in os.environ and os.environ[env_key]:
39
+ return os.environ[env_key]
40
+ return self.values.get(key, default)
41
+
42
+ def require(self, key: str) -> str:
43
+ value = self.get(key)
44
+ if not value:
45
+ raise KeyError(
46
+ f"{key} is not configured; run `p1 config set {key} <value>` "
47
+ f"or set {ENV_PREFIX}{key.upper().replace('.', '_')}"
48
+ )
49
+ return value
50
+
51
+ def set(self, key: str, value: str) -> None:
52
+ self.values[key] = value
53
+
54
+ def save(self) -> Path:
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ lines = ["# p1 configuration. Keep this file out of any repository.\n"]
57
+ for key in sorted(self.values):
58
+ # Quoted so `panel.url` stays one flat key instead of a TOML table.
59
+ lines.append(f'"{_escape(key)}" = "{_escape(self.values[key])}"\n')
60
+ self.path.write_text("".join(lines), encoding="utf-8")
61
+ try: # best effort: keep the file owner-only on POSIX
62
+ os.chmod(self.path, 0o600)
63
+ except OSError:
64
+ pass
65
+ return self.path
66
+
67
+ def masked(self) -> dict[str, str]:
68
+ return {key: mask(key, value) for key, value in sorted(self.values.items())}
69
+
70
+
71
+ def mask(key: str, value: str) -> str:
72
+ lowered = key.lower()
73
+ # `ssh.key` holds a path, so masking it just makes `config show` useless.
74
+ path_like = lowered.endswith((".key", "_key_path", ".path", "_path"))
75
+ if not path_like and any(
76
+ word in lowered for word in ("key", "token", "password", "secret")
77
+ ):
78
+ if len(value) <= 8:
79
+ return "*" * len(value)
80
+ return f"{value[:4]}{'*' * (len(value) - 8)}{value[-4:]}"
81
+ return value
82
+
83
+
84
+ def _escape(value: str) -> str:
85
+ """TOML basic strings treat backslash as an escape, so Windows paths break."""
86
+ return value.replace("\\", "\\\\").replace('"', '\\"')
87
+
88
+
89
+ def load(path: Path | None = None) -> Config:
90
+ target = path or config_path()
91
+ values: dict[str, str] = {}
92
+ broken = ""
93
+ if target.exists():
94
+ try:
95
+ with target.open("rb") as handle:
96
+ raw = tomllib.load(handle)
97
+ values = _flatten(raw)
98
+ except (tomllib.TOMLDecodeError, OSError) as exc:
99
+ # A corrupt config must not break every command; `p1 config set`
100
+ # rewrites the file, which is the natural repair path.
101
+ broken = f"{type(exc).__name__}: {exc}"
102
+ return Config(values=values, path=target, broken=broken)
103
+
104
+
105
+ def _flatten(raw: dict, prefix: str = "") -> dict[str, str]:
106
+ """Accept both ``"panel.url" = x`` and a ``[panel] url = x`` table."""
107
+ flat: dict[str, str] = {}
108
+ for key, value in raw.items():
109
+ name = f"{prefix}{key}"
110
+ if isinstance(value, dict):
111
+ flat.update(_flatten(value, prefix=f"{name}."))
112
+ elif isinstance(value, (str, int, float, bool)):
113
+ flat[name] = str(value)
114
+ return flat
@@ -0,0 +1,218 @@
1
+ """Wire a generated package to a dependency 1Panel already runs (D10).
2
+
3
+ The install form gets a selector, the compose gets the application's own
4
+ variable names. Two rules keep this honest:
5
+
6
+ * **The mapping is the spec's job.** Only the packager knows that this app
7
+ reads `SPRING_DATASOURCE_URL`, not `DATABASE_URL`; the generator never
8
+ invents an application variable name.
9
+ * **Only referenced variables become form fields.** Official packages declare
10
+ PANEL_DB_NAME / USER / USER_PASSWORD / PORT themselves, but a field nobody
11
+ needs is a box the user fills in for nothing (the form budget is the whole
12
+ point of `p1 check`'s F rules).
13
+
14
+ The shapes come from ``rules/data/dependencies.yaml``, measured on the official
15
+ corpus rather than recalled.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import Any
22
+
23
+ from . import rules
24
+ from .errors import DependencyError
25
+
26
+ ENV_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
27
+
28
+
29
+ def _env_map(entry: dict[str, Any], index: int) -> dict[str, str]:
30
+ raw = entry.get("env")
31
+ if isinstance(raw, dict) and raw:
32
+ return {str(key): str(value) for key, value in raw.items()}
33
+ raise DependencyError(
34
+ f"dependencies[{index}] 没有可用映射 env;"
35
+ "依赖只声明 kind 是不够的:生成器需要知道应用自己读哪个变量名(D01 证据),"
36
+ '例如 "env": {"DATABASE_HOST": "${PANEL_DB_HOST}"}'
37
+ )
38
+
39
+
40
+ def _kinds_of(entry: dict[str, Any], primary: dict[str, Any]) -> list[dict[str, Any]]:
41
+ requested = entry.get("kinds") or [primary["key"]]
42
+ if not isinstance(requested, list) or not requested:
43
+ raise DependencyError("dependencies[].kinds 必须是非空数组")
44
+ resolved: list[dict[str, Any]] = []
45
+ for key in requested:
46
+ definition = rules.dependency_kind(str(key))
47
+ if not definition:
48
+ raise DependencyError(_unknown_kind(str(key)))
49
+ if definition.get("family") != primary.get("family"):
50
+ raise DependencyError(
51
+ f"dependencies[].kinds 里的 {key} 与 kind={primary['key']} 不属于同一个变量族"
52
+ f"({definition.get('family')} vs {primary.get('family')})"
53
+ )
54
+ resolved.append(definition)
55
+ return resolved
56
+
57
+
58
+ def _unknown_kind(key: str) -> str:
59
+ known = ", ".join(str(item["key"]) for item in rules.dependency_kinds())
60
+ return (
61
+ f"未知的依赖 kind {key!r};目前支持 {known}。"
62
+ "新款面板应用需要先把变量表加进 rules/data/dependencies.yaml(那是数据,不是代码)"
63
+ )
64
+
65
+
66
+ def _resolve_default(spec: dict[str, Any], kind: dict[str, Any], raw: Any) -> Any:
67
+ if raw == "app_key":
68
+ return str(spec.get("key") or "")
69
+ if raw == "kind_port":
70
+ return int(kind.get("default_port") or 0)
71
+ return raw
72
+
73
+
74
+ def _selector_field(
75
+ definition: dict[str, Any],
76
+ family: dict[str, Any],
77
+ style: str,
78
+ options: list[dict[str, Any]],
79
+ ) -> dict[str, Any]:
80
+ host_key = str(family["host_envkey"])
81
+ label_en = str(family.get("selector_label_en") or "Service")
82
+ label_zh = str(family.get("selector_label_zh") or label_en)
83
+ if style == "apps":
84
+ selector_key = str(family.get("selector_envkey") or "")
85
+ if not selector_key:
86
+ raise DependencyError(
87
+ f"{definition['key']} 只有一步式选择器(style: service):"
88
+ "它的变量族里没有「引擎类型」这一层"
89
+ )
90
+ return {
91
+ "envKey": selector_key,
92
+ "type": "apps",
93
+ "required": True,
94
+ "default": str(definition["key"]),
95
+ "values": [
96
+ {"label": str(item.get("label") or item["key"]), "value": str(item["key"])}
97
+ for item in options
98
+ ],
99
+ "child": {
100
+ "envKey": host_key,
101
+ "type": "service",
102
+ "required": True,
103
+ "default": "",
104
+ },
105
+ "labelEn": label_en,
106
+ "labelZh": label_zh,
107
+ }
108
+ # One-step: the host field *is* the selector, and `key` names the panel app.
109
+ return {
110
+ "envKey": host_key,
111
+ "type": "service",
112
+ "key": str(definition["key"]),
113
+ "required": True,
114
+ "default": "",
115
+ "edit": True,
116
+ "labelEn": label_en,
117
+ "labelZh": label_zh,
118
+ }
119
+
120
+
121
+ def _variable_field(
122
+ spec: dict[str, Any],
123
+ definition: dict[str, Any],
124
+ env_key: str,
125
+ variable: dict[str, Any],
126
+ ) -> dict[str, Any]:
127
+ field: dict[str, Any] = {
128
+ "envKey": env_key,
129
+ "type": str(variable.get("type") or "text"),
130
+ "required": True,
131
+ "labelEn": str(variable.get("label_en") or env_key),
132
+ "labelZh": str(variable.get("label_zh") or env_key),
133
+ }
134
+ if "default" in variable:
135
+ field["default"] = _resolve_default(spec, definition, variable["default"])
136
+ elif field["type"] == "password":
137
+ # A secret has no usable default; the panel asks for it (official
138
+ # packages ship an empty password field).
139
+ field["default"] = ""
140
+ if variable.get("rule"):
141
+ field["rule"] = variable["rule"]
142
+ return field
143
+
144
+
145
+ def build(
146
+ spec: dict[str, Any],
147
+ ) -> tuple[list[dict[str, Any]], dict[str, list[str]], list[str]]:
148
+ """Return (form fields, env lines per service, notes for the operator)."""
149
+ entries = spec.get("dependencies")
150
+ if not entries:
151
+ return [], {}, []
152
+ if not isinstance(entries, list):
153
+ raise DependencyError("dependencies 必须是数组")
154
+
155
+ services = spec.get("services") or []
156
+ default_service = str(services[0].get("name") or "") if services else ""
157
+ fields: list[dict[str, Any]] = []
158
+ env_by_service: dict[str, list[str]] = {}
159
+ notes: list[str] = []
160
+
161
+ for index, entry in enumerate(entries):
162
+ if not isinstance(entry, dict):
163
+ raise DependencyError(f"dependencies[{index}] 必须是对象")
164
+ kind_key = str(entry.get("kind") or "")
165
+ definition = rules.dependency_kind(kind_key)
166
+ if not definition:
167
+ raise DependencyError(_unknown_kind(kind_key))
168
+ family = rules.dependency_family(str(definition.get("family") or ""))
169
+ if not family:
170
+ raise DependencyError(
171
+ f"kind={kind_key} 指向的 family {definition.get('family')!r} 在 "
172
+ "rules/data/dependencies.yaml 里不存在"
173
+ )
174
+
175
+ # Default to the two-step shape (the official majority), except for
176
+ # families that only have one instance type, such as Redis.
177
+ default_style = "apps" if family.get("selector_envkey") else "service"
178
+ style = str(entry.get("style") or default_style)
179
+ if style not in rules.dependency_styles():
180
+ raise DependencyError(
181
+ f"dependencies[{index}].style={style!r} 不是 "
182
+ f"{sorted(rules.dependency_styles())} 之一"
183
+ )
184
+
185
+ env = _env_map(entry, index)
186
+ options = _kinds_of(entry, definition)
187
+ host_key = str(family["host_envkey"])
188
+ selector_key = str(family.get("selector_envkey") or "")
189
+ variables = family.get("variables") or {}
190
+
191
+ refs = sorted({ref for value in env.values() for ref in ENV_REF.findall(value)})
192
+ promoted = {host_key, selector_key}
193
+ unknown = [ref for ref in refs if ref not in variables and ref not in promoted]
194
+ if unknown:
195
+ known = sorted(set(variables) | promoted)
196
+ raise DependencyError(
197
+ f"{kind_key}: 引用了面板没有提供的变量 {unknown};"
198
+ f"这个 family 可用的是 {known}"
199
+ )
200
+
201
+ fields.append(_selector_field(definition, family, style, options))
202
+ for ref in refs:
203
+ if ref in promoted:
204
+ continue
205
+ fields.append(_variable_field(spec, definition, ref, variables[ref]))
206
+
207
+ target = str(entry.get("service") or default_service)
208
+ env_by_service.setdefault(target, []).extend(
209
+ f"{name}={value}" for name, value in env.items()
210
+ )
211
+ notes.append(
212
+ f"{kind_key}: 用户在表单里选实例,"
213
+ f"{', '.join(sorted(env))} 接到 {target or '(第一个服务)'} 上"
214
+ )
215
+ return fields, env_by_service, notes
216
+
217
+
218
+ __all__ = ["DependencyError", "build"]
p1toolkit/deploy.py ADDED
@@ -0,0 +1,302 @@
1
+ """Install a package through 1Panel itself, not around it.
2
+
3
+ An SSH smoke test proves the containers start; it does not prove the panel can
4
+ install the thing. This module walks the real path a user walks:
5
+
6
+ 1. put the package in the panel's local app directory
7
+ 2. make the panel rescan local apps
8
+ 3. resolve the app and its version record to get `appDetailId`
9
+ 4. call the install endpoint
10
+ 5. watch the container until it is up, then take a probe
11
+
12
+ The shapes below were discovered from the running panel (1Panel v2.2.4) rather
13
+ than guessed: the validator names the required fields when you post an empty
14
+ body, and the detail endpoint hands back the id the installer wants.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import shlex
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ import yaml
26
+
27
+ from .panel import PanelClient, PanelError
28
+ from .remote import SshTarget, _tarball
29
+
30
+ LOCAL_APP_ROOT = "/opt/1panel/resource/apps/local"
31
+ DEFAULT_INSTALL_TIMEOUT = 600
32
+
33
+
34
+ @dataclass
35
+ class DeployResult:
36
+ ok: bool
37
+ stage: str
38
+ app_key: str = ""
39
+ install_id: int | None = None
40
+ message: str = ""
41
+ containers: list[str] = field(default_factory=list)
42
+ remote_dir: str = ""
43
+ install_log: str = ""
44
+ kept: bool = False
45
+
46
+ def render(self) -> str:
47
+ lines = [f"{'PASS' if self.ok else 'FAIL'} deploy/{self.stage}"]
48
+ if self.app_key:
49
+ lines.append(f" 应用: {self.app_key}")
50
+ if self.install_id:
51
+ lines.append(f" 安装记录: {self.install_id}")
52
+ if self.message:
53
+ lines.append(f" {self.message}")
54
+ for item in self.containers:
55
+ lines.append(f" 容器: {item}")
56
+ if self.remote_dir:
57
+ lines.append(f" 远端: {self.remote_dir}")
58
+ if not self.ok and self.install_log:
59
+ lines.append(" --- 安装日志尾部 ---")
60
+ for line in self.install_log.strip().splitlines()[-20:]:
61
+ lines.append(f" | {line}")
62
+ return "\n".join(lines)
63
+
64
+
65
+ class DeployError(Exception):
66
+ pass
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # steps
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ def upload(target: SshTarget, package_root: Path, key: str) -> str:
75
+ """Place the package where 1Panel looks for local apps."""
76
+ # Quote every interpolated path: a package directory containing a space or
77
+ # a `;` would otherwise split the command, and the first command here is
78
+ # `rm -rf`.
79
+ remote = f"{LOCAL_APP_ROOT}/{key}"
80
+ quoted = shlex.quote(remote)
81
+ target.must(f"rm -rf {quoted} && mkdir -p {quoted}")
82
+ payload = _tarball(package_root)
83
+ code, _, err = target.run(f"tar -xzf - -C {quoted}", input_bytes=payload, timeout=300)
84
+ if code != 0:
85
+ raise DeployError(f"上传失败: {err.strip()[:300]}")
86
+ # 1Panel reads these as its own; keep ownership consistent with the rest.
87
+ target.run(f"chown -R root:root {quoted}")
88
+ return remote
89
+
90
+
91
+ def sync_local(client: PanelClient) -> dict:
92
+ return client.request("/apps/sync/local", "POST", {})
93
+
94
+
95
+ def find_app(client: PanelClient, key: str, display_name: str = "", attempts: int = 8) -> dict:
96
+ """Local apps are registered as `local<key>`; wait for the rescan to land.
97
+
98
+ The search endpoint filters on the display name, not on the key, so match
99
+ locally over a full page instead of trusting the server-side filter.
100
+ """
101
+ wanted = {key, f"local{key}"}
102
+ for attempt in range(attempts):
103
+ data = client.request(
104
+ "/apps/search", "POST", {"page": 1, "pageSize": 500}
105
+ )
106
+ for item in data.get("items") or []:
107
+ item_key = str(item.get("key") or "")
108
+ item_name = str(item.get("name") or "")
109
+ if item_key in wanted or (display_name and item_name == display_name):
110
+ return item
111
+ if attempt < attempts - 1:
112
+ time.sleep(2)
113
+ raise DeployError(
114
+ f"面板里找不到应用 {key}(同步本地应用后仍未出现;"
115
+ "确认包根目录含 data.yml,且 key 只用小写字母数字和连字符)"
116
+ )
117
+
118
+
119
+ def display_name(package_root: Path) -> str:
120
+ """The name the panel lists the app under (root data.yml `name`)."""
121
+ path = package_root / "data.yml"
122
+ try:
123
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
124
+ except (OSError, yaml.YAMLError):
125
+ return ""
126
+ return str(data.get("name") or "")
127
+
128
+
129
+ def app_detail(client: PanelClient, app_id: int, version: str, app_type: str) -> dict:
130
+ return client.request(f"/apps/detail/{app_id}/{version}/{app_type}")
131
+
132
+
133
+ def install(
134
+ client: PanelClient,
135
+ app_detail_id: int,
136
+ name: str,
137
+ params: dict[str, str] | None = None,
138
+ advanced: bool = False,
139
+ allow_port: bool = True,
140
+ ) -> int:
141
+ payload: dict[str, object] = {
142
+ "appDetailId": app_detail_id,
143
+ "name": name,
144
+ "advanced": advanced,
145
+ "allowPort": allow_port,
146
+ }
147
+ if params:
148
+ # v2 takes the install-form values as an object (v1 wanted a JSON
149
+ # string); sending the wrong one yields a Go unmarshal error.
150
+ payload["params"] = dict(params)
151
+ data = client.request("/apps/install", "POST", payload)
152
+ return int(data.get("installId") or data.get("id") or 0)
153
+
154
+
155
+ def installed_apps(client: PanelClient, name: str = "") -> list[dict]:
156
+ payload = {"page": 1, "pageSize": 100}
157
+ if name:
158
+ payload["name"] = name
159
+ data = client.request("/apps/installed/search", "POST", payload)
160
+ return list(data.get("items") or [])
161
+
162
+
163
+ def uninstall(client: PanelClient, install_id: int, delete_backup: bool = False) -> None:
164
+ client.request(
165
+ "/apps/installed/op",
166
+ "POST",
167
+ {
168
+ "installId": install_id,
169
+ "operate": "delete",
170
+ "deleteBackup": delete_backup,
171
+ "forceDelete": True,
172
+ },
173
+ )
174
+
175
+
176
+ def container_lines(client: PanelClient, needle: str) -> list[str]:
177
+ """Container names and state matching a needle, for the report."""
178
+ try:
179
+ items = client.containers()
180
+ except PanelError:
181
+ return []
182
+ out = []
183
+ for item in items:
184
+ name = str(item.get("name") or item.get("containerName") or "")
185
+ if needle and needle not in name:
186
+ continue
187
+ state = str(item.get("state") or item.get("status") or "")
188
+ image = str(item.get("image") or "")
189
+ out.append(f"{name} {state} {image}".strip())
190
+ return out
191
+
192
+
193
+ RUNNING = "running"
194
+
195
+
196
+ def installed_record(client: PanelClient, name: str) -> dict | None:
197
+ """The panel's own record of an installed app, which carries its status."""
198
+ for item in installed_apps(client):
199
+ if str(item.get("name")) == name:
200
+ return item
201
+ return None
202
+
203
+
204
+ def _status_of(record: dict | None) -> str:
205
+ return str((record or {}).get("status") or "").lower()
206
+
207
+
208
+ # --------------------------------------------------------------------------
209
+ # orchestration
210
+ # --------------------------------------------------------------------------
211
+
212
+
213
+ def deploy(
214
+ target: SshTarget,
215
+ client: PanelClient,
216
+ package_root: Path,
217
+ name: str | None = None,
218
+ version: str = "latest",
219
+ params: dict[str, str] | None = None,
220
+ wait: bool = True,
221
+ timeout: int = DEFAULT_INSTALL_TIMEOUT,
222
+ uninstall_after: bool = False,
223
+ allow_port: bool = True,
224
+ ) -> DeployResult:
225
+ key = package_root.name
226
+ app_name = name or key
227
+ stage = "upload"
228
+ remote_dir = ""
229
+ install_id: int | None = None
230
+ try:
231
+ remote_dir = upload(target, package_root, key)
232
+ stage = "sync"
233
+ sync_local(client)
234
+ stage = "resolve"
235
+ app = find_app(client, key, display_name=display_name(package_root))
236
+ app_key = str(app.get("key") or key)
237
+ app_type = str(app.get("type") or "tool")
238
+ detail = app_detail(client, int(app["id"]), version, app_type)
239
+ stage = "install"
240
+ install_id = install(
241
+ client,
242
+ int(detail["id"]),
243
+ app_name,
244
+ params=params,
245
+ allow_port=allow_port,
246
+ )
247
+ if not wait:
248
+ return DeployResult(
249
+ ok=True,
250
+ stage="install-submitted",
251
+ app_key=app_key,
252
+ install_id=install_id,
253
+ message="已提交安装,未等待完成",
254
+ remote_dir=remote_dir,
255
+ kept=True,
256
+ )
257
+
258
+ stage = "wait"
259
+ deadline = time.time() + timeout
260
+ record: dict | None = None
261
+ while time.time() < deadline:
262
+ record = installed_record(client, app_name)
263
+ status = _status_of(record)
264
+ if status == RUNNING:
265
+ break
266
+ if status in {"error", "failed", "unhealthy"}:
267
+ break
268
+ time.sleep(5)
269
+
270
+ status = _status_of(record)
271
+ ok = status == RUNNING
272
+ containers = container_lines(client, app_name)
273
+ if record and record.get("container"):
274
+ containers.insert(0, f"{record['container']} status={record.get('status')}")
275
+ result = DeployResult(
276
+ ok=ok,
277
+ stage="verify",
278
+ app_key=app_key,
279
+ install_id=install_id,
280
+ message="" if ok else f"安装状态为 {status or '未知'}",
281
+ containers=containers,
282
+ remote_dir=remote_dir,
283
+ kept=not ok,
284
+ )
285
+ if ok and uninstall_after:
286
+ uninstall(client, install_id)
287
+ result.message = "已安装并验证,随后按要求卸载"
288
+ result.kept = False
289
+ return result
290
+ except (DeployError, PanelError) as exc:
291
+ return DeployResult(
292
+ ok=False,
293
+ stage=stage,
294
+ install_id=install_id,
295
+ message=str(exc),
296
+ remote_dir=remote_dir,
297
+ kept=True,
298
+ )
299
+
300
+
301
+ def remove_local(target: SshTarget, key: str) -> None:
302
+ target.run(f"rm -rf {shlex.quote(f'{LOCAL_APP_ROOT}/{key}')}")