1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2025-12-22 06:11:32 +00:00

Optimize python bootstrapper

This commit is contained in:
FreddleSpl0it
2025-05-23 09:49:08 +02:00
parent 5f93ff04a9
commit eb7d2628ac
10 changed files with 164 additions and 114 deletions

View File

@@ -10,42 +10,35 @@ def main():
signal.signal(signal.SIGTERM, handle_sigterm) signal.signal(signal.SIGTERM, handle_sigterm)
container_name = os.getenv("CONTAINER_NAME") container_name = os.getenv("CONTAINER_NAME")
service_name = container_name.replace("-mailcow", "").replace("-", "")
module_name = f"Bootstrap{service_name.capitalize()}"
if container_name == "sogo-mailcow": try:
from modules.BootstrapSogo import Bootstrap mod = __import__(f"modules.{module_name}", fromlist=[module_name])
elif container_name == "nginx-mailcow": Bootstrap = getattr(mod, module_name)
from modules.BootstrapNginx import Bootstrap except (ImportError, AttributeError) as e:
elif container_name == "postfix-mailcow": print(f"Failed to load bootstrap module for: {container_name}{module_name}")
from modules.BootstrapPostfix import Bootstrap print(str(e))
elif container_name == "dovecot-mailcow":
from modules.BootstrapDovecot import Bootstrap
elif container_name == "rspamd-mailcow":
from modules.BootstrapRspamd import Bootstrap
elif container_name == "clamd-mailcow":
from modules.BootstrapClamd import Bootstrap
elif container_name == "mysql-mailcow":
from modules.BootstrapMysql import Bootstrap
elif container_name == "php-fpm-mailcow":
from modules.BootstrapPhpfpm import Bootstrap
else:
print(f"No bootstrap handler for container: {container_name}", file=sys.stderr)
sys.exit(1) sys.exit(1)
b = Bootstrap( b = Bootstrap(
container=container_name, container=container_name,
service=service_name,
db_config={ db_config={
"host": "localhost", "host": "localhost",
"user": os.getenv("DBUSER") or os.getenv("MYSQL_USER"), "user": os.getenv("DBUSER") or os.getenv("MYSQL_USER"),
"password": os.getenv("DBPASS") or os.getenv("MYSQL_PASSWORD"), "password": os.getenv("DBPASS") or os.getenv("MYSQL_PASSWORD"),
"database": os.getenv("DBNAME") or os.getenv("MYSQL_DATABASE"), "database": os.getenv("DBNAME") or os.getenv("MYSQL_DATABASE"),
"unix_socket": "/var/run/mysqld/mysqld.sock", "unix_socket": "/var/run/mysqld/mysqld.sock",
'connection_timeout': 2 'connection_timeout': 2,
'service_table': "service_settings",
'service_types': [service_name]
}, },
db_table="service_settings",
db_settings=['sogo'],
redis_config={ redis_config={
"host": os.getenv("REDIS_SLAVEOF_IP") or "redis-mailcow", "read_host": "redis-mailcow",
"port": int(os.getenv("REDIS_SLAVEOF_PORT") or 6379), "read_port": 6379,
"write_host": os.getenv("REDIS_SLAVEOF_IP") or "redis-mailcow",
"write_port": int(os.getenv("REDIS_SLAVEOF_PORT") or 6379),
"password": os.getenv("REDISPASS"), "password": os.getenv("REDISPASS"),
"db": 0 "db": 0
} }

View File

@@ -7,28 +7,28 @@ import string
import subprocess import subprocess
import time import time
import socket import socket
import signal
import re import re
import redis import redis
import hashlib import hashlib
import json import json
import psutil
import signal
from pathlib import Path from pathlib import Path
import dns.resolver import dns.resolver
import mysql.connector import mysql.connector
from jinja2 import Environment, FileSystemLoader
class BootstrapBase: class BootstrapBase:
def __init__(self, container, db_config, db_table, db_settings, redis_config): def __init__(self, container, service, db_config, redis_config):
self.container = container self.container = container
self.service = service
self.db_config = db_config self.db_config = db_config
self.db_table = db_table
self.db_settings = db_settings
self.redis_config = redis_config self.redis_config = redis_config
self.env = None self.env = None
self.env_vars = None self.env_vars = None
self.mysql_conn = None self.mysql_conn = None
self.redis_conn = None self.redis_connr = None
self.redis_connw = None
def render_config(self, config_dir): def render_config(self, config_dir):
""" """
@@ -43,9 +43,6 @@ class BootstrapBase:
- Also copies the rendered file to: <config_dir>/rendered_configs/<relative_output_path> - Also copies the rendered file to: <config_dir>/rendered_configs/<relative_output_path>
""" """
import json
from pathlib import Path
config_dir = Path(config_dir) config_dir = Path(config_dir)
config_path = config_dir / "config.json" config_path = config_dir / "config.json"
@@ -67,7 +64,13 @@ class BootstrapBase:
continue continue
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
try:
template = self.env.get_template(template_name) template = self.env.get_template(template_name)
except Exception as e:
print(f"Template not found: {template_name} ({e})")
continue
rendered = template.render(self.env_vars) rendered = template.render(self.env_vars)
if clean_blank_lines: if clean_blank_lines:
@@ -112,12 +115,12 @@ class BootstrapBase:
try: try:
cursor = self.mysql_conn.cursor() cursor = self.mysql_conn.cursor()
if self.db_settings: if self.db_config['service_types']:
placeholders = ','.join(['%s'] * len(self.db_settings)) placeholders = ','.join(['%s'] * len(self.db_config['service_types']))
sql = f"SELECT `key`, `value` FROM {self.db_table} WHERE `type` IN ({placeholders})" sql = f"SELECT `key`, `value` FROM {self.db_config['service_table']} WHERE `type` IN ({placeholders})"
cursor.execute(sql, self.db_settings) cursor.execute(sql, self.db_config['service_types'])
else: else:
cursor.execute(f"SELECT `key`, `value` FROM {self.db_table}") cursor.execute(f"SELECT `key`, `value` FROM {self.db_config['service_table']}")
for key, value in cursor.fetchall(): for key, value in cursor.fetchall():
env_vars[key] = value env_vars[key] = value
@@ -247,6 +250,23 @@ class BootstrapBase:
os.chown(sub_path, uid, gid) os.chown(sub_path, uid, gid)
os.chown(p, uid, gid) os.chown(p, uid, gid)
def fix_permissions(self, path, user=None, group=None, mode=None, recursive=False):
"""
Sets owner and/or permissions on a file or directory.
Args:
path (str or Path): Target path.
user (str|int, optional): Username or UID.
group (str|int, optional): Group name or GID.
mode (int, optional): File mode (e.g. 0o644).
recursive (bool): Apply recursively if path is a directory.
"""
if user or group:
self.set_owner(path, user, group, recursive)
if mode:
self.set_permissions(path, mode)
def move_file(self, src, dst, overwrite=True): def move_file(self, src, dst, overwrite=True):
""" """
Moves a file from src to dst, optionally overwriting existing files. Moves a file from src to dst, optionally overwriting existing files.
@@ -458,25 +478,28 @@ class BootstrapBase:
except Exception as e: except Exception as e:
raise Exception(f"Failed to resolve {record_type} record for {hostname}: {e}") raise Exception(f"Failed to resolve {record_type} record for {hostname}: {e}")
def kill_proc(self, process): def kill_proc(self, process_name):
""" """
Sends a SIGTERM signal to all processes matching the given name using `killall`. Sends SIGTERM to all running processes matching the given name.
Args: Args:
process (str): The name of the process to terminate. process_name (str): Name of the process to terminate.
Returns: Returns:
True if the signal was sent successfully, or the subprocess error if it failed. int: Number of processes successfully signaled.
""" """
killed = 0
for proc in psutil.process_iter(['name']):
try: try:
subprocess.run(["killall", "-TERM", process], check=True) if proc.info['name'] == process_name:
except subprocess.CalledProcessError as e: proc.send_signal(signal.SIGTERM)
return e killed += 1
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return killed
return True def connect_mysql(self, socket=None):
def connect_mysql(self):
""" """
Establishes a connection to the MySQL database using the provided configuration. Establishes a connection to the MySQL database using the provided configuration.
@@ -485,13 +508,24 @@ class BootstrapBase:
Logs: Logs:
Connection status and retry errors to stdout. Connection status and retry errors to stdout.
Args:
socket (str, optional): Custom UNIX socket path to override the default.
""" """
print("Connecting to MySQL...") print("Connecting to MySQL...")
config = {
"host": self.db_config['host'],
"user": self.db_config['user'],
"password": self.db_config['password'],
"database": self.db_config['database'],
"unix_socket": socket or self.db_config['unix_socket'],
'connection_timeout': self.db_config['connection_timeout']
}
while True: while True:
try: try:
self.mysql_conn = mysql.connector.connect(**self.db_config) self.mysql_conn = mysql.connector.connect(**config)
if self.mysql_conn.is_connected(): if self.mysql_conn.is_connected():
print("MySQL is up and ready!") print("MySQL is up and ready!")
break break
@@ -509,48 +543,86 @@ class BootstrapBase:
if self.mysql_conn and self.mysql_conn.is_connected(): if self.mysql_conn and self.mysql_conn.is_connected():
self.mysql_conn.close() self.mysql_conn.close()
def connect_redis(self, retries=10, delay=2): def connect_redis(self, max_retries=10, delay=2):
""" """
Establishes a Redis connection and stores it in `self.redis_conn`. Connects to both read and write Redis servers and stores the connections.
Args: Read server: tries indefinitely until successful.
retries (int): Number of ping retries before giving up. Write server: tries up to `max_retries` before giving up.
delay (int): Seconds between retries.
Sets:
self.redis_connr: Redis client for read
self.redis_connw: Redis client for write
""" """
client = redis.Redis( use_rw = self.redis_config['read_host'] == self.redis_config['write_host'] and self.redis_config['read_port'] == self.redis_config['write_port']
host=self.redis_config['host'],
port=self.redis_config['port'], if use_rw:
print("Connecting to Redis read server...")
else:
print("Connecting to Redis server...")
while True:
try:
clientr = redis.Redis(
host=self.redis_config['read_host'],
port=self.redis_config['read_port'],
password=self.redis_config['password'], password=self.redis_config['password'],
db=self.redis_config['db'], db=self.redis_config['db'],
decode_responses=True decode_responses=True
) )
if clientr.ping():
for _ in range(retries): self.redis_connr = clientr
try: print("Redis read server is up and ready!")
if client.ping(): if use_rw:
self.redis_conn = client break
else:
self.redis_connw = clientr
return return
except redis.RedisError as e: except redis.RedisError as e:
print(f"Waiting for Redis... ({e})") print(f"Waiting for Redis read... ({e})")
time.sleep(delay) time.sleep(delay)
raise ConnectionError("Redis is not available after multiple attempts.")
print("Connecting to Redis write server...")
for attempt in range(max_retries):
try:
clientw = redis.Redis(
host=self.redis_config['write_host'],
port=self.redis_config['write_port'],
password=self.redis_config['password'],
db=self.redis_config['db'],
decode_responses=True
)
if clientw.ping():
self.redis_connw = clientw
print("Redis write server is up and ready!")
return
except redis.RedisError as e:
print(f"Waiting for Redis write... (attempt {attempt + 1}/{max_retries}) ({e})")
time.sleep(delay)
print("Redis write server is unreachable.")
def close_redis(self): def close_redis(self):
""" """
Closes the Redis connection if it's open. Closes the Redis read/write connections if open.
Safe to call even if Redis was never connected or already closed.
""" """
if self.redis_conn: if self.redis_connr:
try: try:
self.redis_conn.close() self.redis_connr.close()
except Exception as e: except Exception as e:
print(f"Error while closing Redis connection: {e}") print(f"Error while closing Redis read connection: {e}")
finally: finally:
self.redis_conn = None self.redis_connr = None
if self.redis_connw:
try:
self.redis_connw.close()
except Exception as e:
print(f"Error while closing Redis write connection: {e}")
finally:
self.redis_connw = None
def wait_for_schema_update(self, init_file_path="init_db.inc.php", check_interval=5): def wait_for_schema_update(self, init_file_path="init_db.inc.php", check_interval=5):
""" """

View File

@@ -4,9 +4,8 @@ from pathlib import Path
import os import os
import sys import sys
import time import time
import platform
class Bootstrap(BootstrapBase): class BootstrapClamd(BootstrapBase):
def bootstrap(self): def bootstrap(self):
# Skip Clamd if set # Skip Clamd if set
if self.isYes(os.getenv("SKIP_CLAMD", "")): if self.isYes(os.getenv("SKIP_CLAMD", "")):

View File

@@ -2,12 +2,10 @@ from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase from modules.BootstrapBase import BootstrapBase
from pathlib import Path from pathlib import Path
import os import os
import sys
import time
import pwd import pwd
import hashlib import hashlib
class Bootstrap(BootstrapBase): class BootstrapDovecot(BootstrapBase):
def bootstrap(self): def bootstrap(self):
# Connect to MySQL # Connect to MySQL
self.connect_mysql() self.connect_mysql()
@@ -15,7 +13,8 @@ class Bootstrap(BootstrapBase):
# Connect to Redis # Connect to Redis
self.connect_redis() self.connect_redis()
self.redis_conn.set("DOVECOT_REPL_HEALTH", 1) if self.redis_connw:
self.redis_connw.set("DOVECOT_REPL_HEALTH", 1)
# Wait for DNS # Wait for DNS
self.wait_for_dns("mailcow.email") self.wait_for_dns("mailcow.email")

View File

@@ -1,27 +1,25 @@
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase from modules.BootstrapBase import BootstrapBase
from pathlib import Path
import os import os
import sys
import time import time
import platform
import subprocess import subprocess
class Bootstrap(BootstrapBase): class BootstrapMysql(BootstrapBase):
def bootstrap(self): def bootstrap(self):
dbuser = "root" dbuser = "root"
dbpass = os.getenv("MYSQL_ROOT_PASSWORD", "") dbpass = os.getenv("MYSQL_ROOT_PASSWORD", "")
socket = "/var/run/mysqld/mysqld.sock" socket = "/tmp/mysql-temp.sock"
print("Starting temporary mysqld for upgrade...") print("Starting temporary mysqld for upgrade...")
self.start_temporary(socket) self.start_temporary(socket)
self.connect_mysql() self.connect_mysql(socket)
print("Running mysql_upgrade...") print("Running mysql_upgrade...")
self.upgrade_mysql(dbuser, dbpass, socket) self.upgrade_mysql(dbuser, dbpass, socket)
print("Checking timezone support with CONVERT_TZ...") print("Checking timezone support with CONVERT_TZ...")
self.check_and_import_timezone_support(dbuser, dbpass, socket) self.check_and_import_timezone_support(dbuser, dbpass, socket)
time.sleep(15)
print("Shutting down temporary mysqld...") print("Shutting down temporary mysqld...")
self.close_mysql() self.close_mysql()

View File

@@ -1,11 +1,8 @@
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase from modules.BootstrapBase import BootstrapBase
from pathlib import Path
import os import os
import sys
import time
class Bootstrap(BootstrapBase): class BootstrapNginx(BootstrapBase):
def bootstrap(self): def bootstrap(self):
# Connect to MySQL # Connect to MySQL
self.connect_mysql() self.connect_mysql()

View File

@@ -1,14 +1,9 @@
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase from modules.BootstrapBase import BootstrapBase
from pathlib import Path
import os import os
import ipaddress import ipaddress
import sys
import time
import platform
import subprocess
class Bootstrap(BootstrapBase): class BootstrapPhpfpm(BootstrapBase):
def bootstrap(self): def bootstrap(self):
self.connect_mysql() self.connect_mysql()
self.connect_redis() self.connect_redis()
@@ -63,16 +58,16 @@ class Bootstrap(BootstrapBase):
print("Setting default Redis keys if missing...") print("Setting default Redis keys if missing...")
# Q_RELEASE_FORMAT # Q_RELEASE_FORMAT
if self.redis_conn.get("Q_RELEASE_FORMAT") is None: if self.redis_connw and self.redis_connr.get("Q_RELEASE_FORMAT") is None:
self.redis_conn.set("Q_RELEASE_FORMAT", "raw") self.redis_connw.set("Q_RELEASE_FORMAT", "raw")
# Q_MAX_AGE # Q_MAX_AGE
if self.redis_conn.get("Q_MAX_AGE") is None: if self.redis_connw and self.redis_connr.get("Q_MAX_AGE") is None:
self.redis_conn.set("Q_MAX_AGE", 365) self.redis_connw.set("Q_MAX_AGE", 365)
# PASSWD_POLICY hash defaults # PASSWD_POLICY hash defaults
if self.redis_conn.hget("PASSWD_POLICY", "length") is None: if self.redis_connw and self.redis_connr.hget("PASSWD_POLICY", "length") is None:
self.redis_conn.hset("PASSWD_POLICY", mapping={ self.redis_connw.hset("PASSWD_POLICY", mapping={
"length": 6, "length": 6,
"chars": 0, "chars": 0,
"special_chars": 0, "special_chars": 0,
@@ -82,7 +77,8 @@ class Bootstrap(BootstrapBase):
# DOMAIN_MAP # DOMAIN_MAP
print("Rebuilding DOMAIN_MAP from MySQL...") print("Rebuilding DOMAIN_MAP from MySQL...")
self.redis_conn.delete("DOMAIN_MAP") if self.redis_connw:
self.redis_connw.delete("DOMAIN_MAP")
domains = set() domains = set()
try: try:
cursor = self.mysql_conn.cursor() cursor = self.mysql_conn.cursor()
@@ -96,6 +92,7 @@ class Bootstrap(BootstrapBase):
if domains: if domains:
for domain in domains: for domain in domains:
if self.redis_connw:
self.redis_conn.hset("DOMAIN_MAP", domain, 1) self.redis_conn.hset("DOMAIN_MAP", domain, 1)
print(f"{len(domains)} domains added to DOMAIN_MAP.") print(f"{len(domains)} domains added to DOMAIN_MAP.")
else: else:

View File

@@ -1,11 +1,8 @@
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase from modules.BootstrapBase import BootstrapBase
from pathlib import Path from pathlib import Path
import os
import sys
import time
class Bootstrap(BootstrapBase): class BootstrapPostfix(BootstrapBase):
def bootstrap(self): def bootstrap(self):
# Connect to MySQL # Connect to MySQL
self.connect_mysql() self.connect_mysql()

View File

@@ -1,12 +1,10 @@
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase from modules.BootstrapBase import BootstrapBase
from pathlib import Path from pathlib import Path
import os
import sys
import time import time
import platform import platform
class Bootstrap(BootstrapBase): class BootstrapRspamd(BootstrapBase):
def bootstrap(self): def bootstrap(self):
# Connect to MySQL # Connect to MySQL
self.connect_mysql() self.connect_mysql()

View File

@@ -5,7 +5,7 @@ import os
import sys import sys
import time import time
class Bootstrap(BootstrapBase): class BootstrapSogo(BootstrapBase):
def bootstrap(self): def bootstrap(self):
# Skip SOGo if set # Skip SOGo if set
if self.isYes(os.getenv("SKIP_SOGO", "")): if self.isYes(os.getenv("SKIP_SOGO", "")):