BootstrapBase.py 21 KB

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