BootstrapBase.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  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 redis
  13. import hashlib
  14. import json
  15. from pathlib import Path
  16. import dns.resolver
  17. import mysql.connector
  18. from jinja2 import Environment, FileSystemLoader
  19. class BootstrapBase:
  20. def __init__(self, container, db_config, db_table, db_settings, redis_config):
  21. self.container = container
  22. self.db_config = db_config
  23. self.db_table = db_table
  24. self.db_settings = db_settings
  25. self.redis_config = redis_config
  26. self.env = None
  27. self.env_vars = None
  28. self.mysql_conn = None
  29. self.redis_conn = None
  30. def render_config(self, template_name, output_path, clean_blank_lines=False):
  31. """
  32. Renders a Jinja2 template and writes it to the specified output path.
  33. Args:
  34. template_name (str): Name of the template file.
  35. output_path (str or Path): Path to write the rendered output file.
  36. clean_blank_lines (bool): If True, removes empty/whitespace-only lines from rendered output.
  37. """
  38. output_path = Path(output_path)
  39. output_path.parent.mkdir(parents=True, exist_ok=True)
  40. template = self.env.get_template(template_name)
  41. rendered = template.render(self.env_vars)
  42. if clean_blank_lines:
  43. rendered = "\n".join(line for line in rendered.splitlines() if line.strip())
  44. # converts output to Unix-style line endings
  45. rendered = rendered.replace('\r\n', '\n').replace('\r', '\n')
  46. with open(output_path, "w") as f:
  47. f.write(rendered)
  48. def prepare_template_vars(self, overwrite_path, extra_vars = None):
  49. """
  50. Loads and merges environment variables for Jinja2 templates from multiple sources.
  51. This method combines:
  52. 1. System environment variables
  53. 2. Key/value pairs from the MySQL `service_settings` table
  54. 3. An optional dictionary of extra_vars
  55. 4. A JSON file with overrides (if the file exists)
  56. Args:
  57. overwrite_path (str or Path): Path to a JSON file containing key-value overrides.
  58. extra_vars (dict, optional): A dictionary of additional variables to include.
  59. Returns:
  60. dict: A dictionary containing all resolved template variables.
  61. Raises:
  62. Prints errors if database fetch or JSON parsing fails, but does not raise exceptions.
  63. """
  64. # 1. Load env vars
  65. env_vars = dict(os.environ)
  66. # 2. Load from MySQL
  67. try:
  68. cursor = self.mysql_conn.cursor()
  69. if self.db_settings:
  70. placeholders = ','.join(['%s'] * len(self.db_settings))
  71. sql = f"SELECT `key`, `value` FROM {self.db_table} WHERE `type` IN ({placeholders})"
  72. cursor.execute(sql, self.db_settings)
  73. else:
  74. cursor.execute(f"SELECT `key`, `value` FROM {self.db_table}")
  75. for key, value in cursor.fetchall():
  76. env_vars[key] = value
  77. cursor.close()
  78. except Exception as e:
  79. print(f"Failed to fetch DB service settings: {e}")
  80. # 3. Load extra vars
  81. if extra_vars:
  82. env_vars.update(extra_vars)
  83. # 4. Load overwrites
  84. overwrite_path = Path(overwrite_path)
  85. if overwrite_path.exists():
  86. try:
  87. with overwrite_path.open("r") as f:
  88. overwrite_data = json.load(f)
  89. env_vars.update(overwrite_data)
  90. except Exception as e:
  91. print(f"Failed to parse overwrites: {e}")
  92. return env_vars
  93. def set_timezone(self):
  94. """
  95. Sets the system timezone based on the TZ environment variable.
  96. If the TZ variable is set, writes its value to /etc/timezone.
  97. """
  98. timezone = os.getenv("TZ")
  99. if timezone:
  100. with open("/etc/timezone", "w") as f:
  101. f.write(timezone + "\n")
  102. def set_syslog_redis(self):
  103. """
  104. Reconfigures syslog-ng to use a Redis slave configuration.
  105. If the REDIS_SLAVEOF_IP environment variable is set, replaces the syslog-ng config
  106. with the Redis slave-specific config.
  107. """
  108. redis_slave_ip = os.getenv("REDIS_SLAVEOF_IP")
  109. if redis_slave_ip:
  110. shutil.copy("/etc/syslog-ng/syslog-ng-redis_slave.conf", "/etc/syslog-ng/syslog-ng.conf")
  111. def rsync_file(self, src, dst, recursive=False, owner=None, mode=None):
  112. """
  113. Copies files or directories using rsync, with optional ownership and permissions.
  114. Args:
  115. src (str or Path): Source file or directory.
  116. dst (str or Path): Destination directory.
  117. recursive (bool): If True, copies contents recursively.
  118. owner (tuple): Tuple of (user, group) to set ownership.
  119. mode (int): File mode (e.g., 0o644) to set permissions after sync.
  120. """
  121. src_path = Path(src)
  122. dst_path = Path(dst)
  123. dst_path.mkdir(parents=True, exist_ok=True)
  124. rsync_cmd = ["rsync", "-a"]
  125. if recursive:
  126. rsync_cmd.append(str(src_path) + "/")
  127. else:
  128. rsync_cmd.append(str(src_path))
  129. rsync_cmd.append(str(dst_path))
  130. try:
  131. subprocess.run(rsync_cmd, check=True)
  132. except Exception as e:
  133. print(f"Rsync failed: {e}")
  134. if owner:
  135. self.set_owner(dst_path, *owner, recursive=True)
  136. if mode:
  137. self.set_permissions(dst_path, mode)
  138. def set_permissions(self, path, mode):
  139. """
  140. Sets file or directory permissions.
  141. Args:
  142. path (str or Path): Path to the file or directory.
  143. mode (int): File mode to apply, e.g., 0o644.
  144. Raises:
  145. FileNotFoundError: If the path does not exist.
  146. """
  147. file_path = Path(path)
  148. if not file_path.exists():
  149. raise FileNotFoundError(f"Cannot chmod: {file_path} does not exist")
  150. os.chmod(file_path, mode)
  151. def set_owner(self, path, user, group=None, recursive=False):
  152. """
  153. Changes ownership of a file or directory.
  154. Args:
  155. path (str or Path): Path to the file or directory.
  156. user (str or int): Username or UID for new owner.
  157. group (str or int, optional): Group name or GID; defaults to user's group if not provided.
  158. recursive (bool): If True and path is a directory, ownership is applied recursively.
  159. Raises:
  160. FileNotFoundError: If the path does not exist.
  161. """
  162. # Resolve UID
  163. uid = int(user) if str(user).isdigit() else pwd.getpwnam(user).pw_uid
  164. # Resolve GID
  165. if group is not None:
  166. gid = int(group) if str(group).isdigit() else grp.getgrnam(group).gr_gid
  167. else:
  168. gid = uid if isinstance(user, int) or str(user).isdigit() else grp.getgrnam(user).gr_gid
  169. p = Path(path)
  170. if not p.exists():
  171. raise FileNotFoundError(f"{path} does not exist")
  172. if recursive and p.is_dir():
  173. for sub_path in p.rglob("*"):
  174. os.chown(sub_path, uid, gid)
  175. os.chown(p, uid, gid)
  176. def move_file(self, src, dst, overwrite=True):
  177. """
  178. Moves a file from src to dst, optionally overwriting existing files.
  179. Args:
  180. src (str or Path): Source file path.
  181. dst (str or Path): Destination path.
  182. overwrite (bool): If False, raises error if dst exists.
  183. Raises:
  184. FileNotFoundError: If the source file does not exist.
  185. FileExistsError: If the destination file exists and overwrite is False.
  186. """
  187. src_path = Path(src)
  188. dst_path = Path(dst)
  189. if not src_path.exists():
  190. raise FileNotFoundError(f"Source file does not exist: {src}")
  191. dst_path.parent.mkdir(parents=True, exist_ok=True)
  192. if dst_path.exists() and not overwrite:
  193. raise FileExistsError(f"Destination already exists: {dst} (set overwrite=True to overwrite)")
  194. shutil.move(str(src_path), str(dst_path))
  195. def copy_file(self, src, dst, overwrite=True):
  196. """
  197. Copies a file from src to dst using shutil.
  198. Args:
  199. src (str or Path): Source file path.
  200. dst (str or Path): Destination file path.
  201. overwrite (bool): Whether to overwrite the destination if it exists.
  202. Raises:
  203. FileNotFoundError: If the source file doesn't exist.
  204. FileExistsError: If the destination exists and overwrite is False.
  205. IOError: If the copy operation fails.
  206. """
  207. src_path = Path(src)
  208. dst_path = Path(dst)
  209. if not src_path.is_file():
  210. raise FileNotFoundError(f"Source file not found: {src_path}")
  211. if dst_path.exists() and not overwrite:
  212. raise FileExistsError(f"Destination exists: {dst_path}")
  213. dst_path.parent.mkdir(parents=True, exist_ok=True)
  214. shutil.copy2(src_path, dst_path)
  215. def remove(self, path, recursive=False, wipe_contents=False, exclude=None):
  216. """
  217. Removes a file or directory with optional exclusion logic.
  218. Args:
  219. path (str or Path): The file or directory path to remove.
  220. recursive (bool): If True, directories will be removed recursively.
  221. wipe_contents (bool): If True and path is a directory, only its contents are removed, not the dir itself.
  222. exclude (list[str], optional): List of filenames to exclude from deletion.
  223. Raises:
  224. FileNotFoundError: If the path does not exist.
  225. ValueError: If a directory is passed without recursive or wipe_contents.
  226. """
  227. path = Path(path)
  228. exclude = set(exclude or [])
  229. if not path.exists():
  230. raise FileNotFoundError(f"Cannot remove: {path} does not exist")
  231. if wipe_contents and path.is_dir():
  232. for child in path.iterdir():
  233. if child.name in exclude:
  234. continue
  235. if child.is_dir():
  236. shutil.rmtree(child)
  237. else:
  238. child.unlink()
  239. elif path.is_file():
  240. if path.name not in exclude:
  241. path.unlink()
  242. elif path.is_dir():
  243. if recursive:
  244. shutil.rmtree(path)
  245. else:
  246. raise ValueError(f"{path} is a directory. Use recursive=True or wipe_contents=True to remove it.")
  247. def create_dir(self, path):
  248. """
  249. Creates a directory if it does not exist.
  250. If the directory is missing, it will be created along with any necessary parent directories.
  251. Args:
  252. path (str or Path): The directory path to create.
  253. """
  254. dir_path = Path(path)
  255. if not dir_path.exists():
  256. print(f"Creating directory: {dir_path}")
  257. dir_path.mkdir(parents=True, exist_ok=True)
  258. def patch_exists(self, target_file, patch_file, reverse=False):
  259. """
  260. Checks whether a patch can be applied (or reversed) to a target file.
  261. Args:
  262. target_file (str): File to test the patch against.
  263. patch_file (str): Patch file to apply.
  264. reverse (bool): If True, checks whether the patch can be reversed.
  265. Returns:
  266. bool: True if patch is applicable, False otherwise.
  267. """
  268. cmd = ["patch", "-sfN", "--dry-run", target_file, "<", patch_file]
  269. if reverse:
  270. cmd.insert(1, "-R")
  271. try:
  272. result = subprocess.run(
  273. " ".join(cmd),
  274. shell=True,
  275. stdout=subprocess.DEVNULL,
  276. stderr=subprocess.DEVNULL
  277. )
  278. return result.returncode == 0
  279. except Exception as e:
  280. print(f"Patch dry-run failed: {e}")
  281. return False
  282. def apply_patch(self, target_file, patch_file, reverse=False):
  283. """
  284. Applies a patch file to a target file.
  285. Args:
  286. target_file (str): File to be patched.
  287. patch_file (str): Patch file containing the diff.
  288. reverse (bool): If True, applies the patch in reverse (rollback).
  289. Logs:
  290. Success or failure of the patching operation.
  291. """
  292. cmd = ["patch", target_file, "<", patch_file]
  293. if reverse:
  294. cmd.insert(0, "-R")
  295. try:
  296. subprocess.run(" ".join(cmd), shell=True, check=True)
  297. print(f"Applied patch {'(reverse)' if reverse else ''} to {target_file}")
  298. except subprocess.CalledProcessError as e:
  299. print(f"Patch failed: {e}")
  300. def isYes(self, value):
  301. """
  302. Determines whether a given string represents a "yes"-like value.
  303. Args:
  304. value (str): Input string to evaluate.
  305. Returns:
  306. bool: True if value is "yes" or "y" (case-insensitive), otherwise False.
  307. """
  308. return value.lower() in ["yes", "y"]
  309. def is_port_open(self, host, port):
  310. """
  311. Checks whether a TCP port is open on a given host.
  312. Args:
  313. host (str): The hostname or IP address to check.
  314. port (int): The TCP port number to test.
  315. Returns:
  316. bool: True if the port is open and accepting connections, False otherwise.
  317. """
  318. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
  319. sock.settimeout(1)
  320. result = sock.connect_ex((host, port))
  321. return result == 0
  322. def resolve_docker_dns_record(self, hostname, record_type="A"):
  323. """
  324. Resolves DNS A or AAAA records for a given hostname.
  325. Args:
  326. hostname (str): The domain to query.
  327. record_type (str): "A" for IPv4, "AAAA" for IPv6. Default is "A".
  328. Returns:
  329. list[str]: A list of resolved IP addresses.
  330. Raises:
  331. Exception: If resolution fails or no results are found.
  332. """
  333. try:
  334. resolver = dns.resolver.Resolver()
  335. resolver.nameservers = ["127.0.0.11"]
  336. answers = resolver.resolve(hostname, record_type)
  337. return [answer.to_text() for answer in answers]
  338. except Exception as e:
  339. raise Exception(f"Failed to resolve {record_type} record for {hostname}: {e}")
  340. def kill_proc(self, process):
  341. """
  342. Sends a SIGTERM signal to all processes matching the given name using `killall`.
  343. Args:
  344. process (str): The name of the process to terminate.
  345. Returns:
  346. True if the signal was sent successfully, or the subprocess error if it failed.
  347. """
  348. try:
  349. subprocess.run(["killall", "-TERM", process], check=True)
  350. except subprocess.CalledProcessError as e:
  351. return e
  352. return True
  353. def connect_mysql(self):
  354. """
  355. Establishes a connection to the MySQL database using the provided configuration.
  356. Continuously retries the connection until the database is reachable. Stores
  357. the connection in `self.mysql_conn` once successful.
  358. Logs:
  359. Connection status and retry errors to stdout.
  360. """
  361. print("Connecting to MySQL...")
  362. while True:
  363. try:
  364. self.mysql_conn = mysql.connector.connect(**self.db_config)
  365. if self.mysql_conn.is_connected():
  366. print("MySQL is up and ready!")
  367. break
  368. except mysql.connector.Error as e:
  369. print(f"Waiting for MySQL... ({e})")
  370. time.sleep(2)
  371. def close_mysql(self):
  372. """
  373. Closes the MySQL connection if it's currently open and connected.
  374. Safe to call even if the connection has already been closed.
  375. """
  376. if self.mysql_conn and self.mysql_conn.is_connected():
  377. self.mysql_conn.close()
  378. def connect_redis(self, retries=10, delay=2):
  379. """
  380. Establishes a Redis connection and stores it in `self.redis_conn`.
  381. Args:
  382. retries (int): Number of ping retries before giving up.
  383. delay (int): Seconds between retries.
  384. """
  385. client = redis.Redis(
  386. host=self.redis_config['host'],
  387. port=self.redis_config['port'],
  388. password=self.redis_config['password'],
  389. db=self.redis_config['db'],
  390. decode_responses=True
  391. )
  392. for _ in range(retries):
  393. try:
  394. if client.ping():
  395. self.redis_conn = client
  396. return
  397. except redis.RedisError as e:
  398. print(f"Waiting for Redis... ({e})")
  399. time.sleep(delay)
  400. raise ConnectionError("Redis is not available after multiple attempts.")
  401. def close_redis(self):
  402. """
  403. Closes the Redis connection if it's open.
  404. Safe to call even if Redis was never connected or already closed.
  405. """
  406. if self.redis_conn:
  407. try:
  408. self.redis_conn.close()
  409. except Exception as e:
  410. print(f"Error while closing Redis connection: {e}")
  411. finally:
  412. self.redis_conn = None
  413. def wait_for_schema_update(self, init_file_path="init_db.inc.php", check_interval=5):
  414. """
  415. Waits until the current database schema version matches the expected version
  416. defined in a PHP initialization file.
  417. Compares the `version` value in the `versions` table for `application = 'db_schema'`
  418. with the `$db_version` value extracted from the specified PHP file.
  419. Args:
  420. init_file_path (str): Path to the PHP file containing the expected version string.
  421. check_interval (int): Time in seconds to wait between version checks.
  422. Logs:
  423. Current vs. expected schema versions until they match.
  424. """
  425. print("Checking database schema version...")
  426. while True:
  427. current_version = self._get_current_db_version()
  428. expected_version = self._get_expected_schema_version(init_file_path)
  429. if current_version == expected_version:
  430. print(f"DB schema is up to date: {current_version}")
  431. break
  432. print(f"Waiting for schema update... (DB: {current_version}, Expected: {expected_version})")
  433. time.sleep(check_interval)
  434. def wait_for_host(self, host, retry_interval=1.0, count=1):
  435. """
  436. Waits for a host to respond to ICMP ping.
  437. Args:
  438. host (str): Hostname or IP to ping.
  439. retry_interval (float): Seconds to wait between pings.
  440. count (int): Number of ping packets to send per check (default 1).
  441. """
  442. while True:
  443. try:
  444. result = subprocess.run(
  445. ["ping", "-c", str(count), host],
  446. stdout=subprocess.DEVNULL,
  447. stderr=subprocess.DEVNULL
  448. )
  449. if result.returncode == 0:
  450. print(f"{host} is reachable via ping.")
  451. break
  452. except Exception:
  453. pass
  454. print(f"Waiting for {host}...")
  455. time.sleep(retry_interval)
  456. def wait_for_dns(self, domain, retry_interval=1, timeout=30):
  457. """
  458. Waits until the domain resolves via DNS using pure Python (socket).
  459. Args:
  460. domain (str): The domain to resolve.
  461. retry_interval (int): Time (seconds) to wait between attempts.
  462. timeout (int): Maximum total wait time (seconds).
  463. Returns:
  464. bool: True if resolved, False if timed out.
  465. """
  466. start = time.time()
  467. while True:
  468. try:
  469. socket.gethostbyname(domain)
  470. print(f"{domain} is resolving via DNS.")
  471. return True
  472. except socket.gaierror:
  473. pass
  474. if time.time() - start > timeout:
  475. print(f"DNS resolution for {domain} timed out.")
  476. return False
  477. print(f"Waiting for DNS for {domain}...")
  478. time.sleep(retry_interval)
  479. def _get_current_db_version(self):
  480. """
  481. Fetches the current schema version from the database.
  482. Executes a SELECT query on the `versions` table where `application = 'db_schema'`.
  483. Returns:
  484. str or None: The current schema version as a string, or None if not found or on error.
  485. Logs:
  486. Error message if the query fails.
  487. """
  488. try:
  489. cursor = self.mysql_conn.cursor()
  490. cursor.execute("SELECT version FROM versions WHERE application = 'db_schema'")
  491. result = cursor.fetchone()
  492. cursor.close()
  493. return result[0] if result else None
  494. except Exception as e:
  495. print(f"Error fetching current DB schema version: {e}")
  496. return None
  497. def _get_expected_schema_version(self, filepath):
  498. """
  499. Extracts the expected database schema version from a PHP initialization file.
  500. Looks for a line in the form of: `$db_version = "..."` and extracts the version string.
  501. Args:
  502. filepath (str): Path to the PHP file containing the `$db_version` definition.
  503. Returns:
  504. str or None: The extracted version string, or None if not found or on error.
  505. Logs:
  506. Error message if the file cannot be read or parsed.
  507. """
  508. try:
  509. with open(filepath, "r") as f:
  510. content = f.read()
  511. match = re.search(r'\$db_version\s*=\s*"([^"]+)"', content)
  512. if match:
  513. return match.group(1)
  514. except Exception as e:
  515. print(f"Error reading expected schema version from {filepath}: {e}")
  516. return None
  517. def rand_pass(self, length=22):
  518. """
  519. Generates a secure random password using allowed characters.
  520. Allowed characters include upper/lowercase letters, digits, underscores, and hyphens.
  521. Args:
  522. length (int): Length of the password to generate. Default is 22.
  523. Returns:
  524. str: A securely generated random password string.
  525. """
  526. allowed_chars = string.ascii_letters + string.digits + "_-"
  527. return ''.join(secrets.choice(allowed_chars) for _ in range(length))
  528. def run_command(self, command, check=True, shell=False, input_stream=None):
  529. """
  530. Executes an OS command and optionally checks for errors.
  531. Supports piping via input_stream.
  532. Args:
  533. command (str or list): The command to execute.
  534. check (bool): Raise CalledProcessError on failure if True.
  535. shell (bool): Run in a shell if True.
  536. input_stream: A pipe source to use as stdin (e.g. another process's stdout).
  537. Returns:
  538. subprocess.CompletedProcess: The result of the command execution.
  539. Logs:
  540. Prints command output and errors.
  541. """
  542. try:
  543. result = subprocess.run(
  544. command,
  545. shell=shell,
  546. check=check,
  547. stdin=input_stream,
  548. stdout=subprocess.PIPE,
  549. stderr=subprocess.PIPE,
  550. text=True
  551. )
  552. if result.stdout:
  553. print(result.stdout.strip())
  554. if result.stderr:
  555. print(result.stderr.strip())
  556. return result
  557. except subprocess.CalledProcessError as e:
  558. print(f"Command failed with exit code {e.returncode}: {e.cmd}")
  559. print(e.stderr.strip())
  560. if check:
  561. raise
  562. return e
  563. def sha1_filter(self, value):
  564. return hashlib.sha1(value.encode()).hexdigest()