BootstrapBase.py 25 KB

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