Files
xsk-education-management/scripts/deploy_to_vps.py
T
2026-06-15 14:18:36 +08:00

149 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
PROJECT_DIR = Path(__file__).resolve().parents[1]
DEFAULT_REMOTE_HOST = "121.199.172.246"
DEFAULT_REMOTE_USER = "root"
DEFAULT_REMOTE_PORT = 22222
DEFAULT_REMOTE_DIR = "/root/新时空教务管理系统"
DEFAULT_IDENTITY_FILE = Path.home() / ".ssh" / "xsk_records_vps_ed25519"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="部署新时空教务管理系统到 VPS")
parser.add_argument("--remote-host", default=os.getenv("XSK_REMOTE_HOST", DEFAULT_REMOTE_HOST))
parser.add_argument("--remote-user", default=os.getenv("XSK_REMOTE_USER", DEFAULT_REMOTE_USER))
parser.add_argument("--remote-port", type=int, default=int(os.getenv("XSK_REMOTE_PORT", str(DEFAULT_REMOTE_PORT))))
parser.add_argument("--remote-dir", default=os.getenv("XSK_REMOTE_DIR", DEFAULT_REMOTE_DIR))
parser.add_argument(
"--identity-file",
default=os.getenv("XSK_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE) if DEFAULT_IDENTITY_FILE.exists() else ""),
help="SSH 私钥路径;默认兼容使用历史文件 ~/.ssh/xsk_records_vps_ed25519(如果存在)",
)
parser.add_argument("--app-port", default=os.getenv("XSK_APP_PORT", "18080"))
parser.add_argument(
"--python-image",
default=os.getenv(
"XSK_PYTHON_IMAGE",
"swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim",
),
help="Docker 基础镜像;VPS 无法访问 Docker Hub 时可使用镜像站",
)
parser.add_argument("--auth-user", default=os.getenv("XSK_WEB_USER", "wolfydw"))
parser.add_argument("--auth-password", default=os.getenv("XSK_WEB_PASSWORD"))
parser.add_argument("--use-sshpass", action="store_true", default=os.getenv("XSK_USE_SSHPASS", "") == "1")
return parser.parse_args()
def run(command: list[str], cwd: Path | None = None) -> None:
env = os.environ.copy()
if env.get("XSK_SSH_PASSWORD") and not env.get("SSHPASS"):
env["SSHPASS"] = env["XSK_SSH_PASSWORD"]
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, env=env)
if result.returncode != 0:
redacted = ["***" if part == os.getenv("XSK_SSH_PASSWORD") else part for part in command]
raise RuntimeError(
f"命令失败({result.returncode}): {' '.join(shlex.quote(part) for part in redacted)}\n"
f"STDOUT: {result.stdout.strip()}\nSTDERR: {result.stderr.strip()}"
)
if result.stdout.strip():
print(result.stdout.strip())
def base_remote(args: argparse.Namespace) -> str:
return f"{args.remote_user}@{args.remote_host}"
def ssh_options(args: argparse.Namespace) -> list[str]:
options = ["-p", str(args.remote_port), "-o", "StrictHostKeyChecking=accept-new"]
if args.identity_file:
options.extend(["-i", str(Path(args.identity_file).expanduser()), "-o", "IdentitiesOnly=yes"])
return options
def ssh_command(args: argparse.Namespace) -> list[str]:
command: list[str] = []
if args.use_sshpass:
password = os.getenv("XSK_SSH_PASSWORD")
if not password:
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
command.extend(["sshpass", "-e"])
command.extend(["ssh", *ssh_options(args), base_remote(args)])
return command
def rsync_command(args: argparse.Namespace) -> list[str]:
command: list[str] = []
if args.use_sshpass:
password = os.getenv("XSK_SSH_PASSWORD")
if not password:
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
command.extend(["sshpass", "-e"])
ssh_command_text = " ".join(shlex.quote(part) for part in ["ssh", *ssh_options(args)])
command.extend(
[
"rsync",
"-az",
"--delete",
"--exclude",
".env",
"--exclude",
"*.log",
"-e",
ssh_command_text,
]
)
return command
def write_env(args: argparse.Namespace) -> Path:
if not args.auth_password:
raise RuntimeError("必须通过 XSK_WEB_PASSWORD 或 --auth-password 设置网页访问密码")
env_text = "\n".join(
[
f"APP_PORT={args.app_port}",
"TZ=Asia/Shanghai",
f"PYTHON_IMAGE={args.python_image}",
f"BASIC_AUTH_USERNAME={args.auth_user}",
f"BASIC_AUTH_PASSWORD={args.auth_password}",
"CLASSNOTES_PATH=/data/classnotes.txt",
"ACCOUNTS_PATH=/data/学生课时账户.md",
"",
]
)
handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False)
try:
handle.write(env_text)
return Path(handle.name)
finally:
handle.close()
def main() -> int:
args = parse_args()
app_dir = f"{args.remote_dir.rstrip('/')}/app"
data_dir = f"{args.remote_dir.rstrip('/')}/data"
env_file = write_env(args)
try:
run(ssh_command(args) + [f"mkdir -p {shlex.quote(app_dir)} {shlex.quote(data_dir)}"])
run(rsync_command(args) + [f"{PROJECT_DIR}/", f"{base_remote(args)}:{app_dir}/"])
run(rsync_command(args) + [str(env_file), f"{base_remote(args)}:{app_dir}/.env"])
run(ssh_command(args) + [f"cd {shlex.quote(app_dir)} && docker compose up -d --build --remove-orphans"])
print(f"部署完成: http://{args.remote_host}:{args.app_port}/")
finally:
env_file.unlink(missing_ok=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())