2
0

BootstrapBase.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import os
  2. import pwd
  3. import grp
  4. import shutil
  5. import secrets
  6. import string
  7. import subprocess
  8. import time
  9. import socket
  10. import signal
  11. import re
  12. import json
  13. from pathlib import Path
  14. import mysql.connector
  15. from jinja2 import Environment, FileSystemLoader
  16. class BootstrapBase:
  17. def __init__(self, container, db_config, db_table, db_settings):
  18. self.container = container
  19. self.db_config = db_config
  20. self.db_table = db_table
  21. self.db_settings = db_settings
  22. self.env = None
  23. self.env_vars = None
  24. self.mysql_conn = None
  25. def render_config(self, template_name, output_path):
  26. """
  27. Renders a Jinja2 template and writes it to the specified output path.
  28. The method uses the class's `self.env` Jinja2 environment and `self.env_vars`
  29. for rendering template variables.
  30. Args:
  31. template_name (str): Name of the template file.
  32. output_path (str or Path): Path to write the rendered output file.
  33. """
  34. output_path = Path(output_path)
  35. output_path.parent.mkdir(parents=True, exist_ok=True)
  36. template = self.env.get_template(template_name)
  37. rendered = template.render(self.env_vars)
  38. with open(output_path, "w") as f:
  39. f.write(rendered)
  40. def prepare_template_vars(self, overwrite_path, extra_vars = None):
  41. """
  42. Loads and merges environment variables for Jinja2 templates from multiple sources.
  43. This method combines:
  44. 1. System environment variables
  45. 2. Key/value pairs from the MySQL `service_settings` table
  46. 3. An optional dictionary of extra_vars
  47. 4. A JSON file with overrides (if the file exists)
  48. Args:
  49. overwrite_path (str or Path): Path to a JSON file containing key-value overrides.
  50. extra_vars (dict, optional): A dictionary of additional variables to include.
  51. Returns:
  52. dict: A dictionary containing all resolved template variables.
  53. Raises:
  54. Prints errors if database fetch or JSON parsing fails, but does not raise exceptions.
  55. """
  56. # 1. Load env vars
  57. env_vars = dict(os.environ)
  58. # 2. Load from MySQL
  59. try:
  60. cursor = self.mysql_conn.cursor()
  61. if self.db_settings:
  62. placeholders = ','.join(['%s'] * len(self.db_settings))
  63. sql = f"SELECT `key`, `value` FROM {self.db_table} WHERE `type` IN ({placeholders})"
  64. cursor.execute(sql, self.db_settings)
  65. else:
  66. cursor.execute(f"SELECT `key`, `value` FROM {self.db_table}")
  67. for key, value in cursor.fetchall():
  68. env_vars[key] = value
  69. cursor.close()
  70. except Exception as e:
  71. print(f"Failed to fetch DB service settings: {e}")
  72. # 3. Load extra vars
  73. if extra_vars:
  74. env_vars.update(extra_vars)
  75. # 4. Load overwrites
  76. overwrite_path = Path(overwrite_path)
  77. if overwrite_path.exists():
  78. try:
  79. with overwrite_path.open("r") as f:
  80. overwrite_data = json.load(f)
  81. env_vars.update(overwrite_data)
  82. except Exception as e:
  83. print(f"Failed to parse overwrites: {e}")
  84. return env_vars
  85. def set_timezone(self):
  86. """
  87. Sets the system timezone based on the TZ environment variable.
  88. If the TZ variable is set, writes its value to /etc/timezone.
  89. """
  90. timezone = os.getenv("TZ")
  91. if timezone:
  92. with open("/etc/timezone", "w") as f:
  93. f.write(timezone + "\n")
  94. def set_syslog_redis(self):
  95. """
  96. Reconfigures syslog-ng to use a Redis slave configuration.
  97. If the REDIS_SLAVEOF_IP environment variable is set, replaces the syslog-ng config
  98. with the Redis slave-specific config.
  99. """
  100. redis_slave_ip = os.getenv("REDIS_SLAVEOF_IP")
  101. if redis_slave_ip:
  102. shutil.copy("/etc/syslog-ng/syslog-ng-redis_slave.conf", "/etc/syslog-ng/syslog-ng.conf")
  103. def rsync_file(self, src, dst, recursive=False, owner=None, mode=None):
  104. """
  105. Copies files or directories using rsync, with optional ownership and permissions.
  106. Args:
  107. src (str or Path): Source file or directory.
  108. dst (str or Path): Destination directory.
  109. recursive (bool): If True, copies contents recursively.
  110. owner (tuple): Tuple of (user, group) to set ownership.
  111. mode (int): File mode (e.g., 0o644) to set permissions after sync.
  112. """
  113. src_path = Path(src)
  114. dst_path = Path(dst)
  115. dst_path.mkdir(parents=True, exist_ok=True)
  116. rsync_cmd = ["rsync", "-a"]
  117. if recursive:
  118. rsync_cmd.append(str(src_path) + "/")
  119. else:
  120. rsync_cmd.append(str(src_path))
  121. rsync_cmd.append(str(dst_path))
  122. try:
  123. subprocess.run(rsync_cmd, check=True)
  124. except Exception as e:
  125. print(f"Rsync failed: {e}")
  126. if owner:
  127. self.set_owner(dst_path, *owner, recursive=True)
  128. if mode:
  129. self.set_permissions(dst_path, mode)
  130. def set_permissions(self, path, mode):
  131. """
  132. Sets file or directory permissions.
  133. Args:
  134. path (str or Path): Path to the file or directory.
  135. mode (int): File mode to apply, e.g., 0o644.
  136. Raises:
  137. FileNotFoundError: If the path does not exist.
  138. """
  139. file_path = Path(path)
  140. if not file_path.exists():
  141. raise FileNotFoundError(f"Cannot chmod: {file_path} does not exist")
  142. os.chmod(file_path, mode)
  143. def set_owner(self, path, user, group=None, recursive=False):
  144. """
  145. Changes ownership of a file or directory.
  146. Args:
  147. path (str or Path): Path to the file or directory.
  148. user (str): Username for new owner.
  149. group (str, optional): Group name; defaults to user's group if not provided.
  150. recursive (bool): If True and path is a directory, ownership is applied recursively.
  151. Raises:
  152. FileNotFoundError: If the path does not exist.
  153. """
  154. uid = pwd.getpwnam(user).pw_uid
  155. gid = grp.getgrnam(group or user).gr_gid
  156. p = Path(path)
  157. if not p.exists():
  158. raise FileNotFoundError(f"{path} does not exist")
  159. if recursive and p.is_dir():
  160. for sub_path in p.rglob("*"):
  161. os.chown(sub_path, uid, gid)
  162. os.chown(p, uid, gid)
  163. def move_file(self, src, dst, overwrite=True):
  164. """
  165. Moves a file from src to dst, optionally overwriting existing files.
  166. Args:
  167. src (str or Path): Source file path.
  168. dst (str or Path): Destination path.
  169. overwrite (bool): If False, raises error if dst exists.
  170. Raises:
  171. FileNotFoundError: If the source file does not exist.
  172. FileExistsError: If the destination file exists and overwrite is False.
  173. """
  174. src_path = Path(src)
  175. dst_path = Path(dst)
  176. if not src_path.exists():
  177. raise FileNotFoundError(f"Source file does not exist: {src}")
  178. dst_path.parent.mkdir(parents=True, exist_ok=True)
  179. if dst_path.exists() and not overwrite:
  180. raise FileExistsError(f"Destination already exists: {dst} (set overwrite=True to overwrite)")
  181. shutil.move(str(src_path), str(dst_path))
  182. def patch_exists(self, target_file, patch_file, reverse=False):
  183. """
  184. Checks whether a patch can be applied (or reversed) to a target file.
  185. Args:
  186. target_file (str): File to test the patch against.
  187. patch_file (str): Patch file to apply.
  188. reverse (bool): If True, checks whether the patch can be reversed.
  189. Returns:
  190. bool: True if patch is applicable, False otherwise.
  191. """
  192. cmd = ["patch", "-sfN", "--dry-run", target_file, "<", patch_file]
  193. if reverse:
  194. cmd.insert(1, "-R")
  195. try:
  196. result = subprocess.run(
  197. " ".join(cmd),
  198. shell=True,
  199. stdout=subprocess.DEVNULL,
  200. stderr=subprocess.DEVNULL
  201. )
  202. return result.returncode == 0
  203. except Exception as e:
  204. print(f"Patch dry-run failed: {e}")
  205. return False
  206. def apply_patch(self, target_file, patch_file, reverse=False):
  207. """
  208. Applies a patch file to a target file.
  209. Args:
  210. target_file (str): File to be patched.
  211. patch_file (str): Patch file containing the diff.
  212. reverse (bool): If True, applies the patch in reverse (rollback).
  213. Logs:
  214. Success or failure of the patching operation.
  215. """
  216. cmd = ["patch", target_file, "<", patch_file]
  217. if reverse:
  218. cmd.insert(0, "-R")
  219. try:
  220. subprocess.run(" ".join(cmd), shell=True, check=True)
  221. print(f"Applied patch {'(reverse)' if reverse else ''} to {target_file}")
  222. except subprocess.CalledProcessError as e:
  223. print(f"Patch failed: {e}")
  224. def isYes(self, value):
  225. """
  226. Determines whether a given string represents a "yes"-like value.
  227. Args:
  228. value (str): Input string to evaluate.
  229. Returns:
  230. bool: True if value is "yes" or "y" (case-insensitive), otherwise False.
  231. """
  232. return value.lower() in ["yes", "y"]
  233. def is_port_open(self, host, port):
  234. """
  235. Checks whether a TCP port is open on a given host.
  236. Args:
  237. host (str): The hostname or IP address to check.
  238. port (int): The TCP port number to test.
  239. Returns:
  240. bool: True if the port is open and accepting connections, False otherwise.
  241. """
  242. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
  243. sock.settimeout(1)
  244. result = sock.connect_ex((host, port))
  245. return result == 0
  246. def kill_proc(self, process):
  247. """
  248. Sends a SIGTERM signal to all processes matching the given name using `killall`.
  249. Args:
  250. process (str): The name of the process to terminate.
  251. Returns:
  252. True if the signal was sent successfully, or the subprocess error if it failed.
  253. """
  254. try:
  255. subprocess.run(["killall", "-TERM", process], check=True)
  256. except subprocess.CalledProcessError as e:
  257. return e
  258. return True
  259. def connect_mysql(self):
  260. """
  261. Establishes a connection to the MySQL database using the provided configuration.
  262. Continuously retries the connection until the database is reachable. Stores
  263. the connection in `self.mysql_conn` once successful.
  264. Logs:
  265. Connection status and retry errors to stdout.
  266. """
  267. print("Connecting to MySQL...")
  268. while True:
  269. try:
  270. self.mysql_conn = mysql.connector.connect(**self.db_config)
  271. if self.mysql_conn.is_connected():
  272. print("MySQL is up and ready!")
  273. break
  274. except mysql.connector.Error as e:
  275. print(f"Waiting for MySQL... ({e})")
  276. time.sleep(2)
  277. def close_mysql(self):
  278. """
  279. Closes the MySQL connection if it's currently open and connected.
  280. Safe to call even if the connection has already been closed.
  281. """
  282. if self.mysql_conn and self.mysql_conn.is_connected():
  283. self.mysql_conn.close()
  284. def wait_for_schema_update(self, init_file_path="init_db.inc.php", check_interval=5):
  285. """
  286. Waits until the current database schema version matches the expected version
  287. defined in a PHP initialization file.
  288. Compares the `version` value in the `versions` table for `application = 'db_schema'`
  289. with the `$db_version` value extracted from the specified PHP file.
  290. Args:
  291. init_file_path (str): Path to the PHP file containing the expected version string.
  292. check_interval (int): Time in seconds to wait between version checks.
  293. Logs:
  294. Current vs. expected schema versions until they match.
  295. """
  296. print("Checking database schema version...")
  297. while True:
  298. current_version = self._get_current_db_version()
  299. expected_version = self._get_expected_schema_version(init_file_path)
  300. if current_version == expected_version:
  301. print(f"DB schema is up to date: {current_version}")
  302. break
  303. print(f"Waiting for schema update... (DB: {current_version}, Expected: {expected_version})")
  304. time.sleep(check_interval)
  305. def wait_for_host(self, host, retry_interval=1.0, count=1):
  306. """
  307. Waits for a host to respond to ICMP ping.
  308. Args:
  309. host (str): Hostname or IP to ping.
  310. retry_interval (float): Seconds to wait between pings.
  311. count (int): Number of ping packets to send per check (default 1).
  312. """
  313. while True:
  314. try:
  315. result = subprocess.run(
  316. ["ping", "-c", str(count), host],
  317. stdout=subprocess.DEVNULL,
  318. stderr=subprocess.DEVNULL
  319. )
  320. if result.returncode == 0:
  321. print(f"{host} is reachable via ping.")
  322. break
  323. except Exception:
  324. pass
  325. print(f"Waiting for {host}...")
  326. time.sleep(retry_interval)
  327. def _get_current_db_version(self):
  328. """
  329. Fetches the current schema version from the database.
  330. Executes a SELECT query on the `versions` table where `application = 'db_schema'`.
  331. Returns:
  332. str or None: The current schema version as a string, or None if not found or on error.
  333. Logs:
  334. Error message if the query fails.
  335. """
  336. try:
  337. cursor = self.mysql_conn.cursor()
  338. cursor.execute("SELECT version FROM versions WHERE application = 'db_schema'")
  339. result = cursor.fetchone()
  340. cursor.close()
  341. return result[0] if result else None
  342. except Exception as e:
  343. print(f"Error fetching current DB schema version: {e}")
  344. return None
  345. def _get_expected_schema_version(self, filepath):
  346. """
  347. Extracts the expected database schema version from a PHP initialization file.
  348. Looks for a line in the form of: `$db_version = "..."` and extracts the version string.
  349. Args:
  350. filepath (str): Path to the PHP file containing the `$db_version` definition.
  351. Returns:
  352. str or None: The extracted version string, or None if not found or on error.
  353. Logs:
  354. Error message if the file cannot be read or parsed.
  355. """
  356. try:
  357. with open(filepath, "r") as f:
  358. content = f.read()
  359. match = re.search(r'\$db_version\s*=\s*"([^"]+)"', content)
  360. if match:
  361. return match.group(1)
  362. except Exception as e:
  363. print(f"Error reading expected schema version from {filepath}: {e}")
  364. return None
  365. def rand_pass(self, length=22):
  366. """
  367. Generates a secure random password using allowed characters.
  368. Allowed characters include upper/lowercase letters, digits, underscores, and hyphens.
  369. Args:
  370. length (int): Length of the password to generate. Default is 22.
  371. Returns:
  372. str: A securely generated random password string.
  373. """
  374. allowed_chars = string.ascii_letters + string.digits + "_-"
  375. return ''.join(secrets.choice(allowed_chars) for _ in range(length))