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/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}')}")
|