deploy_tencent.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import argparse
  2. import getpass
  3. import os
  4. import shlex
  5. import sys
  6. import tarfile
  7. import tempfile
  8. import time
  9. from pathlib import Path
  10. import paramiko
  11. EXCLUDED_DIRS = {
  12. ".git",
  13. ".venv",
  14. "__pycache__",
  15. "node_modules",
  16. "tmp",
  17. }
  18. EXCLUDED_FILES = {
  19. "data/.config.yaml",
  20. }
  21. EXCLUDED_PREFIXES = (
  22. "data/bin/",
  23. )
  24. EXCLUDED_SUFFIXES = (
  25. ".pyc",
  26. ".pyo",
  27. ".pyd",
  28. )
  29. def should_exclude(rel_path: str) -> bool:
  30. normalized = rel_path.replace("\\", "/").strip("./")
  31. if not normalized:
  32. return False
  33. if normalized in EXCLUDED_FILES:
  34. return True
  35. if normalized.endswith(EXCLUDED_SUFFIXES):
  36. return True
  37. for prefix in EXCLUDED_PREFIXES:
  38. if normalized.startswith(prefix):
  39. return True
  40. parts = normalized.split("/")
  41. if any(part in EXCLUDED_DIRS for part in parts):
  42. return True
  43. if normalized.startswith("data/") and normalized.endswith(".sqlite3"):
  44. return True
  45. return False
  46. def create_archive(source_dir: Path) -> str:
  47. temp_file = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
  48. temp_file.close()
  49. archive_path = temp_file.name
  50. with tarfile.open(archive_path, "w:gz") as tar:
  51. for path in source_dir.rglob("*"):
  52. if not path.is_file():
  53. continue
  54. rel_path = path.relative_to(source_dir).as_posix()
  55. if should_exclude(rel_path):
  56. continue
  57. tar.add(path, arcname=rel_path)
  58. return archive_path
  59. def run_remote_command(client: paramiko.SSHClient, command: str) -> None:
  60. stdin, stdout, stderr = client.exec_command(command, get_pty=True)
  61. exit_status = stdout.channel.recv_exit_status()
  62. out = stdout.read().decode("utf-8", "ignore")
  63. err = stderr.read().decode("utf-8", "ignore")
  64. def _safe_write(stream, text: str) -> None:
  65. if not text:
  66. return
  67. if not text.endswith("\n"):
  68. text += "\n"
  69. encoding = getattr(stream, "encoding", None) or "utf-8"
  70. stream.buffer.write(text.encode(encoding, errors="replace"))
  71. stream.flush()
  72. if out:
  73. _safe_write(sys.stdout, out)
  74. if err:
  75. _safe_write(sys.stderr, err)
  76. if exit_status != 0:
  77. raise RuntimeError(f"Remote command failed with exit code {exit_status}")
  78. def main() -> int:
  79. parser = argparse.ArgumentParser(description="Deploy xingxing-server to Tencent Cloud over SSH.")
  80. parser.add_argument("--host", required=True, help="Remote host or IP.")
  81. parser.add_argument("--user", required=True, help="SSH username.")
  82. parser.add_argument("--password", help="SSH password. If omitted, prompt securely.")
  83. parser.add_argument(
  84. "--remote-dir",
  85. default="/home/ubuntu/xingxing-server",
  86. help="Remote project directory.",
  87. )
  88. parser.add_argument(
  89. "--source-dir",
  90. default=str(Path(__file__).resolve().parents[2]),
  91. help="Local xingxing-server source directory.",
  92. )
  93. parser.add_argument(
  94. "--compose-file",
  95. default="docker-compose.tencent.yml",
  96. help="Compose file to use on the remote host.",
  97. )
  98. args = parser.parse_args()
  99. source_dir = Path(args.source_dir).resolve()
  100. if not source_dir.exists():
  101. raise FileNotFoundError(f"Source directory does not exist: {source_dir}")
  102. password = args.password or getpass.getpass("SSH password: ")
  103. archive_path = create_archive(source_dir)
  104. timestamp = time.strftime("%Y%m%d%H%M%S")
  105. remote_archive = f"/tmp/xingxing-server-{timestamp}.tar.gz"
  106. remote_stage = f"{args.remote_dir}.stage.{timestamp}"
  107. remote_backup = f"{args.remote_dir}.bak.{timestamp}"
  108. client = paramiko.SSHClient()
  109. client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
  110. try:
  111. print(f"Connecting to {args.user}@{args.host} ...")
  112. client.connect(
  113. hostname=args.host,
  114. username=args.user,
  115. password=password,
  116. timeout=20,
  117. banner_timeout=20,
  118. auth_timeout=20,
  119. )
  120. print(f"Uploading archive to {remote_archive} ...")
  121. sftp = client.open_sftp()
  122. sftp.put(archive_path, remote_archive)
  123. sftp.close()
  124. remote_dir_q = shlex.quote(args.remote_dir)
  125. remote_stage_q = shlex.quote(remote_stage)
  126. remote_backup_q = shlex.quote(remote_backup)
  127. remote_archive_q = shlex.quote(remote_archive)
  128. compose_file_q = shlex.quote(args.compose_file)
  129. sudo_password_q = shlex.quote(password)
  130. command = f"""
  131. set -e
  132. REMOTE_DIR={remote_dir_q}
  133. STAGE_DIR={remote_stage_q}
  134. BACKUP_DIR={remote_backup_q}
  135. ARCHIVE={remote_archive_q}
  136. SUDO_PASSWORD={sudo_password_q}
  137. rm -rf "$STAGE_DIR"
  138. mkdir -p "$STAGE_DIR"
  139. tar -xzf "$ARCHIVE" -C "$STAGE_DIR"
  140. if [ -d "$REMOTE_DIR/data" ]; then
  141. mkdir -p "$STAGE_DIR/data"
  142. cp -a "$REMOTE_DIR/data/." "$STAGE_DIR/data/"
  143. fi
  144. if [ -d "$REMOTE_DIR" ]; then
  145. rm -rf "$BACKUP_DIR"
  146. mv "$REMOTE_DIR" "$BACKUP_DIR"
  147. fi
  148. mv "$STAGE_DIR" "$REMOTE_DIR"
  149. rm -f "$ARCHIVE"
  150. cd "$REMOTE_DIR"
  151. printf '%s\\n' "$SUDO_PASSWORD" | sudo -S -p '' docker compose -f {compose_file_q} up -d --build
  152. printf '%s\\n' "$SUDO_PASSWORD" | sudo -S -p '' docker compose -f {compose_file_q} ps
  153. """
  154. print("Rebuilding and restarting remote service ...")
  155. run_remote_command(client, command)
  156. print(f"Deploy completed. Backup kept at {remote_backup}")
  157. return 0
  158. finally:
  159. client.close()
  160. if os.path.exists(archive_path):
  161. os.remove(archive_path)
  162. if __name__ == "__main__":
  163. raise SystemExit(main())