ai-project-manage-cli 6.0.65 → 6.0.67
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.
- package/dist/index.js +1151 -431
- package/package.json +3 -1
- package/template/apm.config.json +2 -6
- package/template/deploy/README.md +5 -6
- package/template/skills/apm-deploy/SKILL.md +4 -6
- package/template/deploy/deploy.py +0 -564
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-project-manage-cli",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.67",
|
|
4
4
|
"description": "命令行工具:后续用于调用平台后端 API 完成运维与自动化操作",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"esbuild": "~0.28.0",
|
|
26
26
|
"vitest": "~4.1.5",
|
|
27
27
|
"@types/dockerode": "~4.0.1",
|
|
28
|
+
"@types/ssh2": "~1.15.0",
|
|
28
29
|
"@types/ssh2-sftp-client": "~9.0.6"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"yaml": "~2.8.4",
|
|
42
43
|
"minio": "~8.0.7",
|
|
43
44
|
"dockerode": "~5.0.0",
|
|
45
|
+
"ssh2": "~1.16.0",
|
|
44
46
|
"ssh2-sftp-client": "~12.0.1",
|
|
45
47
|
"jszip": "~3.10.1"
|
|
46
48
|
}
|
package/template/apm.config.json
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
##
|
|
1
|
+
## 部署
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
部署测试环境:`apm deploy test`
|
|
4
|
+
部署线上环境:`apm deploy online`
|
|
5
5
|
|
|
6
|
-
## 后端部署(不区分正式环境和测试环境)
|
|
7
|
-
`python .apm/deploy/deploy.py`
|
|
8
6
|
|
|
9
7
|
## 前端产物下载地址
|
|
10
8
|
|
|
@@ -12,4 +10,5 @@ http://<服务器地址>/dist.zip
|
|
|
12
10
|
http://<服务器地址>/{name}.jar.zip
|
|
13
11
|
|
|
14
12
|
## 测试地址
|
|
15
|
-
|
|
13
|
+
|
|
14
|
+
http://<服务器地址>
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
## 工作流程
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
使用**Read**工具阅读 `.apm/deploy/README.md`
|
|
3
|
+
在项目根目录执行 `apm deploy <环境名称>`
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
## 示例
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
2. 用户没有明确部署环境
|
|
7
|
+
1. 部署测试环境:`apm deploy test`
|
|
8
|
+
2. 部署正式环境: `apm deploy online`
|
|
@@ -1,564 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""自动部署脚本"""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import posixpath
|
|
8
|
-
import re
|
|
9
|
-
import shlex
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import time
|
|
14
|
-
import zipfile
|
|
15
|
-
from dataclasses import dataclass
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
from typing import Any
|
|
19
|
-
|
|
20
|
-
import paramiko
|
|
21
|
-
|
|
22
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
23
|
-
PROJECT_ROOT = SCRIPT_DIR.parent.parent
|
|
24
|
-
APM_CONFIG_PATH = PROJECT_ROOT / ".apm" / "apm.config.json"
|
|
25
|
-
DEPLOY_CACHE_DIR = SCRIPT_DIR / ".deploy_cache"
|
|
26
|
-
MANIFEST_FILE = DEPLOY_CACHE_DIR / "manifest.json"
|
|
27
|
-
SPRINGBOOT_SCRIPT = "springboot.sh"
|
|
28
|
-
MAVEN_MODULE = "jeecg-module-system/jeecg-system-start"
|
|
29
|
-
MAVEN_PROFILE = "dev"
|
|
30
|
-
|
|
31
|
-
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
|
32
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
33
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def log(message: str) -> None:
|
|
37
|
-
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def fail(message: str, code: int = 1) -> None:
|
|
41
|
-
log(f"ERROR: {message}")
|
|
42
|
-
sys.exit(code)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def expand_path(path_str: str) -> Path:
|
|
46
|
-
return Path(path_str).expanduser().resolve()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load_apm_config(apm_path: Path) -> dict[str, Any]:
|
|
50
|
-
with apm_path.open("r", encoding="utf-8") as handle:
|
|
51
|
-
data = json.load(handle)
|
|
52
|
-
|
|
53
|
-
name = str(data["name"]).strip()
|
|
54
|
-
deploy = data["wisdomDeploy"]
|
|
55
|
-
health = data["healthCheck"]
|
|
56
|
-
|
|
57
|
-
jar_path = str(deploy["jarPath"]).strip()
|
|
58
|
-
remote_app_dir = posixpath.dirname(jar_path)
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
"project_name": name,
|
|
62
|
-
"host": deploy["host"],
|
|
63
|
-
"port": deploy["port"],
|
|
64
|
-
"username": deploy["username"],
|
|
65
|
-
"password": deploy["password"],
|
|
66
|
-
"remote_vue_dist_dir": str(deploy["remotePath"]).strip(),
|
|
67
|
-
"remote_app_dir": remote_app_dir,
|
|
68
|
-
"remote_lib_dir": posixpath.join(remote_app_dir, "lib"),
|
|
69
|
-
"startup_jar": posixpath.basename(jar_path),
|
|
70
|
-
"package_name": f"{name}.jar.zip",
|
|
71
|
-
"maven_local_repo": str(deploy["mavenLocalRepo"]).strip(),
|
|
72
|
-
"health_check_port": health["port"],
|
|
73
|
-
"health_check_context": health["context"],
|
|
74
|
-
"health_check_timeout": health["timeout"],
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def load_config() -> dict[str, Any]:
|
|
79
|
-
return load_apm_config(APM_CONFIG_PATH)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def get_target_dir() -> Path:
|
|
83
|
-
return PROJECT_ROOT / MAVEN_MODULE / "target"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def get_maven_local_repo(config: dict[str, Any]) -> Path:
|
|
87
|
-
return expand_path(str(config["maven_local_repo"]).strip())
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass
|
|
91
|
-
class UpdateEntry:
|
|
92
|
-
path: Path
|
|
93
|
-
arcname: str
|
|
94
|
-
reason: str
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def relative_key(path: Path) -> str:
|
|
98
|
-
return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def file_signature(path: Path) -> dict[str, float | int]:
|
|
102
|
-
stat = path.stat()
|
|
103
|
-
return {"size": int(stat.st_size), "mtime": float(stat.st_mtime)}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def load_manifest() -> dict[str, dict[str, float | int]]:
|
|
107
|
-
if not MANIFEST_FILE.exists():
|
|
108
|
-
return {}
|
|
109
|
-
with MANIFEST_FILE.open("r", encoding="utf-8") as handle:
|
|
110
|
-
return json.load(handle)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def save_manifest(manifest: dict[str, dict[str, float | int]]) -> None:
|
|
114
|
-
DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
115
|
-
with MANIFEST_FILE.open("w", encoding="utf-8") as handle:
|
|
116
|
-
json.dump(manifest, handle, ensure_ascii=False, indent=2)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def is_project_lib_jar(jar_name: str) -> bool:
|
|
120
|
-
return jar_name.startswith("jeecg-")
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def list_lib_files_to_upload(
|
|
124
|
-
local_lib_dir: Path,
|
|
125
|
-
remote_stats: dict[str, paramiko.SFTPAttributes],
|
|
126
|
-
*,
|
|
127
|
-
manifest: dict[str, dict[str, float | int]] | None = None,
|
|
128
|
-
) -> list[UpdateEntry]:
|
|
129
|
-
entries: list[UpdateEntry] = []
|
|
130
|
-
for jar_file in sorted(local_lib_dir.glob("*.jar")):
|
|
131
|
-
remote_attr = remote_stats.get(jar_file.name)
|
|
132
|
-
should_upload, reason = should_upload_lib_file(
|
|
133
|
-
jar_file,
|
|
134
|
-
remote_attr,
|
|
135
|
-
manifest,
|
|
136
|
-
)
|
|
137
|
-
if should_upload:
|
|
138
|
-
entries.append(
|
|
139
|
-
UpdateEntry(
|
|
140
|
-
path=jar_file,
|
|
141
|
-
arcname=jar_file.name,
|
|
142
|
-
reason=reason,
|
|
143
|
-
)
|
|
144
|
-
)
|
|
145
|
-
return entries
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def create_update_package(entries: list[UpdateEntry], package_name: str) -> Path:
|
|
149
|
-
DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
150
|
-
zip_path = DEPLOY_CACHE_DIR / package_name
|
|
151
|
-
|
|
152
|
-
log(f"创建更新包: {zip_path.name}({len(entries)} 个文件)")
|
|
153
|
-
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
154
|
-
for entry in entries:
|
|
155
|
-
archive.write(entry.path, arcname=entry.arcname)
|
|
156
|
-
log(f" 打包: {entry.arcname} ({entry.reason})")
|
|
157
|
-
|
|
158
|
-
return zip_path
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def upload_update_package(
|
|
162
|
-
sftp: paramiko.SFTPClient,
|
|
163
|
-
zip_path: Path,
|
|
164
|
-
config: dict[str, Any],
|
|
165
|
-
) -> str:
|
|
166
|
-
remote_dir = config["remote_vue_dist_dir"]
|
|
167
|
-
remote_path = f"{remote_dir}/{zip_path.name}"
|
|
168
|
-
|
|
169
|
-
log(f"上传更新包 -> {remote_path}")
|
|
170
|
-
|
|
171
|
-
try:
|
|
172
|
-
sftp.put(str(zip_path), remote_path)
|
|
173
|
-
log("更新包上传成功")
|
|
174
|
-
except OSError as exc:
|
|
175
|
-
fail(f"更新包上传失败: {exc}")
|
|
176
|
-
|
|
177
|
-
return remote_path
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def update_manifest_entries(
|
|
181
|
-
manifest: dict[str, dict[str, float | int]],
|
|
182
|
-
entries: list[UpdateEntry],
|
|
183
|
-
) -> dict[str, dict[str, float | int]]:
|
|
184
|
-
for entry in entries:
|
|
185
|
-
manifest[relative_key(entry.path)] = file_signature(entry.path)
|
|
186
|
-
return manifest
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def get_remote_file_stats(
|
|
190
|
-
sftp: paramiko.SFTPClient,
|
|
191
|
-
remote_dir: str,
|
|
192
|
-
) -> dict[str, paramiko.SFTPAttributes]:
|
|
193
|
-
stats: dict[str, paramiko.SFTPAttributes] = {}
|
|
194
|
-
try:
|
|
195
|
-
for attr in sftp.listdir_attr(remote_dir):
|
|
196
|
-
if attr.filename.endswith(".jar"):
|
|
197
|
-
stats[attr.filename] = attr
|
|
198
|
-
except FileNotFoundError:
|
|
199
|
-
pass
|
|
200
|
-
return stats
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def should_upload_lib_file(
|
|
204
|
-
local_path: Path,
|
|
205
|
-
remote_attr: paramiko.SFTPAttributes | None,
|
|
206
|
-
manifest: dict[str, dict[str, float | int]] | None = None,
|
|
207
|
-
) -> tuple[bool, str]:
|
|
208
|
-
if remote_attr is None:
|
|
209
|
-
return False, "远程不存在,跳过"
|
|
210
|
-
|
|
211
|
-
local_size = int(local_path.stat().st_size)
|
|
212
|
-
remote_size = int(remote_attr.st_size)
|
|
213
|
-
if local_size != remote_size:
|
|
214
|
-
return True, f"大小变化 {remote_size} -> {local_size}"
|
|
215
|
-
|
|
216
|
-
if is_project_lib_jar(local_path.name) and manifest is not None:
|
|
217
|
-
key = relative_key(local_path)
|
|
218
|
-
current = file_signature(local_path)
|
|
219
|
-
previous = manifest.get(key)
|
|
220
|
-
if previous is None:
|
|
221
|
-
return True, "项目模块未记录"
|
|
222
|
-
if int(previous["size"]) != current["size"]:
|
|
223
|
-
return True, "项目模块大小变化"
|
|
224
|
-
if float(previous["mtime"]) < current["mtime"]:
|
|
225
|
-
return True, "项目模块重新构建"
|
|
226
|
-
|
|
227
|
-
return False, "大小一致,跳过"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def get_mvn_executable() -> str:
|
|
231
|
-
candidates = ("mvn.cmd", "mvn.bat", "mvn") if sys.platform == "win32" else ("mvn",)
|
|
232
|
-
for name in candidates:
|
|
233
|
-
found = shutil.which(name)
|
|
234
|
-
if found:
|
|
235
|
-
return found
|
|
236
|
-
fail("未找到 mvn 命令,请确认 Maven 已安装并加入 PATH")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def run_maven_build(config: dict[str, Any]) -> None:
|
|
240
|
-
profile = MAVEN_PROFILE
|
|
241
|
-
maven_repo = get_maven_local_repo(config)
|
|
242
|
-
cmd = [
|
|
243
|
-
get_mvn_executable(),
|
|
244
|
-
"clean",
|
|
245
|
-
"package",
|
|
246
|
-
f"-P{profile}",
|
|
247
|
-
f"-Dmaven.repo.local={maven_repo}",
|
|
248
|
-
"-DskipTests",
|
|
249
|
-
]
|
|
250
|
-
|
|
251
|
-
log(f"开始 Maven 构建: {' '.join(cmd)}")
|
|
252
|
-
log(f"Maven 本地仓库: {maven_repo}")
|
|
253
|
-
result = subprocess.run(
|
|
254
|
-
cmd,
|
|
255
|
-
cwd=PROJECT_ROOT,
|
|
256
|
-
)
|
|
257
|
-
if result.returncode != 0:
|
|
258
|
-
fail(f"Maven 构建失败,退出码: {result.returncode}")
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def locate_lib_dir() -> Path:
|
|
262
|
-
target_dir = get_target_dir()
|
|
263
|
-
if not target_dir.exists():
|
|
264
|
-
fail(f"构建产物目录不存在: {target_dir}")
|
|
265
|
-
|
|
266
|
-
lib_dir = target_dir / "lib"
|
|
267
|
-
if not lib_dir.is_dir():
|
|
268
|
-
fail(f"lib 目录不存在: {lib_dir}")
|
|
269
|
-
|
|
270
|
-
lib_jars = list(lib_dir.glob("*.jar"))
|
|
271
|
-
if not lib_jars:
|
|
272
|
-
fail(f"lib 目录下没有依赖 JAR: {lib_dir}")
|
|
273
|
-
|
|
274
|
-
log(f"定位 lib 产物: {len(lib_jars)} 个")
|
|
275
|
-
return lib_dir
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def connect_ssh(config: dict[str, Any]) -> paramiko.SSHClient:
|
|
279
|
-
client = paramiko.SSHClient()
|
|
280
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
281
|
-
|
|
282
|
-
log(f"连接服务器 {config['username']}@{config['host']}:{config['port']}")
|
|
283
|
-
connect_kwargs: dict[str, Any] = {
|
|
284
|
-
"hostname": config["host"],
|
|
285
|
-
"port": int(config["port"]),
|
|
286
|
-
"username": config["username"],
|
|
287
|
-
"password": config["password"],
|
|
288
|
-
"timeout": 30,
|
|
289
|
-
"allow_agent": False,
|
|
290
|
-
"look_for_keys": False,
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
try:
|
|
294
|
-
client.connect(**connect_kwargs)
|
|
295
|
-
except Exception as exc:
|
|
296
|
-
fail(f"SSH 连接失败: {exc}")
|
|
297
|
-
|
|
298
|
-
return client
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def read_channel_stream(channel: paramiko.Channel) -> str:
|
|
302
|
-
"""流式读取 SSH 通道输出,避免长时间命令无回显。"""
|
|
303
|
-
chunks: list[str] = []
|
|
304
|
-
while not channel.closed:
|
|
305
|
-
if channel.recv_ready():
|
|
306
|
-
data = channel.recv(4096)
|
|
307
|
-
if not data:
|
|
308
|
-
break
|
|
309
|
-
text = data.decode("utf-8", errors="replace")
|
|
310
|
-
chunks.append(text)
|
|
311
|
-
print(text, end="", flush=True)
|
|
312
|
-
elif channel.exit_status_ready():
|
|
313
|
-
while channel.recv_ready():
|
|
314
|
-
data = channel.recv(4096)
|
|
315
|
-
if data:
|
|
316
|
-
text = data.decode("utf-8", errors="replace")
|
|
317
|
-
chunks.append(text)
|
|
318
|
-
print(text, end="", flush=True)
|
|
319
|
-
break
|
|
320
|
-
else:
|
|
321
|
-
time.sleep(0.2)
|
|
322
|
-
|
|
323
|
-
combined = "".join(chunks)
|
|
324
|
-
if combined and not combined.endswith("\n"):
|
|
325
|
-
print()
|
|
326
|
-
return combined.strip()
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def run_remote_command(
|
|
330
|
-
client: paramiko.SSHClient,
|
|
331
|
-
command: str,
|
|
332
|
-
*,
|
|
333
|
-
check: bool = True,
|
|
334
|
-
get_pty: bool = False,
|
|
335
|
-
stream: bool = False,
|
|
336
|
-
timeout_secs: float | None = None,
|
|
337
|
-
) -> tuple[int, str, str]:
|
|
338
|
-
log(f"远程执行: {command}")
|
|
339
|
-
_, stdout, stderr = client.exec_command(command, get_pty=get_pty)
|
|
340
|
-
channel = stdout.channel
|
|
341
|
-
if timeout_secs is not None:
|
|
342
|
-
channel.settimeout(timeout_secs)
|
|
343
|
-
|
|
344
|
-
if stream:
|
|
345
|
-
out = read_channel_stream(channel)
|
|
346
|
-
err = ""
|
|
347
|
-
else:
|
|
348
|
-
out = stdout.read().decode("utf-8", errors="replace").strip()
|
|
349
|
-
err = "" if get_pty else stderr.read().decode("utf-8", errors="replace").strip()
|
|
350
|
-
|
|
351
|
-
exit_code = channel.recv_exit_status()
|
|
352
|
-
|
|
353
|
-
if not stream:
|
|
354
|
-
if out:
|
|
355
|
-
print(out)
|
|
356
|
-
if err:
|
|
357
|
-
print(err, file=sys.stderr)
|
|
358
|
-
|
|
359
|
-
if check and exit_code != 0:
|
|
360
|
-
fail(f"远程命令失败 (exit {exit_code}): {command}")
|
|
361
|
-
|
|
362
|
-
return exit_code, out, err
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def extract_update_package_on_remote(
|
|
366
|
-
client: paramiko.SSHClient,
|
|
367
|
-
config: dict[str, Any],
|
|
368
|
-
remote_zip_path: str,
|
|
369
|
-
) -> int:
|
|
370
|
-
"""解压更新包到 lib 目录,仅覆盖远程已存在的 JAR。"""
|
|
371
|
-
remote_lib_dir = config["remote_lib_dir"]
|
|
372
|
-
quoted_zip = shlex.quote(remote_zip_path)
|
|
373
|
-
quoted_lib = shlex.quote(remote_lib_dir)
|
|
374
|
-
|
|
375
|
-
script = f"""
|
|
376
|
-
set -e
|
|
377
|
-
TMP=$(mktemp -d)
|
|
378
|
-
trap 'rm -rf "$TMP"' EXIT
|
|
379
|
-
unzip -oq {quoted_zip} -d "$TMP"
|
|
380
|
-
updated=0
|
|
381
|
-
while IFS= read -r -d '' src; do
|
|
382
|
-
name=$(basename "$src")
|
|
383
|
-
dest={quoted_lib}/"$name"
|
|
384
|
-
if [ -f "$dest" ]; then
|
|
385
|
-
cp -f "$src" "$dest"
|
|
386
|
-
echo "覆盖: $name"
|
|
387
|
-
updated=$((updated + 1))
|
|
388
|
-
else
|
|
389
|
-
echo "跳过(远程不存在): $name"
|
|
390
|
-
fi
|
|
391
|
-
done < <(find "$TMP" -name '*.jar' -type f -print0)
|
|
392
|
-
echo "UPDATED_COUNT=$updated"
|
|
393
|
-
"""
|
|
394
|
-
|
|
395
|
-
_, out, _ = run_remote_command(client, script)
|
|
396
|
-
match = re.search(r"UPDATED_COUNT=(\d+)", out)
|
|
397
|
-
if not match:
|
|
398
|
-
fail(f"远程解压失败,未获取更新数量\n输出: {out or '(空)'}")
|
|
399
|
-
|
|
400
|
-
updated = int(match.group(1))
|
|
401
|
-
log(f"lib 解压完成: 覆盖 {updated} 个")
|
|
402
|
-
return updated
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def springboot_output_indicates_success(action: str, combined: str) -> bool:
|
|
406
|
-
"""springboot.sh 经 SSH 执行时 exit code 不可靠,需结合输出判断。"""
|
|
407
|
-
lower = combined.lower()
|
|
408
|
-
if action == "health":
|
|
409
|
-
return "健康检查通过" in combined
|
|
410
|
-
if action in {"start", "restart"}:
|
|
411
|
-
return "is starting" in combined or "is running" in lower
|
|
412
|
-
if action == "stop":
|
|
413
|
-
return (
|
|
414
|
-
"is stopping" in combined
|
|
415
|
-
or "not running" in lower
|
|
416
|
-
or "please check it" in lower
|
|
417
|
-
)
|
|
418
|
-
if action == "status":
|
|
419
|
-
return "running" in lower or "not running" in lower
|
|
420
|
-
return True
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def run_springboot_action(
|
|
424
|
-
client: paramiko.SSHClient,
|
|
425
|
-
config: dict[str, Any],
|
|
426
|
-
action: str,
|
|
427
|
-
*args: str,
|
|
428
|
-
) -> str:
|
|
429
|
-
if action in {"start", "restart"}:
|
|
430
|
-
if not args:
|
|
431
|
-
fail(f"远程 {action} 缺少 jar 参数")
|
|
432
|
-
jar = args[0].strip().splitlines()[0].strip()
|
|
433
|
-
if not jar:
|
|
434
|
-
fail(f"无效的 jar 名称: {args[0]!r}")
|
|
435
|
-
|
|
436
|
-
command = " && ".join(
|
|
437
|
-
[
|
|
438
|
-
f"cd {shlex.quote(config['remote_app_dir'])}",
|
|
439
|
-
" ".join(
|
|
440
|
-
[f"./{SPRINGBOOT_SCRIPT}", *map(shlex.quote, (action, *args))]
|
|
441
|
-
),
|
|
442
|
-
]
|
|
443
|
-
)
|
|
444
|
-
exit_code, out, err = run_remote_command(
|
|
445
|
-
client,
|
|
446
|
-
command,
|
|
447
|
-
check=False,
|
|
448
|
-
)
|
|
449
|
-
combined = f"{out}\n{err}".strip()
|
|
450
|
-
output_ok = springboot_output_indicates_success(action, combined)
|
|
451
|
-
if action == "health":
|
|
452
|
-
if exit_code != 0 or not output_ok:
|
|
453
|
-
fail(
|
|
454
|
-
f"健康检查失败\n"
|
|
455
|
-
f"命令: {command}\n输出: {combined or '(空)'}"
|
|
456
|
-
)
|
|
457
|
-
return combined
|
|
458
|
-
if exit_code != 0 and not output_ok:
|
|
459
|
-
fail(f"远程 {action} 失败: {' '.join(args)}\n{combined}")
|
|
460
|
-
if action in {"start", "restart"} and not output_ok:
|
|
461
|
-
fail(
|
|
462
|
-
f"远程 {action} 未成功: {' '.join(args)}\n"
|
|
463
|
-
f"命令: {command}\n输出: {combined or '(空)'}"
|
|
464
|
-
)
|
|
465
|
-
return combined
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def get_running_jar(
|
|
469
|
-
client: paramiko.SSHClient,
|
|
470
|
-
app_dir: str,
|
|
471
|
-
config: dict[str, Any],
|
|
472
|
-
) -> str | None:
|
|
473
|
-
combined = run_springboot_action(
|
|
474
|
-
client, config, "status", config["startup_jar"]
|
|
475
|
-
)
|
|
476
|
-
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", combined).strip().lower()
|
|
477
|
-
if "not running" in text:
|
|
478
|
-
return None
|
|
479
|
-
if "running" in text:
|
|
480
|
-
return config["startup_jar"]
|
|
481
|
-
return None
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def health_check_service(
|
|
485
|
-
client: paramiko.SSHClient,
|
|
486
|
-
config: dict[str, Any],
|
|
487
|
-
) -> None:
|
|
488
|
-
port = str(int(config["health_check_port"]))
|
|
489
|
-
context = str(config["health_check_context"]).strip()
|
|
490
|
-
timeout = str(int(config["health_check_timeout"]))
|
|
491
|
-
log(f"健康检查: springboot.sh health {port} {context} {timeout}")
|
|
492
|
-
run_springboot_action(client, config, "health", port, context, timeout)
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def main() -> None:
|
|
496
|
-
config = load_config()
|
|
497
|
-
|
|
498
|
-
log(f"=== 自动部署: {config['project_name']} ===")
|
|
499
|
-
log(f"配置文件: {APM_CONFIG_PATH}")
|
|
500
|
-
log(f"项目根目录: {PROJECT_ROOT}")
|
|
501
|
-
|
|
502
|
-
run_maven_build(config)
|
|
503
|
-
|
|
504
|
-
lib_dir = locate_lib_dir()
|
|
505
|
-
manifest = load_manifest()
|
|
506
|
-
|
|
507
|
-
client = connect_ssh(config)
|
|
508
|
-
need_restart = False
|
|
509
|
-
lib_upload_entries: list[UpdateEntry] = []
|
|
510
|
-
try:
|
|
511
|
-
sftp = client.open_sftp()
|
|
512
|
-
try:
|
|
513
|
-
app_dir = config["remote_app_dir"]
|
|
514
|
-
remote_lib_stats = get_remote_file_stats(sftp, config["remote_lib_dir"])
|
|
515
|
-
|
|
516
|
-
log("收集 JAR 更新...")
|
|
517
|
-
lib_upload_entries = list_lib_files_to_upload(
|
|
518
|
-
lib_dir,
|
|
519
|
-
remote_lib_stats,
|
|
520
|
-
manifest=manifest,
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
updated = 0
|
|
524
|
-
if lib_upload_entries:
|
|
525
|
-
zip_path = create_update_package(
|
|
526
|
-
lib_upload_entries,
|
|
527
|
-
config["package_name"],
|
|
528
|
-
)
|
|
529
|
-
remote_zip_path = upload_update_package(sftp, zip_path, config)
|
|
530
|
-
log("远程解压 lib 目录(仅覆盖已有 JAR)...")
|
|
531
|
-
updated = extract_update_package_on_remote(
|
|
532
|
-
client,
|
|
533
|
-
config,
|
|
534
|
-
remote_zip_path,
|
|
535
|
-
)
|
|
536
|
-
else:
|
|
537
|
-
log("无 JAR 需要更新,跳过更新包上传")
|
|
538
|
-
|
|
539
|
-
running_jar = get_running_jar(client, app_dir, config)
|
|
540
|
-
need_restart = updated > 0
|
|
541
|
-
if not need_restart and not running_jar:
|
|
542
|
-
log("服务未运行,需要启动")
|
|
543
|
-
need_restart = True
|
|
544
|
-
elif not need_restart:
|
|
545
|
-
log("没有文件需要更新,跳过重启")
|
|
546
|
-
|
|
547
|
-
if need_restart:
|
|
548
|
-
log("重启服务...")
|
|
549
|
-
run_springboot_action(client, config, "restart", config["startup_jar"])
|
|
550
|
-
|
|
551
|
-
health_check_service(client, config)
|
|
552
|
-
|
|
553
|
-
if lib_upload_entries:
|
|
554
|
-
save_manifest(update_manifest_entries(manifest, lib_upload_entries))
|
|
555
|
-
finally:
|
|
556
|
-
sftp.close()
|
|
557
|
-
finally:
|
|
558
|
-
client.close()
|
|
559
|
-
|
|
560
|
-
log("部署完成")
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if __name__ == "__main__":
|
|
564
|
-
main()
|