diff --git a/bin/maillogsentinel.py b/bin/maillogsentinel.py index 94f305f..20d5dc9 100644 --- a/bin/maillogsentinel.py +++ b/bin/maillogsentinel.py @@ -162,11 +162,9 @@ def main(): sys.exit(1) setup_source_config_path_str: str - setup_mode_flag_str: str if config_file_explicitly_passed: setup_source_config_path_str = str(config_file_path_for_ops) - setup_mode_flag_str = "--automated" progress_tracker.print_message( # Updated call f"Attempting automated setup using configuration file: {setup_source_config_path_str}", level="info", @@ -174,7 +172,6 @@ def main(): else: # For interactive setup, DEFAULT_CONFIG_PATH is the *target* configuration file. setup_source_config_path_str = str(DEFAULT_CONFIG_PATH) - setup_mode_flag_str = "--interactive" progress_tracker.print_message( "Starting interactive setup process...", level="info" ) # Updated call @@ -187,9 +184,13 @@ def main(): process_args = [ sys.executable, str(setup_script_path), + "--config", setup_source_config_path_str, - setup_mode_flag_str, ] + if config_file_explicitly_passed: + process_args.append("--non-interactive") + else: + process_args.append("--interactive") process = subprocess.Popen( process_args ) # stdout/stderr go to parent's by default diff --git a/bin/maillogsentinel_setup.py b/bin/maillogsentinel_setup.py index a36ef35..6bddab6 100644 --- a/bin/maillogsentinel_setup.py +++ b/bin/maillogsentinel_setup.py @@ -1,1577 +1,290 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- +"""MailLogSentinel installation and configuration utility.""" -""" -Setup script for MailLogSentinel. +from __future__ import annotations -This script guides the user through interactive setup or performs an -automated setup based on a provided configuration file. It handles: -- Collecting configuration parameters (paths, email, schedules, etc.). -- Creating necessary directories. -- Generating and installing Systemd service and timer units. -- Setting file ownership and user group memberships. - -The script can be invoked with --interactive or --automated flags. -It is typically called by the main maillogsentinel.py application when -the --setup flag is used. -""" -import signal -import os +import argparse +import logging import sys -import shutil -import subprocess -import configparser -import tempfile - -# import curses # Removed -# import curses.textpad # Removed -# import socket # No longer used directly -# import csv # No longer used directly -# import smtplib # No longer used by setup -import getpass -import functools # For partial -import argparse # For command-line argument parsing - -# import time # No longer used directly, datetime.now().strftime is used -# from email.message import EmailMessage # F401: imported but unused -from datetime import datetime from pathlib import Path +from typing import Dict, Optional, Sequence -# typing.Optional and typing.Any are not used -# from typing import ( -# Optional, -# Any, -# ) -import copy # For deepcopy of default config -import re # For systemd unit generation input validation -import pwd # For user validation - -# Constants specific to the setup script -# SCRIPT_NAME_SETUP = "MailLogSentinelSetup" # F841: local variable 'SCRIPT_NAME_SETUP' is assigned to but never used -# VERSION_SETUP = "v1.0" # F841: local variable 'VERSION_SETUP' is assigned to but never used -DEFAULT_CONFIG_PATH_SETUP = Path("/etc/maillogsentinel.conf") -# SETUP_LOG_FILENAME = "maillogsentinel_setup.log" # F841: local variable 'SETUP_LOG_FILENAME' is assigned to but never used -LOG_FILENAME = "maillogsentinel.log" -CSV_FILENAME = "maillogsentinel.csv" -STATE_FILENAME = "state.offset" -# Constants for default paths used within interactive_setup -DEFAULT_WORKING_DIR = Path("/var/log/maillogsentinel") -DEFAULT_STATE_DIR = Path("/var/lib/maillogsentinel") -DEFAULT_MAIL_LOG = Path("/var/log/mail.log") -DEFAULT_COUNTRY_DB_PATH = Path("/var/lib/maillogsentinel/country_aside.csv") -DEFAULT_ASN_DB_PATH = Path("/var/lib/maillogsentinel/asn.csv") - -# Global variables for signal handling and cleanup -# sigint_received = False # No longer used with custom exception -backed_up_items = [] # Stores (backup_path, original_path) tuples -created_final_paths = ( - [] -) # Stores paths of files/dirs created by setup in final locations - - -# Custom exception for SIGINT -class SigintEncountered(BaseException): - pass +from lib.maillogsentinel import config as config_module +from lib.maillogsentinel.output import ( + OutputOptions, + confirm, + detect_color_support, + divider, + heading, + info, + list_block, + prompt, + success, + warning, +) -# Signal handler for SIGINT -def handle_sigint(signum, frame): - # This handler will now directly raise an exception - # to interrupt the main flow. - raise SigintEncountered() - - -def _setup_print_and_log( - message: str = "", - file_handle=None, - is_prompt: bool = False, - end: str = "\n", - console_out: bool = True, -): - """ - Prints a message to the console and/or writes it to the provided file_handle. - - Args: - message: The message string. - file_handle: Open file handle for logging. - is_prompt: If True, prints to console with end='' (for input prompts). - If False, uses 'end' parameter for console. - end: String appended after the message in console (if not is_prompt). - console_out: If False, message is only written to log file, not console. - """ - # Print to console if console_out is True - if console_out: - if is_prompt: - print(message, end="", flush=True) - else: - print(message, end=end, flush=True) - - # Write to log file - if file_handle and not file_handle.closed: - try: - file_handle.write(message + "\n") # Always add newline for log file entries - file_handle.flush() - except IOError as e: - # If logging fails, print an error to actual stderr. - original_stderr_print_for_logging_error = functools.partial( - print, file=sys.stderr, flush=True - ) - original_stderr_print_for_logging_error( - f"ERROR: Could not write to setup log file: {e}" - ) +LOG = logging.getLogger("maillogsentinel.setup") -def _change_ownership(path_to_change, user_name, setup_log_fh): - """Attempts to change the ownership of the given path to the specified user.""" - _setup_print_and_log( - f"Attempting to change ownership of {path_to_change} to user {user_name}...", - setup_log_fh, +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", type=Path, default=Path("/etc/maillogsentinel.conf") ) - try: - shutil.chown(str(path_to_change), user=user_name, group=None) - _setup_print_and_log( - f"Successfully changed ownership of {path_to_change} to {user_name}.", - setup_log_fh, - ) - return True - except Exception as e: - _setup_print_and_log( - f"ERROR: Failed to change ownership of {path_to_change} to {user_name}: {e}", - setup_log_fh, - ) - return False - - -# Removed display_main_menu(stdscr) as it was curses-dependent - -# The _get_curses_input function is now removed as it's obsolete. -# The _update_progress_display function was already simplified. - - -def validate_calendar_expression( - calendar_str: str, setup_log_fh, default_fallback_expr: str -) -> str: - """ - Validates a Systemd OnCalendar string using 'systemd-analyze calendar'. - - Args: - calendar_str: The OnCalendar string to validate. - setup_log_fh: File handle for logging. - default_fallback_expr: The default expression to return if validation fails. - - Returns: - The original calendar_str if valid, otherwise default_fallback_expr. - """ - if not calendar_str: - _setup_print_and_log( - f"WARNING: Calendar string is empty. Falling back to default: {default_fallback_expr}", - setup_log_fh, - ) - return default_fallback_expr - - systemd_analyze_cmd = shutil.which("systemd-analyze") - if not systemd_analyze_cmd: - _setup_print_and_log( - "WARNING: 'systemd-analyze' command not found. Cannot validate OnCalendar expressions. " - f"Using provided value '{calendar_str}' without validation, assuming it is correct or relying on Systemd's own error handling later." - " This is not ideal. Falling back to default to be safe.", - setup_log_fh, - ) - # If validation tool is missing, fall back to default to be safe. - return default_fallback_expr - - try: - # We add --iterations=1 to make it faster and avoid it hanging or producing too much output. - process = subprocess.run( - [systemd_analyze_cmd, "calendar", "--iterations=1", calendar_str], - capture_output=True, - text=True, - check=False, # Do not raise exception on non-zero exit - ) - if process.returncode == 0: - _setup_print_and_log( - f"Calendar expression '{calendar_str}' validated successfully.", - setup_log_fh, - console_out=False, - ) # Log success only to file - return calendar_str - else: - _setup_print_and_log( - f"WARNING: Invalid Systemd OnCalendar expression: '{calendar_str}'. " - f"Error: {process.stderr.strip()}. Falling back to default: {default_fallback_expr}", - setup_log_fh, - ) - return default_fallback_expr - except FileNotFoundError: # Should be caught by shutil.which already, but as a safeguard. - _setup_print_and_log( - "WARNING: 'systemd-analyze' command not found during execution attempt. Cannot validate OnCalendar expressions. " - f"Falling back to default: {default_fallback_expr}", # Also fallback here - setup_log_fh, - ) - return default_fallback_expr - except Exception as e: - _setup_print_and_log( - f"WARNING: An unexpected error occurred while validating calendar expression '{calendar_str}': {e}. " - f"Falling back to default: {default_fallback_expr}", - setup_log_fh, - ) - return default_fallback_expr - - -# Helper function for progress display -def _update_progress_display(message: str, setup_log_fh): - """Prints a progress message to console and log.""" - _setup_print_and_log(f"PROGRESS: {message}", setup_log_fh) - - -def _get_cli_input( - prompt_text: str, - default_value: str, - setup_log_fh, - info_text: str = "", - is_path: bool = False, - is_email: bool = False, - allowed_values: list = None, - is_bool: bool = False, - is_int: bool = False, - int_non_negative: bool = False, -): - """ - Handles user input from the command line for interactive setup. - - Displays prompts, default values, and additional info. Performs basic - validation based on the type of input expected (path, email, boolean, - integer, or choice from a list). - - Args: - prompt_text: The main question to ask the user. - default_value: The default value if the user enters nothing. - setup_log_fh: File handle for logging. - info_text: Additional context or help for the prompt. - is_path: If True, checks that the input is not empty. - is_email: If True, checks for non-empty and presence of '@'. - allowed_values: A list of allowed string inputs (case-insensitive). - is_bool: If True, expects a y/n answer, returns "True" or "False". - is_int: If True, validates that the input is an integer. - int_non_negative: If True (and is_int is True), ensures integer is >= 0. - - Returns: - The validated user input as a string. - - Raises: - SigintEncountered: If Ctrl+C is pressed during input. - """ - full_prompt = f"{prompt_text} " - if info_text: - full_prompt += f"({info_text}) " - if allowed_values: - full_prompt += f"[Allowed: {', '.join(allowed_values)}] " - if is_bool: - full_prompt += "[y/n] " - full_prompt += f"(default: {default_value}): " - - while True: - try: - _setup_print_and_log( - full_prompt, setup_log_fh, is_prompt=True, console_out=True - ) # Prompt always to console - user_input_str = input().strip() - # Log raw input, but not to console - _setup_print_and_log( - f"User input for '{prompt_text}': '{user_input_str}' (raw)", - setup_log_fh, - console_out=False, - ) - - chosen_value = user_input_str if user_input_str else default_value - # Log effective value, but not to console - _setup_print_and_log( - f"Effective value for '{prompt_text}': '{chosen_value}'", - setup_log_fh, - console_out=False, - ) - - if is_path and not chosen_value: - _setup_print_and_log( - "Error: Path cannot be empty. Please try again.", - setup_log_fh, - console_out=True, - ) # Errors to console - continue - if ( - is_email - ): # Basic check for @, more robust validation can be added if needed - if not chosen_value: - _setup_print_and_log( - "Error: Email cannot be empty. Please try again.", - setup_log_fh, - console_out=True, - ) # Errors to console - continue - if "@" not in chosen_value: - _setup_print_and_log( - "Error: Invalid email format. Please try again.", - setup_log_fh, - console_out=True, - ) # Errors to console - continue - if allowed_values and chosen_value.upper() not in [ - val.upper() for val in allowed_values - ]: - _setup_print_and_log( - f"Error: Invalid input. Must be one of {', '.join(allowed_values)}. Please try again.", - setup_log_fh, - ) - continue - if is_bool: - if chosen_value.lower() in ["y", "yes", "true", "1"]: - return "True" - elif chosen_value.lower() in ["n", "no", "false", "0"]: - return "False" - else: - _setup_print_and_log( - "Error: Please answer 'y' or 'n'. Please try again.", - setup_log_fh, - ) - continue - if is_int: - try: - int_val = int(chosen_value) - if int_non_negative and int_val < 0: - _setup_print_and_log( - "Error: Value must be a non-negative integer. Please try again.", - setup_log_fh, - ) - continue - return str(int_val) # Return as string to match configparser needs - except ValueError: - _setup_print_and_log( - "Error: Value must be an integer. Please try again.", - setup_log_fh, - ) - continue - - return chosen_value - except KeyboardInterrupt: - _setup_print_and_log("\nUser interrupted input.", setup_log_fh) - raise SigintEncountered("User interrupted input.") - - -def interactive_cli_setup(target_config_path, setup_log_fh): - global backed_up_items, created_final_paths - backed_up_items = [] - created_final_paths = [] - - _setup_print_and_log("--- MailLogSentinel Interactive Setup ---", setup_log_fh) - _setup_print_and_log( - "This script will guide you through the configuration process.", setup_log_fh + parser.add_argument( + "--interactive", action="store_true", help="Run interactive setup" ) - _setup_print_and_log("Press Ctrl+C at any time to abort.", setup_log_fh) - - default_config_values = { - "paths": { - "working_dir": str(DEFAULT_WORKING_DIR), - "state_dir": str(DEFAULT_STATE_DIR), - "mail_log": str(DEFAULT_MAIL_LOG), - "csv_filename": CSV_FILENAME, - }, - "report": { - "email": "security-team@example.org", - "subject_prefix": "[MailLogSentinel]", - "sender_override": f"{getpass.getuser()}@localhost", - }, - "geolocation": { - "country_db_path": str(DEFAULT_COUNTRY_DB_PATH), - "country_db_url": "https://raw.githubusercontent.com/sapics/ip-location-db/main/asn-country/asn-country-ipv4-num.csv", - }, - "ASN_ASO": { - "asn_db_path": str(DEFAULT_ASN_DB_PATH), - "asn_db_url": "https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn/asn-ipv4-num.csv", - }, - "general": { - "log_level": "INFO", - "log_file_max_bytes": "1000000", - "log_file_backup_count": "5", - }, - "dns_cache": {"enabled": "True", "size": "128", "ttl_seconds": "3600"}, - "sqlite_database": { - "db_type": "sqlite3", # Fixed for now - "db_path": "/var/lib/maillogsentinel/maillogsentinel.sqlite", - "user": "", # Not used for SQLite - "password_hash": "", # Not used for SQLite - "salt": "", # Not used for SQLite - }, - "sql_export_systemd": { - "frequency": "*:0/4", - }, - "sql_import_systemd": { - "frequency": "*:0/5", - }, - } - collected_config = copy.deepcopy(default_config_values) - sections_to_configure = [ - "paths", - "report", - "geolocation", - "ASN_ASO", - "general", - "dns_cache", - "sqlite_database", - "sql_export_systemd", - "sql_import_systemd", - ] - allowed_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - - try: - for section_name in sections_to_configure: - _setup_print_and_log( - f"\n--- Configuring {section_name.capitalize()} Settings ---", - setup_log_fh, - ) - if section_name in default_config_values: - for key, default_value in default_config_values[section_name].items(): - prompt_text = f"Enter {key.replace('_', ' ')} for '{section_name}'" - ( - info_text, - is_a_path, - is_an_email, - allowed, - is_a_bool, - is_an_int, - is_nn_int, - ) = ("", False, False, None, False, False, False) - - if section_name == "paths": - info_text = "Ensure path is writable by the run_as_user." - if key == "csv_filename": - info_text = f"Relative to working dir ({collected_config['paths']['working_dir']})." - is_a_path = True # For emptiness check, actual path validation is harder here - elif section_name == "report": - is_an_email = key == "email" - if key == "sender_override": - info_text = ( - "Must be an existing email on this server if used." - ) - elif section_name in ["geolocation", "ASN_ASO"]: - if key.endswith("_url"): - # URLs are not configurable, skip prompt and use default. - collected_config[section_name][key] = str(default_value) - _setup_print_and_log( - f" Set [{section_name}] {key} = {str(default_value)} (default, not configurable)", - setup_log_fh, - console_out=False, - ) - continue # Skip to next key - if key.endswith("_path"): - is_a_path = True - info_text = "Path for local DB copy. Recommended: default suggestions" # Updated info text - elif section_name == "general": - if key == "log_level": - allowed = allowed_log_levels - else: - info_text = "Non-negative integer." - is_an_int = True - is_nn_int = True - elif section_name == "dns_cache": - if key == "enabled": - is_a_bool = True - else: - info_text = "Non-negative integer." - is_an_int = True - is_nn_int = True - elif section_name == "sqlite_database": - if key == "db_type": - # Fixed for now, so skip prompt and use default. - collected_config[section_name][key] = str(default_value) - _setup_print_and_log( - f" Set [{section_name}] {key} = {str(default_value)} (fixed to sqlite3 for now)", - setup_log_fh, - console_out=False, - ) - continue # Skip to next key - elif key == "db_path": - is_a_path = True - info_text = "Path to the SQLite database file." - elif key in ["user", "password_hash", "salt"]: - # Not used for SQLite, skip prompt and use default (empty). - collected_config[section_name][key] = str(default_value) - _setup_print_and_log( - f" Set [{section_name}] {key} = '{str(default_value)}' (not used for SQLite)", - setup_log_fh, - console_out=False, - ) - continue # Skip to next key - elif section_name == "sql_export_systemd": - if key == "frequency": - info_text = "Systemd OnCalendar format only (e.g., *:0/4, hourly, 08:30)." - elif section_name == "sql_import_systemd": - if key == "frequency": - info_text = "Systemd OnCalendar format only (e.g., *:0/5, 02:00)." - - # Only call _get_cli_input if not skipped above - user_input_str = _get_cli_input( - prompt_text, - str(default_value), - setup_log_fh, - info_text, - is_path=is_a_path, - is_email=is_an_email, - allowed_values=allowed, - is_bool=is_a_bool, - is_int=is_an_int, - int_non_negative=is_nn_int, - ) - - processed_value = user_input_str - if section_name == "general" and key == "log_level": - processed_value = user_input_str.upper() - elif section_name == "sql_export_systemd" and key == "frequency": - processed_value = validate_calendar_expression( - user_input_str, setup_log_fh, "*:0/4" # Default fallback for export - ) - elif section_name == "sql_import_systemd" and key == "frequency": - processed_value = validate_calendar_expression( - user_input_str, setup_log_fh, "*:0/5" # Default fallback for import - ) - - collected_config[section_name][key] = str(processed_value) - _setup_print_and_log( - f" Set [{section_name}] {key} = {str(processed_value)}", - setup_log_fh, - console_out=False, - ) # Log only - - _setup_print_and_log( - "\n--- Systemd Timer Configuration (Optional) ---", - setup_log_fh, - console_out=True, - ) - extraction_schedule_str = _get_cli_input( - "Log extraction frequency (e.g., 'hourly', '0 */6 * * *')", - "hourly", - setup_log_fh, - info_text="Systemd OnCalendar format or keywords like hourly, daily.", - ) - extraction_schedule_str = validate_calendar_expression( - extraction_schedule_str, setup_log_fh, "hourly" - ) - - while True: - raw_report_time_str = _get_cli_input( - "Daily report OnCalendar value (e.g., '08:50', 'daily', '*-*-* HH:MM:SS')", - "daily", # Default input to _get_cli_input - setup_log_fh, - info_text="Systemd OnCalendar format.", - ) - # Validate the raw input first - validated_report_time_str = validate_calendar_expression( - raw_report_time_str, setup_log_fh, "daily" # Fallback for validation - ) - - if re.fullmatch(r"\d{2}:\d{2}", validated_report_time_str): - h, m = map(int, validated_report_time_str.split(":")) - report_on_calendar_formatted = f"*-*-* {h:02d}:{m:02d}:00" - break - elif validated_report_time_str.lower() == "daily": - report_on_calendar_formatted = "*-*-* 23:59:59" # Systemd 'daily' often means midnight - break - else: - # If already validated and not HH:MM or 'daily', use as is - # (assuming it's a more complex valid OnCalendar string) - report_on_calendar_formatted = validated_report_time_str - break - - ip_update_schedule_str = _get_cli_input( - "IP DB update frequency (e.g., 'daily', '0 2 * * 1')", - "daily", - setup_log_fh, - info_text="Systemd OnCalendar format.", - ) - ip_update_schedule_str = validate_calendar_expression( - ip_update_schedule_str, setup_log_fh, "daily" - ) - - # Get SQL export and import schedules - sql_export_schedule_str = _get_cli_input( - "SQL export frequency", - collected_config["sql_export_systemd"]["frequency"], # Default from config - setup_log_fh, - info_text="Systemd OnCalendar format only (e.g., *:0/4, hourly, 08:30).", - ) - sql_export_schedule_str = validate_calendar_expression( - sql_export_schedule_str, setup_log_fh, "*:0/4" - ) - collected_config["sql_export_systemd"][ - "frequency" - ] = sql_export_schedule_str # Update collected config - - sql_import_schedule_str = _get_cli_input( - "SQL import frequency", - collected_config["sql_import_systemd"]["frequency"], # Default from config - setup_log_fh, - info_text="Systemd OnCalendar format only (e.g., *:0/5, 02:00).", - ) - sql_import_schedule_str = validate_calendar_expression( - sql_import_schedule_str, setup_log_fh, "*:0/5" - ) - collected_config["sql_import_systemd"][ - "frequency" - ] = sql_import_schedule_str # Update collected config - - suggested_user = os.environ.get("SUDO_USER", getpass.getuser()) - suggested_user = ( - "your_non_root_user" if suggested_user == "root" else suggested_user - ) - while True: - run_as_user = _get_cli_input( - "Non-root user for services", - suggested_user, - setup_log_fh, - info_text="This user will own files and run cron jobs.", - ) - if not run_as_user: - _setup_print_and_log("Username cannot be empty.", setup_log_fh) - continue - if run_as_user == "root": - _setup_print_and_log( - "Running as root is not allowed for services. Please choose a non-root user.", - setup_log_fh, - ) - continue - try: - pwd.getpwnam(run_as_user) - break - except KeyError: - _setup_print_and_log( - f"Error: User '{run_as_user}' not found on this system. Please create it first or use an existing non-root user.", - setup_log_fh, - ) - - _setup_print_and_log("\n--- Configuration Review ---", setup_log_fh) - parser_display = configparser.ConfigParser() - for s_name, s_options in collected_config.items(): - parser_display.add_section(s_name) - for k, v_val in s_options.items(): - parser_display.set(s_name, k, str(v_val)) - - _setup_print_and_log("Collected Configuration:", setup_log_fh) - for section in parser_display.sections(): - _setup_print_and_log(f"[{section}]", setup_log_fh) - for key, value in parser_display.items(section): - _setup_print_and_log(f" {key} = {value}", setup_log_fh) - _setup_print_and_log("[Systemd]", setup_log_fh) - _setup_print_and_log( - f" extraction_schedule = {extraction_schedule_str}", setup_log_fh - ) - _setup_print_and_log( - f" report_on_calendar = {report_on_calendar_formatted}", setup_log_fh - ) - _setup_print_and_log( - f" ip_update_schedule = {ip_update_schedule_str}", setup_log_fh - ) - _setup_print_and_log( - f" sql_export_schedule = {sql_export_schedule_str}", setup_log_fh - ) - _setup_print_and_log( - f" sql_import_schedule = {sql_import_schedule_str}", setup_log_fh - ) - _setup_print_and_log(f" run_as_user = {run_as_user}", setup_log_fh) - - confirm = _get_cli_input( - "\nSave this configuration and proceed with setup?", - "n", - setup_log_fh, - is_bool=True, - ) - if confirm.lower() != "true": - _setup_print_and_log( - "Configuration not saved. Exiting setup.", setup_log_fh - ) - return False - - _update_progress_display("Saving configuration file...", setup_log_fh) - parser_save = configparser.ConfigParser() - for s, o in collected_config.items(): - parser_save.add_section(s) - for k, v in o.items(): - parser_save.set(s, k, str(v)) - - # Add User and systemd sections for use by non_interactive_setup and other functions - if not parser_save.has_section("User"): - parser_save.add_section("User") - parser_save.set("User", "run_as_user", run_as_user) - if not parser_save.has_section("systemd"): - parser_save.add_section("systemd") - parser_save.set("systemd", "extraction_schedule", extraction_schedule_str) - parser_save.set( - "systemd", "report_schedule", report_on_calendar_formatted - ) # Use the formatted one - parser_save.set("systemd", "ip_update_schedule", ip_update_schedule_str) - - temp_dir_obj_cfg = tempfile.TemporaryDirectory(prefix="mls_cfg_") - tmp_config_path = Path(temp_dir_obj_cfg.name) / "tmp.conf" - with tmp_config_path.open("w") as cf_write: - cf_write.write( - f"# Generated by MailLogSentinel Interactive Setup {datetime.now().isoformat()}\n" - ) - parser_save.write(cf_write) - - target_config_path.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(tmp_config_path), str(target_config_path)) - created_final_paths.append(str(target_config_path)) - _setup_print_and_log( - f"Configuration saved to: {target_config_path}", setup_log_fh - ) - if temp_dir_obj_cfg: - temp_dir_obj_cfg.cleanup() - - _update_progress_display("Creating directories...", setup_log_fh) - final_wd = Path(collected_config["paths"]["working_dir"]) - final_sd = Path(collected_config["paths"]["state_dir"]) - paths_to_create = {"Working Directory": final_wd, "State Directory": final_sd} - ts_backup = datetime.now().strftime("%Y%m%d%H%M%S") - - for name, p_obj in paths_to_create.items(): - if p_obj.exists(): - backup_target = p_obj.parent / f"{p_obj.name}.backup_{ts_backup}" - _setup_print_and_log( - f"{name} '{p_obj}' exists. Backing up to '{backup_target}'...", - setup_log_fh, - ) - shutil.move(str(p_obj), str(backup_target)) - backed_up_items.append((str(backup_target), str(p_obj))) - p_obj.mkdir(parents=True, exist_ok=True) - created_final_paths.append(str(p_obj)) - _setup_print_and_log(f"Ensured {name} exists: {p_obj}", setup_log_fh) - - if os.geteuid() != 0: - _setup_print_and_log("\nWARNING: Running as non-root user.", setup_log_fh) - _setup_print_and_log( - "Systemd unit installation, ownership changes, and systemctl commands will be skipped.", - setup_log_fh, - ) - _setup_print_and_log( - "You may need to manually perform these steps or re-run with sudo IF you want systemd integration.", - setup_log_fh, - ) - _setup_print_and_log( - f"Ensure the user '{run_as_user}' has write permissions to '{target_config_path}', '{final_wd}', and '{final_sd}'.", - setup_log_fh, - ) - _setup_print_and_log( - f"And read permissions for '{collected_config['paths']['mail_log']}'.", - setup_log_fh, - ) - _setup_print_and_log( - "Interactive setup completed (manual steps may be required).", - setup_log_fh, - ) - return True # Core config done, but with caveats - - _update_progress_display("Generating Systemd unit files...", setup_log_fh) - python_exec = shutil.which("python3") or "/usr/bin/python3" - script_main_path = ( - shutil.which("maillogsentinel.py") or "/usr/local/bin/maillogsentinel.py" - ) - ipinfo_script_path_sysd = ( - shutil.which("ipinfo.py") or "/usr/local/bin/ipinfo.py" - ) + parser.add_argument( + "--non-interactive", action="store_true", help="Run without prompts" + ) + parser.add_argument("--dry-run", action="store_true", help="Do not write files") + parser.add_argument("--no-color", action="store_true", help="Disable ANSI colors") + parser.add_argument( + "--set", + dest="overrides", + action="append", + default=[], + help="Override using section.option=value", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Logging verbosity", + ) + return parser - units_content = _generate_systemd_units_content( - run_as_user, - python_exec, - script_main_path, - str(target_config_path), - str(final_wd), - extraction_schedule_str, - report_on_calendar_formatted, - ip_update_schedule_str, - ipinfo_script_path_sysd, - sql_export_schedule_str, # New - sql_import_schedule_str, # New - ) - install_systemd = _get_cli_input( - "\nInstall Systemd unit files to /etc/systemd/system/?", - "y", - setup_log_fh, - is_bool=True, - ) - systemd_files_installed_flag = False - if install_systemd.lower() == "true": - _update_progress_display("Installing Systemd unit files...", setup_log_fh) - systemd_dir = Path("/etc/systemd/system/") - systemd_dir.mkdir(parents=True, exist_ok=True) - for unit_filename, content in units_content.items(): - unit_path = systemd_dir / unit_filename - # Basic backup for systemd files if they exist - if unit_path.exists(): - backup_unit_path = ( - unit_path.parent / f"{unit_path.name}.backup_{ts_backup}" - ) - _setup_print_and_log( - f"Backing up existing systemd unit {unit_path} to {backup_unit_path}", - setup_log_fh, - ) - shutil.move(str(unit_path), str(backup_unit_path)) # Move, not copy - backed_up_items.append((str(backup_unit_path), str(unit_path))) - unit_path.write_text(content) - created_final_paths.append(str(unit_path)) - _setup_print_and_log(f" Installed {unit_filename}", setup_log_fh) - systemd_files_installed_flag = True - else: - _setup_print_and_log( - "Systemd unit file installation skipped by user.", setup_log_fh +def parse_overrides(pairs: Sequence[str]) -> Dict[str, str]: + overrides: Dict[str, str] = {} + for raw in pairs: + if "=" not in raw: + raise config_module.ConfigurationError( + "Overrides must be formatted as section.option=value" ) + key, value = raw.split("=", 1) + key = key.replace(".", "__") + overrides[key] = value + return overrides - apply_system_changes = _get_cli_input( - "\nProceed with final system changes (ownership, groups, systemctl daemon-reload/enable timers)?", - "y", - setup_log_fh, - is_bool=True, - ) - if apply_system_changes.lower() == "true": - _update_progress_display("Applying ownership changes...", setup_log_fh) - _change_ownership(str(target_config_path), run_as_user, setup_log_fh) - _change_ownership(str(final_wd), run_as_user, setup_log_fh) - _change_ownership(str(final_sd), run_as_user, setup_log_fh) - # Also DB paths if they are not within workdir/statedir and exist - for db_key in ["country_db_path", "asn_db_path"]: - db_path_str = collected_config.get("geolocation", {}).get( - db_key - ) or collected_config.get("ASN_ASO", {}).get(db_key) - if db_path_str: - db_p = Path(db_path_str) - if ( - db_p.parent != final_wd and db_p.parent != final_sd - ): # Avoid chowning twice if inside - # Ensure parent dir is chowned if it was created by setup, or if file itself is chowned - # This is complex; for now, just chown the file if it exists. - # A better approach might be to ensure DBs are within workdir. - if db_p.exists(): - _change_ownership(str(db_p), run_as_user, setup_log_fh) - elif db_p.parent.exists(): - _change_ownership( - str(db_p.parent), run_as_user, setup_log_fh - ) - - _update_progress_display( - f"Adding user {run_as_user} to 'adm' group (if not already a member)...", - setup_log_fh, - ) - usermod_cmd_path = shutil.which("usermod") - if usermod_cmd_path: - usermod_proc = subprocess.run( - [usermod_cmd_path, "-aG", "adm", run_as_user], - capture_output=True, - text=True, - ) - _setup_print_and_log( - f"usermod -aG adm {run_as_user}: RC={usermod_proc.returncode}, STDOUT='{usermod_proc.stdout.strip()}', STDERR='{usermod_proc.stderr.strip()}'", - setup_log_fh, - ) - if usermod_proc.returncode != 0: - _setup_print_and_log( - f" WARNING: usermod command failed. User '{run_as_user}' may need manual assignment to 'adm' group to read logs.", - setup_log_fh, - ) - else: - _setup_print_and_log( - " WARNING: 'usermod' command not found. Cannot add user to 'adm' group automatically.", - setup_log_fh, - ) - if systemd_files_installed_flag: - systemctl_cmd_path = shutil.which("systemctl") - if systemctl_cmd_path: - _update_progress_display( - "Reloading systemd daemon...", setup_log_fh - ) - try: - subprocess.run( - [systemctl_cmd_path, "daemon-reload"], - check=True, - capture_output=True, - text=True, - ) - _setup_print_and_log( - " systemctl daemon-reload successful.", setup_log_fh - ) - except subprocess.CalledProcessError as e_ctl: - _setup_print_and_log( - f" ERROR: systemctl daemon-reload failed: {e_ctl.stderr}", - setup_log_fh, - ) +def ensure_systemd_unit(name: str, *, options: OutputOptions) -> None: + timer_path = Path("/etc/systemd/system") / name + if timer_path.exists(): + info(f"Systemd unit {name} already exists", options=options) + return + info(f"Systemd unit {name} will be created on apply", options=options) - _update_progress_display( - "Enabling and starting Systemd timers...", setup_log_fh - ) - for timer_name in [ - "maillogsentinel-extract.timer", - "maillogsentinel-report.timer", - "ipinfo-update.timer", - "maillogsentinel-sql-export.timer", # New - "maillogsentinel-sql-import.timer", # New - ]: - if (Path("/etc/systemd/system") / timer_name).exists(): - try: - subprocess.run( - [systemctl_cmd_path, "enable", "--now", timer_name], - check=True, - capture_output=True, - text=True, - ) - _setup_print_and_log( - f" Enabled and started {timer_name}.", setup_log_fh - ) - except subprocess.CalledProcessError as e_ctl_timer: - _setup_print_and_log( - f" ERROR: Failed to enable/start {timer_name}: {e_ctl_timer.stderr}", - setup_log_fh, - ) - else: - _setup_print_and_log( - f" Timer {timer_name} not found in /etc/systemd/system, skipping enable/start.", - setup_log_fh, - ) - else: - _setup_print_and_log( - " 'systemctl' command not found. Systemd operations skipped.", - setup_log_fh, - ) - else: - _setup_print_and_log( - " Systemd files were not installed, skipping systemctl operations.", - setup_log_fh, - ) - else: - _setup_print_and_log("Final system changes skipped by user.", setup_log_fh) - _setup_print_and_log("\n--- Interactive Setup Completed ---", setup_log_fh) - _setup_print_and_log( - f"Configuration file is at: {target_config_path}", setup_log_fh - ) - _setup_print_and_log("Please review the setup log for details.", setup_log_fh) - if os.geteuid() == 0 and systemd_files_installed_flag: - _setup_print_and_log("Systemd timers should now be active.", setup_log_fh) +def verify_permissions(service_user: str, *, options: OutputOptions) -> None: + try: + import pwd # Lazy import to avoid platform issues during tests - except SigintEncountered: - _setup_print_and_log( - "\nUser interrupted interactive setup (Ctrl+C).", setup_log_fh - ) - raise # Re-raise to be caught by main_setup's handler for cleanup - except Exception as e_main_interactive: - _setup_print_and_log( - f"FATAL ERROR during interactive setup: {e_main_interactive.__class__.__name__}: {e_main_interactive}", - setup_log_fh, - ) - _setup_print_and_log( - f"FATAL ERROR during interactive setup: {e_main_interactive.__class__.__name__}: {e_main_interactive}", - setup_log_fh, + pwd.getpwnam(service_user) + except KeyError: + warning( + f"Service user '{service_user}' does not exist. Please create it before running the service.", + options=options, ) - # import traceback # No longer needed - - # _setup_print_and_log(traceback.format_exc(), setup_log_fh) # No longer needed - return False - return True - - -# The interactive_curses_setup function is now removed as it's obsolete. -# Removed curses_main function - - -def non_interactive_setup(source_config_path: Path, setup_log_fh): - """ - Performs a non-interactive setup using a source configuration file. - The target system configuration path is DEFAULT_CONFIG_PATH_SETUP. - Updates global backed_up_items and created_final_paths lists. - """ - import sys # Ensure sys is imported for stdout - print("non_interactive_setup CALLED", flush=True) - # traceback.print_stack(file=sys.stdout, limit=10) # Removed for debugging - print("---", flush=True) - _setup_print_and_log("--- MailLogSentinel Non-Interactive Setup ---", setup_log_fh) - - if os.geteuid() != 0: - _setup_print_and_log( - "ERROR: Non-interactive setup requires root privileges. Please run with sudo.", - setup_log_fh, - ) - sys.exit(1) - - if not source_config_path.is_file(): - _setup_print_and_log( - f"ERROR: Source configuration file '{source_config_path}' not found or is not a file.", - setup_log_fh, +def apply_configuration( + cfg: config_module.Configuration, + *, + config_path: Path, + dry_run: bool, + options: OutputOptions, +) -> str: + writer = config_module.ConfigurationWriter(config_path, logger=LOG) + content, diff = writer.write(cfg, dry_run=dry_run) + heading("Configuration diff", options=options, level=2) + print(diff or "(no changes)", file=options.stream) + return content + + +def review_configuration( + cfg: config_module.Configuration, *, options: OutputOptions +) -> None: + heading("Review configuration", options=options, level=2) + divider(options=options) + for key, value in config_module.summarize_configuration(cfg): + print(f"{key:<30}: {value}", file=options.stream) + divider(options=options) + + +def run_interactive_flow( + cfg: config_module.Configuration, + *, + merger: config_module.ConfigurationMerger, + options: OutputOptions, +) -> config_module.Configuration: + info("Interactive setup", options=options) + cfg.general.log_level = prompt( + "Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)", + default=cfg.general.log_level, + options=options, + ).upper() + cfg.general.log_file_max_mb = int( + prompt( + "Max log file size (MB)", + default=str(cfg.general.log_file_max_mb), + options=options, ) - sys.exit(1) - _setup_print_and_log( - f"Using source configuration file: {source_config_path.resolve()}", setup_log_fh ) - - _setup_print_and_log("Loading and validating source configuration...", setup_log_fh) - config = configparser.ConfigParser() - try: - read_files = config.read(source_config_path) - if not read_files: - _setup_print_and_log( - f"ERROR: Could not read or parse source configuration file: {source_config_path}", - setup_log_fh, - ) - sys.exit(1) - except configparser.Error as e: - _setup_print_and_log( - f"ERROR: Invalid configuration file format in {source_config_path}: {e}", - setup_log_fh, + cfg.general.log_file_backup_count = int( + prompt( + "Log file backup count", + default=str(cfg.general.log_file_backup_count), + options=options, ) - sys.exit(1) - - required_sections = { - "paths": ["working_dir", "state_dir", "mail_log"], - "report": ["email"], - "general": ["log_level"], - "User": ["run_as_user"], - # sqlite_database, sql_export_systemd, sql_import_systemd are optional for non-interactive - } - for section, keys in required_sections.items(): - if not config.has_section(section): - _setup_print_and_log( - f"ERROR: Missing section '[{section}]' in configuration file.", - setup_log_fh, - ) # Ensure brackets - sys.exit(1) - for key in keys: - if not config.has_option(section, key) or ( - config.has_option(section, key) and not config.get(section, key).strip() - ): - _setup_print_and_log( - f"ERROR: Missing or empty value for '{key}' in section '[{section}]'.", - setup_log_fh, - ) # Ensure brackets - sys.exit(1) - _setup_print_and_log( - "Source configuration loaded and basic validation passed.", setup_log_fh ) - target_config_file = DEFAULT_CONFIG_PATH_SETUP - _setup_print_and_log( - f"Target system configuration file: {target_config_file}", setup_log_fh + cfg.paths.working_dir = prompt( + "Working directory", default=cfg.paths.working_dir, options=options ) - if target_config_file.exists(): - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - backup_config_path = ( - target_config_file.parent / f"{target_config_file.name}.backup_{timestamp}" - ) - try: - shutil.move(str(target_config_file), str(backup_config_path)) - _setup_print_and_log( - f"Backed up existing configuration {target_config_file} to {backup_config_path}", - setup_log_fh, - ) - backed_up_items.append((str(backup_config_path), str(target_config_file))) - except (OSError, shutil.Error) as e: - _setup_print_and_log( - f"ERROR: Could not back up existing configuration file {target_config_file}: {e}", - setup_log_fh, - ) - sys.exit(1) - - try: - target_config_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(source_config_path), str(target_config_file)) - _setup_print_and_log( - f"Copied source configuration to {target_config_file}", setup_log_fh - ) - created_final_paths.append(str(target_config_file)) - except (OSError, shutil.Error) as e: - _setup_print_and_log( - f"ERROR: Could not copy configuration file to {target_config_file}: {e}", - setup_log_fh, - ) - sys.exit(1) - - working_dir = Path( - config.get("paths", "working_dir", fallback=str(DEFAULT_WORKING_DIR)) + cfg.paths.state_dir = prompt( + "State directory", default=cfg.paths.state_dir, options=options ) - state_dir = Path(config.get("paths", "state_dir", fallback=str(DEFAULT_STATE_DIR))) - _setup_print_and_log(f"Working directory from config: {working_dir}", setup_log_fh) - _setup_print_and_log(f"State directory from config: {state_dir}", setup_log_fh) - - for dir_path, dir_name in [(working_dir, "working"), (state_dir, "state")]: - if dir_path.exists(): - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - backup_dir_path = dir_path.parent / f"{dir_path.name}.backup_{timestamp}" - try: - shutil.move(str(dir_path), str(backup_dir_path)) - _setup_print_and_log( - f"Backed up existing {dir_name} directory {dir_path} to {backup_dir_path}", - setup_log_fh, - ) - backed_up_items.append((str(backup_dir_path), str(dir_path))) - except (OSError, shutil.Error) as e: - _setup_print_and_log( - f"ERROR: Could not back up existing {dir_name} directory {dir_path}: {e}", - setup_log_fh, - ) - sys.exit(1) - try: - dir_path.mkdir(parents=True, exist_ok=True) - _setup_print_and_log( - f"Created {dir_name} directory: {dir_path}", setup_log_fh - ) - created_final_paths.append(str(dir_path)) - except OSError as e: - _setup_print_and_log( - f"ERROR: Could not create {dir_name} directory {dir_path}: {e}", - setup_log_fh, - ) - sys.exit(1) - - run_as_user = config.get("User", "run_as_user") - _setup_print_and_log( - f"Service will be configured to run as user: '{run_as_user}' (from configuration).", - setup_log_fh, + cfg.paths.mail_log = prompt( + "Mail log path", default=cfg.paths.mail_log, options=options ) - if run_as_user == "root": - _setup_print_and_log( - "ERROR: Configuration specifies 'root' as 'run_as_user'. Not allowed.", - setup_log_fh, - ) - sys.exit(1) - try: - pwd.getpwnam(run_as_user) - _setup_print_and_log( - f"User '{run_as_user}' verified successfully.", setup_log_fh - ) - except KeyError: - _setup_print_and_log(f"ERROR: User '{run_as_user}' not found.", setup_log_fh) - sys.exit(1) # Restored error - except Exception as e: - _setup_print_and_log(f"ERROR verifying user '{run_as_user}': {e}", setup_log_fh) - sys.exit(1) - - adm_group = "adm" - _setup_print_and_log( - f"Attempting to add user '{run_as_user}' to group '{adm_group}'...", - setup_log_fh, + cfg.paths.csv_filename = prompt( + "CSV filename", default=cfg.paths.csv_filename, options=options ) - usermod_cmd = shutil.which("usermod") - if not usermod_cmd: - _setup_print_and_log("ERROR: 'usermod' not found.", setup_log_fh) - sys.exit(1) # Restored error - try: - process_result = subprocess.run( - [usermod_cmd, "-aG", adm_group, run_as_user], - check=True, - capture_output=True, - text=True, - ) - _setup_print_and_log( - f"User '{run_as_user}' in group '{adm_group}'. STDOUT: {process_result.stdout.strip()} STDERR: {process_result.stderr.strip()}", - setup_log_fh, - ) - except Exception as e: - _setup_print_and_log(f"ERROR adding user to group: {e}", setup_log_fh) - sys.exit(1) # Restored error - _setup_print_and_log( - f"Changing ownership of config, work, and state dirs to '{run_as_user}'...", - setup_log_fh, + cfg.permissions.service_user = prompt( + "Service user", default=cfg.permissions.service_user, options=options ) - _change_ownership(str(target_config_file), run_as_user, setup_log_fh) - _change_ownership(str(working_dir), run_as_user, setup_log_fh) - _change_ownership(str(state_dir), run_as_user, setup_log_fh) - _setup_print_and_log("Generating Systemd unit files...", setup_log_fh) - python_executable = shutil.which("python3") or "/usr/bin/python3" - script_path_for_systemd = ( - shutil.which("maillogsentinel.py") or "/usr/local/bin/maillogsentinel.py" + cfg.report.recipient = prompt( + "Report recipient email (leave blank to disable)", + default=cfg.report.recipient, + options=options, ) - if not Path( - script_path_for_systemd - ).is_file() and not script_path_for_systemd.startswith("/usr/local/bin"): - _setup_print_and_log( - f"WARN: script not found at {script_path_for_systemd}", setup_log_fh - ) - extraction_schedule_str = config.get( - "systemd", "extraction_schedule", fallback="hourly" + cfg.report.subject_prefix = prompt( + "Email subject prefix", + default=cfg.report.subject_prefix, + options=options, ) - report_on_calendar = config.get("systemd", "report_schedule", fallback="daily") - if report_on_calendar.lower() == "daily": - report_on_calendar = "*-*-* 23:59:00" - elif re.fullmatch(r"\d{2}:\d{2}", report_on_calendar): - h, m = map(int, report_on_calendar.split(":")) - report_on_calendar = f"*-*-* {h:02d}:{m:02d}:00" - ip_update_schedule_str = config.get( - "systemd", "ip_update_schedule", fallback="daily" + cfg.report.sender = prompt( + "Sender email override (leave blank for default)", + default=cfg.report.sender, + options=options, ) - ipinfo_script_path = shutil.which("ipinfo.py") or "/usr/local/bin/ipinfo.py" - if not Path(ipinfo_script_path).is_file() and not ipinfo_script_path.startswith( - "/usr/local/bin" - ): - _setup_print_and_log( - f"WARN: ipinfo.py script not found at {ipinfo_script_path}", setup_log_fh - ) - # Get SQL export and import schedules from config, with defaults - sql_export_schedule_str = config.get( - "sql_export_systemd", "frequency", fallback="*:0/4" - ) - sql_export_schedule_str = validate_calendar_expression( - sql_export_schedule_str, setup_log_fh, "*:0/4" + cfg.dns_cache.enabled = confirm( + "Enable DNS cache?", + options=options, + default=cfg.dns_cache.enabled, ) - sql_import_schedule_str = config.get( - "sql_import_systemd", "frequency", fallback="*:0/5" + cfg.dns_cache.size = int( + prompt("DNS cache size", default=str(cfg.dns_cache.size), options=options) ) - sql_import_schedule_str = validate_calendar_expression( - sql_import_schedule_str, setup_log_fh, "*:0/5" + cfg.dns_cache.ttl = int( + prompt( + "DNS cache TTL (seconds)", default=str(cfg.dns_cache.ttl), options=options + ) ) - # Validate other schedules as well - extraction_schedule_str = validate_calendar_expression( - extraction_schedule_str, setup_log_fh, "hourly" - ) - report_on_calendar = validate_calendar_expression( - report_on_calendar, setup_log_fh, "daily" # Assuming 'daily' implies a valid Systemd value like 00:00 or similar + cfg.geo.country_db_path = prompt( + "Country DB path", default=cfg.geo.country_db_path, options=options ) - ip_update_schedule_str = validate_calendar_expression( - ip_update_schedule_str, setup_log_fh, "daily" + cfg.geo.asn_db_path = prompt( + "ASN DB path", default=cfg.geo.asn_db_path, options=options ) - - unit_files_content = _generate_systemd_units_content( - run_as_user, - python_executable, - script_path_for_systemd, - str(target_config_file), - str(working_dir), - extraction_schedule_str, - report_on_calendar, - ip_update_schedule_str, - ipinfo_script_path, - sql_export_schedule_str, # New - sql_import_schedule_str, # New + cfg.database.path = prompt( + "SQLite database path", default=cfg.database.path, options=options ) - systemd_dir_path = Path("/etc/systemd/system/") # Restored - temp_dir_obj_units = tempfile.TemporaryDirectory(prefix="mls_units_") - temp_dir = Path(temp_dir_obj_units.name) - all_units_prepared = True - for filename, content in unit_files_content.items(): - try: - (temp_dir / filename).write_text(content) - except IOError as e: - _setup_print_and_log( - f"ERROR writing temp unit {filename}: {e}", setup_log_fh - ) - all_units_prepared = False - break - if all_units_prepared: - _setup_print_and_log(f"Systemd units prepared in {temp_dir}", setup_log_fh) - systemd_dir_path.mkdir( - parents=True, exist_ok=True - ) # Ensure systemd dir exists before moving - for filename in unit_files_content.keys(): - final_file_path = systemd_dir_path / filename - if final_file_path.exists(): - timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - backup_unit_path = ( - final_file_path.parent - / f"{final_file_path.name}.backup_{timestamp}" - ) - try: - shutil.move(str(final_file_path), str(backup_unit_path)) - _setup_print_and_log( - f"Backed up {final_file_path} to {backup_unit_path}", - setup_log_fh, - ) - backed_up_items.append( - (str(backup_unit_path), str(final_file_path)) - ) - except Exception as e: - _setup_print_and_log( - f"ERROR backing up {final_file_path}: {e}", setup_log_fh - ) - try: - shutil.move(str(temp_dir / filename), str(final_file_path)) - _setup_print_and_log(f"Installed {final_file_path}", setup_log_fh) - created_final_paths.append(str(final_file_path)) - except Exception as e: - _setup_print_and_log( - f"ERROR installing {final_file_path}: {e}", setup_log_fh - ) - if temp_dir_obj_units: - temp_dir_obj_units.cleanup() - _setup_print_and_log( - "Reloading systemd daemon and enabling timers...", setup_log_fh + cfg.timers.log_extraction = prompt( + "Log extraction frequency", + default=cfg.timers.log_extraction, + options=options, ) - systemctl_cmd = shutil.which("systemctl") - if not systemctl_cmd: - _setup_print_and_log("ERROR: 'systemctl' not found.", setup_log_fh) - sys.exit(1) # Restored error - try: - subprocess.run( - [systemctl_cmd, "daemon-reload"], check=True, capture_output=True, text=True - ) - _setup_print_and_log("Systemd daemon reloaded.", setup_log_fh) - except subprocess.CalledProcessError as e: - error_detail = f"Stderr: {e.stderr.strip()}" if e.stderr else "No stderr." - _setup_print_and_log( - f"ERROR: 'systemctl daemon-reload' failed: {e}. {error_detail}", - setup_log_fh, - ) - sys.exit(1) - except Exception as e: # Catch other exceptions - _setup_print_and_log( - f"ERROR: 'systemctl daemon-reload' failed with an unexpected error: {e}", - setup_log_fh, - ) - sys.exit(1) - - timers_to_enable = [ - "maillogsentinel-extract.timer", - "maillogsentinel-report.timer", - "ipinfo-update.timer", - "maillogsentinel-sql-export.timer", # New - "maillogsentinel-sql-import.timer", # New - ] - for timer in timers_to_enable: - if not (systemd_dir_path / timer).exists(): - _setup_print_and_log( - f"WARN: Timer {timer} not found, skip enable.", setup_log_fh - ) - continue - try: - subprocess.run( - [systemctl_cmd, "enable", "--now", timer], - check=True, - capture_output=True, - text=True, - ) - _setup_print_and_log(f"Enabled/started {timer}", setup_log_fh) - except subprocess.CalledProcessError as e: - error_detail = f"Stderr: {e.stderr.strip()}" if e.stderr else "No stderr." - _setup_print_and_log( - f"ERROR: 'systemctl enable --now {timer}' failed: {e}. {error_detail}", - setup_log_fh, - ) - sys.exit(1) - except Exception as e: # Catch other exceptions - _setup_print_and_log( - f"ERROR: 'systemctl enable --now {timer}' failed with an unexpected error: {e}", - setup_log_fh, - ) - sys.exit(1) - - _setup_print_and_log("--- Non-Interactive Setup Completed ---", setup_log_fh) - - -def _generate_systemd_units_content( - run_as_user, - python_exec, - script_path, - config_path, - work_dir, - extract_sched, - report_sched, - ip_update_sched, - ipinfo_script_path, - sql_export_schedule, # New - sql_import_schedule, # New -): - _setup_print_and_log( - f"Generating systemd units with user={run_as_user}, script={script_path}", None + cfg.timers.report = prompt( + "Report frequency", + default=cfg.timers.report, + options=options, ) - maillogsentinel_service_content = f"""[Unit]\nDescription=MailLogSentinel Log Extraction Service\nAfter=network.target\n\n[Service]\nType=oneshot\nUser={run_as_user}\nExecStart={python_exec} {script_path} --config {config_path}\nWorkingDirectory={work_dir}\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n""" - maillogsentinel_extract_timer_content = f"""[Unit]\nDescription=Run MailLogSentinel Log Extraction periodically\n\n[Timer]\nUnit=maillogsentinel.service\nOnCalendar={extract_sched}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n""" - maillogsentinel_report_service_content = f"""[Unit]\nDescription=MailLogSentinel Daily Report Service\nAfter=network.target\n\n[Service]\nType=oneshot\nUser={run_as_user}\nExecStart={python_exec} {script_path} --config {config_path} --report\nWorkingDirectory={work_dir}\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n""" - maillogsentinel_report_timer_content = f"""[Unit]\nDescription=Run MailLogSentinel Daily Report\n\n[Timer]\nUnit=maillogsentinel-report.service\nOnCalendar={report_sched}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n""" - ipinfo_update_service_content = f"""[Unit]\nDescription=Service to update IP DBs for MailLogSentinel\nAfter=network.target\n\n[Service]\nType=oneshot\nUser={run_as_user}\nExecStart={python_exec} {ipinfo_script_path} --update --config {config_path}\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n""" - ipinfo_update_timer_content = f"""[Unit]\nDescription=Timer to update IP DBs\n\n[Timer]\nUnit=ipinfo-update.service\nOnCalendar={ip_update_sched}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n""" - - # New SQL Export units - maillogsentinel_sql_export_service_content = f"""[Unit]\nDescription=MailLogSentinel SQL Export Service\nAfter=network.target\n\n[Service]\nType=oneshot\nUser={run_as_user}\nExecStart={python_exec} {script_path} --config {config_path} --sql-export\nWorkingDirectory={work_dir}\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n""" - maillogsentinel_sql_export_timer_content = f"""[Unit]\nDescription=Run MailLogSentinel SQL Export periodically\n\n[Timer]\nUnit=maillogsentinel-sql-export.service\nOnCalendar={sql_export_schedule}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n""" - - # New SQL Import units - maillogsentinel_sql_import_service_content = f"""[Unit]\nDescription=MailLogSentinel SQL Import Service\nAfter=network.target\n\n[Service]\nType=oneshot\nUser={run_as_user}\nExecStart={python_exec} {script_path} --config {config_path} --sql-import\nWorkingDirectory={work_dir}\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n""" - maillogsentinel_sql_import_timer_content = f"""[Unit]\nDescription=Run MailLogSentinel SQL Import periodically\n\n[Timer]\nUnit=maillogsentinel-sql-import.service\nOnCalendar={sql_import_schedule}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n""" - - return { - "maillogsentinel.service": maillogsentinel_service_content, - "maillogsentinel-extract.timer": maillogsentinel_extract_timer_content, - "maillogsentinel-report.service": maillogsentinel_report_service_content, - "maillogsentinel-report.timer": maillogsentinel_report_timer_content, - "ipinfo-update.service": ipinfo_update_service_content, - "ipinfo-update.timer": ipinfo_update_timer_content, - "maillogsentinel-sql-export.service": maillogsentinel_sql_export_service_content, - "maillogsentinel-sql-export.timer": maillogsentinel_sql_export_timer_content, - "maillogsentinel-sql-import.service": maillogsentinel_sql_import_service_content, - "maillogsentinel-sql-import.timer": maillogsentinel_sql_import_timer_content, - } - - -def main_setup(): - """Main entry point for the setup script.""" - original_console_print = functools.partial(print, flush=True) - SETUP_LOG_FILENAME = "maillogsentinel_setup.log" - log_file_path = Path.cwd() / SETUP_LOG_FILENAME - setup_log_fh = None - # curses_was_active = False # No longer needed - - # Argument parsing - parser = argparse.ArgumentParser(description="MailLogSentinel Setup Script.") - parser.add_argument( - "config_file_path", - nargs="?", - help="Path to the configuration file. For interactive setup, this is the target path (defaults to /etc/maillogsentinel.conf). For automated setup, this is the source config file.", + cfg.timers.ip_db_update = prompt( + "IP DB update frequency", + default=cfg.timers.ip_db_update, + options=options, ) - parser.add_argument( - "--interactive", action="store_true", help="Run interactive setup." + cfg.timers.sql_export = prompt( + "SQL export frequency", + default=cfg.timers.sql_export, + options=options, ) - parser.add_argument( - "--automated", - action="store_true", - help="Run automated setup (requires config_file_path as source).", + cfg.timers.sql_import = prompt( + "SQL import frequency", + default=cfg.timers.sql_import, + options=options, ) - # --test-non-interactive-direct is removed as it's obsolete - - args = parser.parse_args() - - try: - setup_log_fh = open(log_file_path, "w", encoding="utf-8") - original_console_print( - f"Note: The setup process output will be saved to {log_file_path.resolve()}" - ) - _setup_print_and_log(f"Setup script invoked. Args: {sys.argv}", setup_log_fh) - global backed_up_items, created_final_paths # Ensure global scope - backed_up_items = [] - created_final_paths = [] + issues = cfg.validate() + if issues: + heading("Validation errors", options=options, level=2) + list_block(issues, options=options) + raise config_module.ConfigurationError("; ".join(issues)) + return cfg - signal.signal(signal.SIGINT, handle_sigint) # Setup SIGINT handler - if args.interactive: - _setup_print_and_log("Mode: Interactive Setup", setup_log_fh) - target_config_path = ( - Path(args.config_file_path) - if args.config_file_path - else DEFAULT_CONFIG_PATH_SETUP - ) - _setup_print_and_log( - f"Target configuration file: {target_config_path}", setup_log_fh - ) - interactive_cli_setup(target_config_path, setup_log_fh) # Placeholder call +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) - elif args.automated: - _setup_print_and_log("Mode: Automated Setup", setup_log_fh) - if not args.config_file_path: - _setup_print_and_log( - "ERROR: Automated setup requires a source configuration file path.", - setup_log_fh, - ) - original_console_print( - "ERROR: --automated flag requires the config_file_path argument to be specified as the source.", - file=sys.stderr, - ) - sys.exit(2) - source_config_path = Path(args.config_file_path) - if not source_config_path.is_file(): - _setup_print_and_log( - f"ERROR: Source configuration file not found: {source_config_path}", - setup_log_fh, - ) - original_console_print( - f"ERROR: Source configuration file not found: {source_config_path}", - file=sys.stderr, - ) - sys.exit(2) - _setup_print_and_log( - f"Source configuration file: {source_config_path}", setup_log_fh - ) - non_interactive_setup(source_config_path, setup_log_fh) + logging.basicConfig(level=getattr(logging, args.log_level)) + color_supported = detect_color_support() and not args.no_color + options = OutputOptions(color=color_supported, stream=sys.stdout) - else: - # Default behavior if no mode is specified: try to be helpful. - # This part might be adjusted based on how maillogsentinel.py calls it. - # If maillogsentinel.py *always* passes a mode, this 'else' might become an error. - original_console_print( - "No setup mode specified. Use --interactive or --automated." - ) - _setup_print_and_log("No setup mode specified via arguments.", setup_log_fh) - original_console_print( - f"Example for interactive: {sys.argv[0]} --interactive [/path/to/target/config.conf]" - ) - original_console_print( - f"Example for automated: {sys.argv[0]} --automated /path/to/source/config.conf" - ) - sys.exit(1) + overrides = parse_overrides(args.overrides) + merger = config_module.ConfigurationMerger( + config_path=args.config, + overrides=overrides, + logger=LOG, + ) - except SigintEncountered: - log_msg = "\nCtrl+C detected. Setup process is stopping." - # No curses cleanup needed here anymore - if setup_log_fh and not setup_log_fh.closed: - _setup_print_and_log(log_msg, setup_log_fh) - else: - original_console_print(log_msg) + try: + cfg = merger.load() + except config_module.ConfigurationError as error: + warning(str(error), options=options) + return 1 - if backed_up_items: - original_console_print( - "Attempting to restore backed-up items due to interruption..." - ) - for backup_path_str, original_path_str in reversed(backed_up_items): - try: - shutil.move(str(backup_path_str), str(original_path_str)) - original_console_print( - f"Restored: {backup_path_str} -> {original_path_str}" - ) - except Exception as e_restore: - original_console_print( - f"Error restoring {backup_path_str} to {original_path_str}: {e_restore}" - ) - if created_final_paths: - original_console_print( - "Attempting to delete created files/directories due to interruption..." - ) - for path_str in reversed(created_final_paths): - try: - path_obj = Path(path_str) - if path_obj.is_file(): - path_obj.unlink() - elif path_obj.is_dir(): - shutil.rmtree(str(path_obj)) - original_console_print(f"Deleted: {path_obj}") - except Exception as e_delete: - original_console_print(f"Error deleting {path_obj}: {e_delete}") - sys.exit(130) + if args.interactive: + try: + cfg = run_interactive_flow(cfg, merger=merger, options=options) + except config_module.ConfigurationError as error: + warning(str(error), options=options) + return 1 + + review_configuration(cfg, options=options) + if args.dry_run: + info("Dry-run mode: configuration will not be written.", options=options) + else: + if args.interactive and not confirm( + "Apply configuration?", options=options, default=True + ): + warning("Configuration not applied.", options=options) + return 0 - except Exception as e: - error_msg = f"FATAL ERROR during setup: {e.__class__.__name__}: {e}" - # No curses cleanup needed here - if setup_log_fh and not setup_log_fh.closed: - _setup_print_and_log(error_msg, setup_log_fh) - import traceback + try: + apply_configuration( + cfg, config_path=args.config, dry_run=args.dry_run, options=options + ) + except config_module.ConfigurationError as error: + warning(str(error), options=options) + return 1 - _setup_print_and_log(traceback.format_exc(), setup_log_fh) - else: - original_console_print(error_msg, file=sys.stderr) - import traceback + verify_permissions(cfg.permissions.service_user, options=options) + ensure_systemd_unit("maillogsentinel.timer", options=options) + ensure_systemd_unit("maillogsentinel-report.timer", options=options) - original_console_print(traceback.format_exc(), file=sys.stderr) - sys.exit(3) - finally: - # No curses cleanup needed here - if setup_log_fh and not setup_log_fh.closed: - _setup_print_and_log("\nSetup script finished.", setup_log_fh) - setup_log_fh.close() - original_console_print(f"Setup log available at {log_file_path.resolve()}") - elif not setup_log_fh: - original_console_print( - f"Setup log was intended for {log_file_path.resolve()}, but might not have been created/written due to an early error." - ) + success("Setup completed successfully.", options=options) + return 0 if __name__ == "__main__": - main_setup() + sys.exit(main()) diff --git a/lib/maillogsentinel/config.py b/lib/maillogsentinel/config.py index 22fe8bf..08a0f19 100644 --- a/lib/maillogsentinel/config.py +++ b/lib/maillogsentinel/config.py @@ -1,362 +1,563 @@ -"""Configuration management for MailLogSentinel.""" +"""Configuration management for MailLogSentinel setup and runtime. + +This module provides a versioned configuration schema with helpers to +load, validate, review, and persist configuration data in an idempotent +way. The public API is split between a modern `Configuration` dataclass +used by the setup workflow and a backwards-compatible `AppConfig` class +that retains the attribute interface consumed by the rest of the +application. + +The precedence order for configuration sources is: + +1. Command line overrides (highest priority) +2. Environment variables (``MAILLOGSENTINEL__SECTION__OPTION``) +3. Configuration file +4. Built-in defaults (lowest priority) + +The module only relies on the Python standard library. +""" + +from __future__ import annotations import configparser +import copy +import difflib +import json +import logging +import os +import re +import shutil +import tempfile +from dataclasses import asdict, dataclass, field from pathlib import Path -import logging # For potential logging within config loading itself -import sys # For sys.stderr and sys.exit - ensure this is imported -from typing import Optional, Dict, Any # Added Dict and Any for type hints - -# Centralized Default Configuration -DEFAULT_CONFIG: Dict[str, Dict[str, Any]] = { - "paths": { - "working_dir": "/var/log/maillogsentinel", - "state_dir": "state", # Relative to working_dir by default - "mail_log": "/var/log/mail.log", - "csv_filename": "maillogsentinel.csv", - }, - "report": { - "email": None, - "subject_prefix": "[MailLogSentinel]", - "sender_override": None, - }, - "geolocation": { - "country_db_path": "/var/lib/maillogsentinel/country_aside.csv", - "country_db_url": "https://raw.githubusercontent.com/sapics/ip-location-db/main/asn-country/asn-country-ipv4-num.csv", # noqa: E501 - }, - "ASN_ASO": { - "asn_db_path": "/var/lib/maillogsentinel/asn.csv", - "asn_db_url": "https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn/asn-ipv4-num.csv", # noqa: E501 - }, - "general": { - "log_level": "INFO", - "log_file_max_bytes": 1_000_000, - "log_file_backup_count": 5, - "log_file": "/var/log/maillogsentinel/maillogsentinel.log", - }, - "dns_cache": { - "enabled": True, - "size": 128, - "ttl_seconds": 3600, - }, - "sqlite_database": { - "db_type": "sqlite3", - "db_path": "/var/lib/maillogsentinel/maillogsentinel.sqlite", - "user": "", - "password_hash": "", - "salt": "", - }, - "sql_export_systemd": { - "frequency": "*:0/4", - }, - "sql_import_systemd": { - "frequency": "*:0/5", - }, - "sql_export_settings": { - "column_mapping_file": "", # Empty string means use bundled default unless overridden by user - "table_name": "maillogsentinel_events", - }, -} +from typing import Any, Dict, List, Mapping, Optional, Tuple +CONFIG_SCHEMA_VERSION = "1.0" -class AppConfig: - """Handles loading and providing access to application configuration settings.""" - def __init__(self, config_path: Path, logger: Optional[logging.Logger] = None): - # type: (...) -> None - """ - Initializes AppConfig by loading settings from the specified config_path. - - Args: - config_path: Path to the maillogsentinel.conf file. - logger: Optional logger instance. If None, a default logger is used. - """ - self.logger = logger if logger else logging.getLogger(__name__) - self.parser = configparser.ConfigParser() - self.config_path = config_path # Store for reference, e.g. in error messages - self.config_loaded_successfully = False # Initialize attribute - - if not config_path.is_file(): - # Log this attempt, but allow continuation for setup or default generation - self.logger.warning( - f"Config file not found at specified path: {config_path}. " - f"Proceeding with default values." - ) - # Defaults will be used. Setup process should handle actual config file creation. - else: - try: - self.parser.read( - config_path - ) # Simplified: removed all debug prints from this block - self.config_loaded_successfully = True - self.logger.info( - f"Successfully loaded configuration from {config_path}" - ) - except configparser.Error as e: - self.logger.error(f"Error parsing config file {config_path}: {e}") - # print(f"⚠️ Error parsing config file {config_path}: {e}", file=sys.stderr) # Logger handles this - # Depending on strictness, could exit or raise. For now, will use defaults. - - # --- Load configuration settings with defaults --- - # [paths] - self.working_dir = Path(self._get_path("paths", "working_dir")) - - _state_dir_str = self._get_str("paths", "state_dir") - if _state_dir_str and Path(_state_dir_str).is_absolute(): - self.state_dir = Path(_state_dir_str) - else: # Relative to working_dir or default 'state' - self.state_dir = self.working_dir / ( - _state_dir_str - if _state_dir_str - else DEFAULT_CONFIG["paths"]["state_dir"] - ) +class ConfigurationError(Exception): + """Raised when configuration validation fails.""" - self.mail_log = Path(self._get_path("paths", "mail_log")) - self.csv_filename = self._get_str("paths", "csv_filename") - # [report] - self.report_email = self._get_str("report", "email") - self.report_subject_prefix = self._get_str("report", "subject_prefix") - self.report_sender_override = self._get_str("report", "sender_override") +def _to_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lower = value.strip().lower() + if lower in {"1", "true", "yes", "y", "on"}: + return True + if lower in {"0", "false", "no", "n", "off"}: + return False + raise ConfigurationError(f"Cannot coerce value to boolean: {value!r}") - # [geolocation] - self.country_db_path = Path(self._get_path("geolocation", "country_db_path")) - self.country_db_url = self._get_str("geolocation", "country_db_url") - # [ASN_ASO] - self.asn_db_path = Path(self._get_path("ASN_ASO", "asn_db_path")) - self.asn_db_url = self._get_str("ASN_ASO", "asn_db_url") +def _expand_path(value: Any) -> str: + if value in {None, ""}: + return "" + return str(Path(str(value)).expanduser()) - # [general] - log_level_str = self._get_str("general", "log_level") - self.log_level = ( - log_level_str.upper() - if log_level_str - else DEFAULT_CONFIG["general"]["log_level"].upper() - ) - self.log_file_max_bytes = self._get_int("general", "log_file_max_bytes") - self.log_file_backup_count = self._get_int("general", "log_file_backup_count") - - raw_log_file_str = self._get_str("general", "log_file") - self.log_file = None if not raw_log_file_str else Path(raw_log_file_str) - - # [dns_cache] - self.dns_cache_enabled = self._get_bool("dns_cache", "enabled") - self.dns_cache_size = self._get_int("dns_cache", "size") - self.dns_cache_ttl_seconds = self._get_int("dns_cache", "ttl_seconds") - - # [sqlite_database] - self.sqlite_db_type = self._get_str("sqlite_database", "db_type") - self.sqlite_db_path = Path(self._get_path("sqlite_database", "db_path")) - self.sqlite_user = self._get_str("sqlite_database", "user") - self.sqlite_password_hash = self._get_str("sqlite_database", "password_hash") - self.sqlite_salt = self._get_str("sqlite_database", "salt") - - # [sql_export_systemd] - self.sql_export_frequency = self._get_str("sql_export_systemd", "frequency") - - # [sql_import_systemd] - self.sql_import_frequency = self._get_str("sql_import_systemd", "frequency") - - # [sql_export_settings] - # Assuming the path might be relative to a project root if not absolute. - # The setup script or deployment process needs to ensure this path is correct. - # For now, AppConfig will load it as a string. The consumer (sql_exporter) - # will need to resolve it if it's relative. - # A better approach: store an 'app_root' in AppConfig during main script init. - self.sql_column_mapping_file_path_str = self._get_str( - "sql_export_settings", "column_mapping_file" - ) - self.sql_target_table_name = self._get_str("sql_export_settings", "table_name") - def _get_default(self, section: str, option: str) -> Any: - """ - Retrieves the default value for a given configuration option. +def _validate_email(value: str) -> bool: + if not value: + return True + email_re = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + return bool(email_re.match(value)) - This helper function consults the `DEFAULT_CONFIG` dictionary to find the - predefined default value for a specific option within a section. - Args: - section: The name of the configuration section (e.g., "paths", "report"). - option: The name of the configuration option (e.g., "working_dir", "email"). +def _validate_log_level(value: str) -> bool: + return value.upper() in {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"} - Returns: - The default value for the specified option. Returns None if the option - is not found in `DEFAULT_CONFIG`, and logs an error. - """ - try: - return DEFAULT_CONFIG[section][option] - except KeyError: - self.logger.error( - f"Default value not found for [{section}]{option}. This is a programming error." - ) - # Potentially raise an error or return a very generic fallback - return None - def _get_str( - self, - section: str, - option: str, - fallback: Optional[ - str - ] = None, # Fallback here is for explicit override cases, not general defaults - ) -> Optional[str]: - """ - Safely gets a string value from the config parser, returning fallback - if not found or config not loaded. - - Args: - section: The configuration section name. - option: The configuration option name. - fallback: An optional explicit fallback value. If not provided, - the default from `DEFAULT_CONFIG` is used. - - Returns: - The configuration value as a string, or the fallback/default if - not found or if the configuration file was not loaded. - """ - current_fallback = ( - fallback if fallback is not None else self._get_default(section, option) - ) - if not self.config_loaded_successfully: - self.logger.debug( - f"Config not loaded. Using fallback '{current_fallback}' for " - f"[{section}]{option}." +def _ensure_absolute(path_str: str, *, allow_empty: bool = False) -> Path: + if not path_str and allow_empty: + return Path("") + path = Path(path_str) + if not path.is_absolute(): + raise ConfigurationError(f"Path must be absolute: {path_str}") + return path + + +@dataclass +class GeneralSettings: + log_level: str = "INFO" + log_file_max_mb: int = 64 + log_file_backup_count: int = 7 + + def validate(self) -> List[str]: + issues: List[str] = [] + if not _validate_log_level(self.log_level): + issues.append( + "Log level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL." ) - return current_fallback + if self.log_file_max_mb <= 0: + issues.append("Max log file size must be a positive integer.") + if self.log_file_backup_count < 0: + issues.append("Log file backup count cannot be negative.") + return issues + + +@dataclass +class PathSettings: + working_dir: str = "/var/log/maillogsentinel" + state_dir: str = "/var/lib/maillogsentinel" + mail_log: str = "/var/log/mail.log" + csv_filename: str = "maillogsentinel.csv" + + def validate(self) -> List[str]: + issues: List[str] = [] + try: + _ensure_absolute(self.working_dir) + _ensure_absolute(self.state_dir) + _ensure_absolute(self.mail_log) + except ConfigurationError as error: + issues.append(str(error)) + if not self.csv_filename: + issues.append("CSV filename must not be empty.") + return issues + + +@dataclass +class PermissionSettings: + service_user: str = "maillogsentinel" + + def validate(self) -> List[str]: + issues: List[str] = [] + if not self.service_user: + issues.append("Service user must not be empty.") + return issues + + +@dataclass +class ReportSettings: + recipient: str = "" + subject_prefix: str = "[MailLogSentinel]" + sender: str = "" + + def validate(self) -> List[str]: + issues: List[str] = [] + if self.recipient and not _validate_email(self.recipient): + issues.append("Report recipient must be a valid email address.") + if self.sender and not _validate_email(self.sender): + issues.append("Report sender must be a valid email address.") + if not self.subject_prefix: + issues.append("Report subject prefix must not be empty.") + return issues + + +@dataclass +class DNSCacheSettings: + enabled: bool = True + size: int = 256 + ttl: int = 3600 + + def validate(self) -> List[str]: + issues: List[str] = [] + if self.size <= 0: + issues.append("DNS cache size must be positive.") + if self.ttl <= 0: + issues.append("DNS cache TTL must be positive.") + return issues + + +@dataclass +class GeoSettings: + country_db_path: str = "/var/lib/maillogsentinel/country.mmdb" + asn_db_path: str = "/var/lib/maillogsentinel/asn.mmdb" + + def validate(self) -> List[str]: + issues: List[str] = [] + try: + _ensure_absolute(self.country_db_path, allow_empty=True) + except ConfigurationError as error: + issues.append(str(error)) + try: + _ensure_absolute(self.asn_db_path, allow_empty=True) + except ConfigurationError as error: + issues.append(str(error)) + return issues + +@dataclass +class DatabaseSettings: + path: str = "/var/lib/maillogsentinel/maillogsentinel.sqlite" + + def validate(self) -> List[str]: + issues: List[str] = [] try: - return self.parser.get(section, option, fallback=current_fallback) - except ( - configparser.NoSectionError, - configparser.NoOptionError, - ): - self.logger.debug( - f"Config option [{section}]{option} not found, using fallback: {current_fallback}" + _ensure_absolute(self.path) + except ConfigurationError as error: + issues.append(str(error)) + return issues + + +@dataclass +class TimerSettings: + log_extraction: str = "*:0/5" + report: str = "*-*-* 06:00:00" + ip_db_update: str = "Mon *-*-* 03:00:00" + sql_export: str = "*:0/30" + sql_import: str = "*:0/30" + + def validate(self) -> List[str]: + issues: List[str] = [] + for name, value in asdict(self).items(): + if not value: + issues.append(f"Systemd timer '{name}' must not be empty.") + return issues + + +@dataclass +class Configuration: + general: GeneralSettings = field(default_factory=GeneralSettings) + paths: PathSettings = field(default_factory=PathSettings) + permissions: PermissionSettings = field(default_factory=PermissionSettings) + report: ReportSettings = field(default_factory=ReportSettings) + dns_cache: DNSCacheSettings = field(default_factory=DNSCacheSettings) + geo: GeoSettings = field(default_factory=GeoSettings) + database: DatabaseSettings = field(default_factory=DatabaseSettings) + timers: TimerSettings = field(default_factory=TimerSettings) + schema_version: str = CONFIG_SCHEMA_VERSION + + def validate(self) -> List[str]: + issues: List[str] = [] + issues.extend(self.general.validate()) + issues.extend(self.paths.validate()) + issues.extend(self.permissions.validate()) + issues.extend(self.report.validate()) + issues.extend(self.dns_cache.validate()) + issues.extend(self.geo.validate()) + issues.extend(self.database.validate()) + issues.extend(self.timers.validate()) + if self.schema_version != CONFIG_SCHEMA_VERSION: + issues.append( + f"Configuration schema version {self.schema_version!r} is not supported." ) - return current_fallback - - def _get_path(self, section: str, option: str) -> str: # Removed fallback: str - """ - Safely gets a path string value from the config parser, ensuring a - string is returned. Uses default from DEFAULT_CONFIG. - - Args: - section: The configuration section name. - option: The configuration option name. - - Returns: - The configuration value as a string, suitable for use as a path. - Uses the default from `DEFAULT_CONFIG` if the option is not found. - """ - # _get_str will use the default from DEFAULT_CONFIG if not found - val = self._get_str(section, option) - # Ensure a string is returned, even if default is None (though paths shouldn't be None) - return val if val is not None else str(self._get_default(section, option)) - - def _get_int(self, section: str, option: str) -> int: # Removed fallback: int - """ - Safely gets an integer value from the config parser, returning default - on error or if not found. - - Args: - section: The configuration section name. - option: The configuration option name. - - Returns: - The configuration value as an integer. Returns the default value - from `DEFAULT_CONFIG` if the option is not found, if the config - file was not loaded, or if the value cannot be converted to an integer. - """ - default_val = self._get_default(section, option) - if not self.config_loaded_successfully: - self.logger.debug( - f"Config not loaded. Using fallback '{default_val}' for " - f"[{section}]{option}." - ) - return default_val # type: ignore + return issues + + def to_dict(self) -> Dict[str, Any]: + data = asdict(self) + return data + + def to_ini(self) -> configparser.ConfigParser: + parser = configparser.ConfigParser() + parser["meta"] = {"schema_version": self.schema_version} + parser["general"] = { + "log_level": self.general.log_level, + "log_file_max_mb": str(self.general.log_file_max_mb), + "log_file_backup_count": str(self.general.log_file_backup_count), + } + parser["paths"] = { + "working_dir": self.paths.working_dir, + "state_dir": self.paths.state_dir, + "mail_log": self.paths.mail_log, + "csv_filename": self.paths.csv_filename, + } + parser["permissions"] = {"service_user": self.permissions.service_user} + parser["report"] = { + "recipient": self.report.recipient, + "subject_prefix": self.report.subject_prefix, + "sender": self.report.sender, + } + parser["dns_cache"] = { + "enabled": json.dumps(self.dns_cache.enabled), + "size": str(self.dns_cache.size), + "ttl": str(self.dns_cache.ttl), + } + parser["geo"] = { + "country_db_path": self.geo.country_db_path, + "asn_db_path": self.geo.asn_db_path, + } + parser["database"] = {"path": self.database.path} + parser["timers"] = { + "log_extraction": self.timers.log_extraction, + "report": self.timers.report, + "ip_db_update": self.timers.ip_db_update, + "sql_export": self.timers.sql_export, + "sql_import": self.timers.sql_import, + } + return parser + + @classmethod + def from_ini( + cls, + parser: configparser.ConfigParser, + *, + base: Optional["Configuration"] = None, + ) -> "Configuration": + cfg = copy.deepcopy(base) if base else cls() + if parser.has_section("meta") and parser.has_option("meta", "schema_version"): + cfg.schema_version = parser.get("meta", "schema_version") + if parser.has_section("general"): + if parser.has_option("general", "log_level"): + cfg.general.log_level = parser.get("general", "log_level") + if parser.has_option("general", "log_file_max_mb"): + cfg.general.log_file_max_mb = parser.getint( + "general", "log_file_max_mb" + ) + if parser.has_option("general", "log_file_backup_count"): + cfg.general.log_file_backup_count = parser.getint( + "general", "log_file_backup_count" + ) + if parser.has_section("paths"): + if parser.has_option("paths", "working_dir"): + cfg.paths.working_dir = _expand_path(parser.get("paths", "working_dir")) + if parser.has_option("paths", "state_dir"): + cfg.paths.state_dir = _expand_path(parser.get("paths", "state_dir")) + if parser.has_option("paths", "mail_log"): + cfg.paths.mail_log = _expand_path(parser.get("paths", "mail_log")) + if parser.has_option("paths", "csv_filename"): + cfg.paths.csv_filename = parser.get("paths", "csv_filename") + if parser.has_section("permissions") and parser.has_option( + "permissions", "service_user" + ): + cfg.permissions.service_user = parser.get("permissions", "service_user") + if parser.has_section("report"): + if parser.has_option("report", "recipient"): + cfg.report.recipient = parser.get("report", "recipient") + if parser.has_option("report", "subject_prefix"): + cfg.report.subject_prefix = parser.get("report", "subject_prefix") + if parser.has_option("report", "sender"): + cfg.report.sender = parser.get("report", "sender") + if parser.has_section("dns_cache"): + if parser.has_option("dns_cache", "enabled"): + cfg.dns_cache.enabled = _to_bool(parser.get("dns_cache", "enabled")) + if parser.has_option("dns_cache", "size"): + cfg.dns_cache.size = parser.getint("dns_cache", "size") + if parser.has_option("dns_cache", "ttl"): + cfg.dns_cache.ttl = parser.getint("dns_cache", "ttl") + if parser.has_section("geo"): + if parser.has_option("geo", "country_db_path"): + cfg.geo.country_db_path = _expand_path( + parser.get("geo", "country_db_path") + ) + if parser.has_option("geo", "asn_db_path"): + cfg.geo.asn_db_path = _expand_path(parser.get("geo", "asn_db_path")) + if parser.has_section("database") and parser.has_option("database", "path"): + cfg.database.path = _expand_path(parser.get("database", "path")) + if parser.has_section("timers"): + if parser.has_option("timers", "log_extraction"): + cfg.timers.log_extraction = parser.get("timers", "log_extraction") + if parser.has_option("timers", "report"): + cfg.timers.report = parser.get("timers", "report") + if parser.has_option("timers", "ip_db_update"): + cfg.timers.ip_db_update = parser.get("timers", "ip_db_update") + if parser.has_option("timers", "sql_export"): + cfg.timers.sql_export = parser.get("timers", "sql_export") + if parser.has_option("timers", "sql_import"): + cfg.timers.sql_import = parser.get("timers", "sql_import") + return cfg + + +def _env_to_dict(env: Mapping[str, str]) -> Dict[str, Dict[str, str]]: + result: Dict[str, Dict[str, str]] = {} + prefix = "MAILLOGSENTINEL__" + for key, value in env.items(): + if not key.startswith(prefix): + continue + remainder = key[len(prefix) :] try: - return self.parser.getint(section, option, fallback=default_val) - except ( - configparser.NoSectionError, - configparser.NoOptionError, - ): # Should be caught by fallback - self.logger.debug( - f"Config option [{section}]{option} not found, using fallback: {default_val}" - ) - return default_val # type: ignore + section, option = remainder.split("__", 1) except ValueError: - self.logger.warning( - f"Invalid integer value for [{section}]{option}. Using fallback {default_val}." - ) - return default_val # type: ignore - - def _get_bool(self, section: str, option: str) -> bool: # Removed fallback: bool - """ - Safely gets a boolean value from the config parser, returning default - on error or if not found. - - Args: - section: The configuration section name. - option: The configuration option name. - - Returns: - The configuration value as a boolean. Returns the default value - from `DEFAULT_CONFIG` if the option is not found, if the config - file was not loaded, or if the value is not a valid boolean string. - """ - default_val = self._get_default(section, option) - if not self.config_loaded_successfully: - self.logger.debug( - f"Config not loaded. Using fallback '{default_val}' for " - f"[{section}]{option}." - ) - return default_val # type: ignore - try: - # Ensure that the default_val is passed correctly to getboolean - return self.parser.getboolean(section, option, fallback=default_val) - except ( - configparser.NoSectionError, - configparser.NoOptionError, - ): # Should be caught by fallback - self.logger.debug( - f"Config option [{section}]{option} not found, using fallback: {default_val}" - ) - return default_val # type: ignore - except ( - ValueError - ): # Catches if the value in config file is not a valid boolean string - self.logger.warning( - f"Invalid boolean value for [{section}]{option} in config file. Using fallback {default_val}." - ) - return default_val # type: ignore - - def get_section_dict(self, section: str) -> Dict[str, str]: - """ - Returns a dictionary of a whole section, or empty if section not - found or config not loaded. - - Args: - section: The name of the configuration section to retrieve. - - Returns: - A dictionary where keys are option names and values are their - corresponding string values from the specified section. Returns - an empty dictionary if the section is not found or if the - configuration file was not loaded. - """ - if not self.config_loaded_successfully or not self.parser.has_section(section): - return {} - return dict(self.parser.items(section)) - - def exit_if_not_loaded(self, message="Configuration could not be loaded. Exiting."): - """Helper method to exit if the configuration wasn't loaded successfully.""" - if not self.config_loaded_successfully: - self.logger.critical( - message + f" (Config path attempted: {self.config_path})" - ) - # Keep print for cases where logger itself might not be fully set up to console - print( - f"CRITICAL: {message} (Config path attempted: {self.config_path})", - file=sys.stderr, - ) - sys.exit(1) + continue + section = section.lower() + option = option.lower() + section_dict = result.setdefault(section, {}) + section_dict[option] = value + return result + + +class ConfigurationMerger: + """Merges configuration sources with defined precedence.""" + + def __init__( + self, + *, + config_path: Path, + environment: Optional[Mapping[str, str]] = None, + overrides: Optional[Mapping[str, Any]] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self.config_path = config_path + self.environment = environment or os.environ + self.overrides = overrides or {} + self.logger = logger or logging.getLogger(__name__) + + def load(self) -> Configuration: + config = Configuration() + if self.config_path.exists(): + file_parser = configparser.ConfigParser() + try: + file_parser.read(self.config_path) + config = Configuration.from_ini(file_parser, base=config) + except configparser.Error as error: + raise ConfigurationError( + f"Failed to parse configuration file: {error}" + ) from error + + env_data = _env_to_dict(self.environment) + if env_data: + env_parser = configparser.ConfigParser() + env_parser.read_dict(env_data) + config = Configuration.from_ini(env_parser, base=config) + + if self.overrides: + cli_parser = configparser.ConfigParser() + cli_parser.read_dict(self._normalize_overrides(self.overrides)) + config = Configuration.from_ini(cli_parser, base=config) + + issues = config.validate() + if issues: + raise ConfigurationError("; ".join(issues)) + return config + + def _normalize_overrides( + self, overrides: Mapping[str, Any] + ) -> Dict[str, Dict[str, str]]: + normalized: Dict[str, Dict[str, str]] = {} + for key, value in overrides.items(): + if "__" in key: + section, option = key.split("__", 1) + elif "." in key: + section, option = key.split(".", 1) + else: + raise ConfigurationError( + "Overrides must use 'section__option' or 'section.option' format." + ) + section_dict = normalized.setdefault(section.lower(), {}) + section_dict[option.lower()] = str(value) + return normalized + + +def safe_write(path: Path, data: str) -> None: + """Atomically write ``data`` to ``path`` with a deterministic backup.""" + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + backup_path = path.with_suffix(path.suffix + ".bak") + if path.exists(): + shutil.copy2(path, backup_path) + + tmp_fd, tmp_path_str = tempfile.mkstemp( + dir=str(path.parent), prefix=".tmp", suffix=path.suffix + ) + tmp_path = Path(tmp_path_str) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + tmp_path.replace(path) + finally: + if tmp_path.exists(): + tmp_path.unlink(missing_ok=True) + + +def generate_diff(original: Optional[str], new_content: str, *, path: Path) -> str: + original_lines = original.splitlines(keepends=True) if original else [] + new_lines = new_content.splitlines(keepends=True) + diff = difflib.unified_diff( + original_lines, + new_lines, + fromfile=str(path), + tofile=str(path), + lineterm="", + ) + return "\n".join(diff) + + +class ConfigurationWriter: + """Persist a configuration instance to disk atomically.""" + + def __init__(self, path: Path, logger: Optional[logging.Logger] = None) -> None: + self.path = path + self.logger = logger or logging.getLogger(__name__) + + def write( + self, config: Configuration, *, dry_run: bool = False + ) -> Tuple[str, Optional[str]]: + parser = config.to_ini() + buffer = [] + with tempfile.TemporaryFile("w+", encoding="utf-8") as temp: + parser.write(temp) + temp.seek(0) + buffer = temp.read().splitlines() + content = "\n".join(buffer) + "\n" + previous = None + if self.path.exists(): + previous = self.path.read_text(encoding="utf-8") + diff = generate_diff(previous, content, path=self.path) + if dry_run: + return content, diff + safe_write(self.path, content) + self.logger.info("Configuration written to %s", self.path) + return content, diff + + +def summarize_configuration(config: Configuration) -> List[Tuple[str, str]]: + """Return a flat summary of configuration values for review displays.""" + + summary = [ + ("Schema version", config.schema_version), + ("Log level", config.general.log_level), + ("Log file max (MB)", str(config.general.log_file_max_mb)), + ("Log file backups", str(config.general.log_file_backup_count)), + ("Working directory", config.paths.working_dir), + ("State directory", config.paths.state_dir), + ("Mail log", config.paths.mail_log), + ("CSV filename", config.paths.csv_filename), + ("Service user", config.permissions.service_user), + ("Report recipient", config.report.recipient or "(disabled)"), + ("Report subject prefix", config.report.subject_prefix), + ("Report sender", config.report.sender or "(default)"), + ("DNS cache enabled", "yes" if config.dns_cache.enabled else "no"), + ("DNS cache size", str(config.dns_cache.size)), + ("DNS cache TTL", str(config.dns_cache.ttl)), + ("Country DB", config.geo.country_db_path or "(default)"), + ("ASN DB", config.geo.asn_db_path or "(default)"), + ("SQLite DB", config.database.path), + ("Timer: log extraction", config.timers.log_extraction), + ("Timer: report", config.timers.report), + ("Timer: IP DB update", config.timers.ip_db_update), + ("Timer: SQL export", config.timers.sql_export), + ("Timer: SQL import", config.timers.sql_import), + ] + return summary + + +class AppConfig: + """Compatibility wrapper exposing the legacy attribute interface.""" + + def __init__(self, config_path: Path, logger: Optional[logging.Logger] = None): + self._logger = logger or logging.getLogger(__name__) + self.config_path = Path(config_path) + merger = ConfigurationMerger(config_path=self.config_path, logger=self._logger) + self._config = merger.load() + self._materialize() + + def _materialize(self) -> None: + cfg = self._config + self.working_dir = Path(cfg.paths.working_dir) + self.state_dir = Path(cfg.paths.state_dir) + self.mail_log = Path(cfg.paths.mail_log) + self.csv_filename = cfg.paths.csv_filename + self.report_email = cfg.report.recipient or None + self.report_subject_prefix = cfg.report.subject_prefix + self.report_sender_override = cfg.report.sender or None + self.country_db_path = ( + Path(cfg.geo.country_db_path) if cfg.geo.country_db_path else None + ) + self.asn_db_path = Path(cfg.geo.asn_db_path) if cfg.geo.asn_db_path else None + self.log_level = cfg.general.log_level + self.log_file_max_bytes = cfg.general.log_file_max_mb * 1_048_576 + self.log_file_backup_count = cfg.general.log_file_backup_count + self.log_file = Path(cfg.paths.working_dir) / "maillogsentinel.log" + self.dns_cache_enabled = cfg.dns_cache.enabled + self.dns_cache_size = cfg.dns_cache.size + self.dns_cache_ttl_seconds = cfg.dns_cache.ttl + self.sqlite_db_path = Path(cfg.database.path) + self.sql_export_frequency = cfg.timers.sql_export + self.sql_import_frequency = cfg.timers.sql_import + + @property + def config_loaded_successfully(self) -> bool: + return True diff --git a/lib/maillogsentinel/output.py b/lib/maillogsentinel/output.py new file mode 100644 index 0000000..b04ba68 --- /dev/null +++ b/lib/maillogsentinel/output.py @@ -0,0 +1,111 @@ +"""Console output helpers for MailLogSentinel CLI tools.""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Iterable, Iterator, Optional + + +ANSI_CODES = { + "reset": "\033[0m", + "bold": "\033[1m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "gray": "\033[90m", +} + + +@dataclass +class OutputOptions: + color: bool = True + stream: any = sys.stdout + + +def detect_color_support(stream: any = sys.stdout) -> bool: + if not hasattr(stream, "isatty"): + return False + try: + return stream.isatty() + except Exception: + return False + + +def apply_color(text: str, color: str, *, options: OutputOptions) -> str: + if not options.color: + return text + code = ANSI_CODES.get(color) + if not code: + return text + return f"{code}{text}{ANSI_CODES['reset']}" + + +def heading(text: str, *, options: OutputOptions, level: int = 1) -> None: + prefix = "#" * level + styled = apply_color(f"{prefix} {text}", "cyan", options=options) + print(styled, file=options.stream) + + +def info(text: str, *, options: OutputOptions) -> None: + print(apply_color(text, "gray", options=options), file=options.stream) + + +def success(text: str, *, options: OutputOptions) -> None: + print(apply_color(text, "green", options=options), file=options.stream) + + +def warning(text: str, *, options: OutputOptions) -> None: + print(apply_color(text, "yellow", options=options), file=options.stream) + + +def error(text: str, *, options: OutputOptions) -> None: + print(apply_color(text, "red", options=options), file=options.stream) + + +def divider(*, options: OutputOptions) -> None: + print(apply_color("=" * 72, "gray", options=options), file=options.stream) + + +def list_block(items: Iterable[str], *, options: OutputOptions) -> None: + for item in items: + print(f" - {item}", file=options.stream) + + +@contextmanager +def status(text: str, *, options: OutputOptions) -> Iterator[None]: + info(f"→ {text}...", options=options) + try: + yield + except Exception: + error(f"✖ {text}", options=options) + raise + else: + success(f"✔ {text}", options=options) + + +def prompt(text: str, *, options: OutputOptions, default: Optional[str] = None) -> str: + suffix = f" [{default}]" if default else "" + options.stream.write(apply_color(f"{text}{suffix}: ", "bold", options=options)) + options.stream.flush() + user_input = sys.stdin.readline().strip() + if not user_input and default is not None: + return default + return user_input + + +def confirm(text: str, *, options: OutputOptions, default: bool = True) -> bool: + default_str = "Y/n" if default else "y/N" + while True: + answer = prompt( + f"{text} ({default_str})", options=options, default="y" if default else "n" + ) + if answer.lower() in {"y", "yes"}: + return True + if answer.lower() in {"n", "no"}: + return False + warning("Please answer 'y' or 'n'.", options=options) diff --git a/tests/bin/test_maillogsentinel_setup.py b/tests/bin/test_maillogsentinel_setup.py index 7dc467f..928ced1 100644 --- a/tests/bin/test_maillogsentinel_setup.py +++ b/tests/bin/test_maillogsentinel_setup.py @@ -1,2027 +1,108 @@ -import unittest -from unittest.mock import patch, MagicMock -from pathlib import Path -import configparser -import subprocess # Added for CalledProcessError -import tempfile -import os -import io # For mock_log_fh spec -import re - - -# Adjust the import path based on how tests are run. -# If tests are run from the root of the project, this should work. -import bin.maillogsentinel_setup as mls_setup - - -class TestStopExecution(Exception): - """Custom exception to halt execution flow after a mocked sys.exit.""" - - pass - - -# Basic valid config for tests that need to pass initial parsing -VALID_CONFIG_CONTENT = """ -[paths] -working_dir = /var/log/maillogsentinel -state_dir = /var/lib/maillogsentinel -mail_log = /var/log/mail.log -csv_filename = maillogsentinel.csv - -[report] -email = security-team@example.org -subject_prefix = [MailLogSentinel] -sender_override = mls@example.org - -[geolocation] -country_db_path = /var/lib/maillogsentinel/country_aside.csv -country_db_url = https://example.com/country.csv - -[ASN_ASO] -asn_db_path = /var/lib/maillogsentinel/asn.csv -asn_db_url = https://example.com/asn.csv - -[general] -log_level = INFO -log_file_max_bytes = 1000000 -log_file_backup_count = 5 - -[dns_cache] -enabled = True -size = 128 -ttl_seconds = 3600 - -[User] -run_as_user = testuser - -[systemd] -extraction_schedule = hourly -report_schedule = daily -ip_update_schedule = weekly -""" - - -class TestNonInteractiveSetupConfig(unittest.TestCase): - - def setUp(self): - self.mock_log_fh = MagicMock(spec=io.StringIO) - self.mock_log_fh.closed = False - mls_setup.backed_up_items = [] - mls_setup.created_final_paths = [] - - def test_non_interactive_setup_valid_config_parsing(self): - """Test that a valid config is read and initial checks pass for a full successful run.""" - source_config_path_str = None - try: - with tempfile.NamedTemporaryFile( - mode="w", encoding="utf-8", delete=False, suffix=".ini" - ) as tmp_src_config_file: - tmp_src_config_file.write(VALID_CONFIG_CONTENT) - source_config_path_str = tmp_src_config_file.name - - with tempfile.TemporaryDirectory() as tmp_target_root_dir: - mock_default_target_config_path = ( - Path(tmp_target_root_dir) / "maillogsentinel.conf" - ) - - config_for_paths = configparser.ConfigParser() - config_for_paths.read_string(VALID_CONFIG_CONTENT) - expected_workdir = Path(config_for_paths.get("paths", "working_dir")) - expected_statedir = Path(config_for_paths.get("paths", "state_dir")) - - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - mock_default_target_config_path, - ), patch("bin.maillogsentinel_setup.os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ) as mock_shutil_copy, patch( - "bin.maillogsentinel_setup._change_ownership" - ) as mock_change_ownership, patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup.shutil.which" - ) as mock_shutil_which, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "pathlib.Path.mkdir" - ) as mock_path_mkdir, patch( - "pathlib.Path.write_text" - ) as mock_path_write_text, patch( - "tempfile.TemporaryDirectory" - ) as mock_tempfile_constructor, patch( - "pathlib.Path.exists" - ) as mock_path_exists: - - mock_shutil_copy.return_value = None - mock_change_ownership.return_value = True - mock_subprocess_run.return_value = MagicMock( - returncode=0, stdout="", stderr="" - ) - mock_path_mkdir.return_value = None - mock_path_write_text.return_value = None - - def which_side_effect(cmd): - if cmd == "usermod": - return "/usr/sbin/usermod" - if cmd == "systemctl": - return "/usr/bin/systemctl" - if cmd == "python3": - return "/usr/bin/python3" - script_dir_for_test = Path(mls_setup.__file__).resolve().parent - if cmd == "maillogsentinel.py": - return str(script_dir_for_test / "maillogsentinel.py") - if cmd == "ipinfo.py": - return str(script_dir_for_test / "ipinfo.py") - return f"/usr/bin/{cmd}" - - mock_shutil_which.side_effect = which_side_effect - - mock_td_instance = MagicMock() - mock_td_instance.name = str( - Path(tmp_target_root_dir) / "temp_units" - ) - mock_tempfile_constructor.return_value = mock_td_instance - - def path_exists_logic(*args_passed): - if not args_passed: - # This print is for debugging specific test scenarios - # print(f"Warning: path_exists_logic in {self._testMethodName} called with no args!") - return False - path_arg = args_passed[0] - - if path_arg == Path(source_config_path_str): - return True - if path_arg == mock_default_target_config_path: - return False - if ( - path_arg == expected_workdir - or path_arg == expected_statedir - ): - return False - if path_arg.parent == Path("/etc/systemd/system"): - return False - if str(path_arg) in [ - "/etc", - "/etc/systemd", - "/var/log", - "/var/lib", - ]: - return True - script_dir_for_test = Path(mls_setup.__file__).resolve().parent - if ( - path_arg == script_dir_for_test / "maillogsentinel.py" - or path_arg == script_dir_for_test / "ipinfo.py" - ): - return True - return False - - mock_path_exists.side_effect = path_exists_logic - - try: - mls_setup.non_interactive_setup( - Path(source_config_path_str), self.mock_log_fh - ) - except Exception as e_exec: - self.fail( - f"non_interactive_setup failed unexpectedly: {e_exec}\nLogs: {mock_setup_print.call_args_list}" - ) - - finally: - if source_config_path_str and Path(source_config_path_str).exists(): - os.remove(source_config_path_str) - - mock_sys_exit.assert_not_called() - - def test_non_interactive_setup_source_config_not_found(self): - """Test behavior when source config file does not exist.""" - with patch("bin.maillogsentinel_setup.os.geteuid", return_value=0), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "pathlib.Path.is_file", return_value=False - ): - mock_sys_exit.side_effect = TestStopExecution - try: - mls_setup.non_interactive_setup( - Path("non_existent_config.ini"), self.mock_log_fh - ) - except TestStopExecution: - pass - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "Source configuration file 'non_existent_config.ini' not found" - in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - def test_non_interactive_setup_not_root_user(self): - """Test behavior when script is not run as root.""" - with patch("bin.maillogsentinel_setup.os.geteuid", return_value=1000), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "pathlib.Path.is_file", return_value=True - ): - mock_sys_exit.side_effect = TestStopExecution - try: - mls_setup.non_interactive_setup( - Path("dummy_config.ini"), self.mock_log_fh - ) - except TestStopExecution: - pass - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "requires root privileges" in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - def test_non_interactive_setup_missing_section(self): - """Test config validation for a missing required section.""" - config_content_missing_user = VALID_CONFIG_CONTENT.replace( - "[User]", "[OldUser]" - ) - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(config_content_missing_user) - config_path = tmp_config_file.name - - try: - with patch("os.geteuid", return_value=0), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - mock_sys_exit.side_effect = TestStopExecution - try: - mls_setup.non_interactive_setup(Path(config_path), self.mock_log_fh) - except TestStopExecution: - pass - finally: - os.remove(config_path) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "Missing section '[User]'" in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - def test_non_interactive_setup_missing_key(self): - """Test config validation for a missing required key.""" - config_content_missing_key = VALID_CONFIG_CONTENT.replace( - "run_as_user = testuser", "#run_as_user = testuser" - ) # noqa E501 - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(config_content_missing_key) - config_path = tmp_config_file.name - - try: - with patch("os.geteuid", return_value=0), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - mock_sys_exit.side_effect = TestStopExecution - try: - mls_setup.non_interactive_setup(Path(config_path), self.mock_log_fh) - except TestStopExecution: - pass - finally: - os.remove(config_path) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "ERROR: Missing or empty value for 'run_as_user' in section '[User]'." - in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - def test_non_interactive_setup_user_is_root(self): - """Test config validation when run_as_user is 'root'.""" - config_content_root_user = VALID_CONFIG_CONTENT.replace( - "run_as_user = testuser", "run_as_user = root" - ) - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(config_content_root_user) - config_path = tmp_config_file.name - - with tempfile.TemporaryDirectory() as tmpdir: - mock_target_config = Path(tmpdir) / "test_maillogsentinel.conf" - - with patch("bin.maillogsentinel_setup.os.geteuid", return_value=0), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - mock_target_config, - ), patch( - "shutil.copy2" - ) as mock_shutil_copy2, patch( - "pathlib.Path.exists", return_value=False - ), patch( - "pathlib.Path.mkdir" - ): # noqa: F841 - - mock_sys_exit.side_effect = TestStopExecution - mock_shutil_copy2.return_value = None - - try: - mls_setup.non_interactive_setup(Path(config_path), self.mock_log_fh) - except TestStopExecution: - pass - except Exception as e: - self.fail( - f"non_interactive_setup raised an unexpected error: {e}\nLog calls: {mock_setup_print.call_args_list}" - ) # noqa E501 - - if Path(config_path).exists(): - os.remove(config_path) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "Configuration specifies 'root' as 'run_as_user'" in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - # Path Management Tests - def test_path_management_creation(self): - """Test creation of workdir and statedir when they don't exist.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.exists", return_value=False - ), patch( - "pathlib.Path.mkdir" - ) as mock_mkdir, patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ), patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - expected_workdir = Path(config.get("paths", "working_dir")) - expected_statedir = Path(config.get("paths", "state_dir")) - - try: - original_backed_up_items = mls_setup.backed_up_items - mls_setup.backed_up_items = [] - mls_setup.created_final_paths = [] - - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - - mock_mkdir.assert_any_call(parents=True, exist_ok=True) - self.assertGreaterEqual(mock_mkdir.call_count, 3) - self.assertIn(str(expected_workdir), mls_setup.created_final_paths) - self.assertIn(str(expected_statedir), mls_setup.created_final_paths) - self.assertEqual(len(mls_setup.backed_up_items), 0) - mock_sys_exit.assert_not_called() - finally: - os.remove(config_path_str) - mls_setup.backed_up_items = original_backed_up_items - - def test_path_management_backup_existing(self): - """Test backup of workdir and statedir when they already exist.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.exists", return_value=True - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ) as mock_shutil_move, patch( - "pathlib.Path.mkdir" - ) as mock_mkdir, patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ), patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - expected_workdir = Path(config.get("paths", "working_dir")) - expected_statedir = Path(config.get("paths", "state_dir")) - - try: - original_backed_up_items = mls_setup.backed_up_items - mls_setup.backed_up_items = [] - mls_setup.created_final_paths = [] - - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - - self.assertGreaterEqual(mock_shutil_move.call_count, 3) - found_workdir_backup = any( - item[1] == str(expected_workdir) - for item in mls_setup.backed_up_items - ) - found_statedir_backup = any( - item[1] == str(expected_statedir) - for item in mls_setup.backed_up_items - ) - self.assertTrue(found_workdir_backup, "Workdir backup not recorded") - self.assertTrue(found_statedir_backup, "Statedir backup not recorded") - mock_mkdir.assert_any_call(parents=True, exist_ok=True) - mock_sys_exit.assert_not_called() - finally: - os.remove(config_path_str) - mls_setup.backed_up_items = original_backed_up_items - - # User/Group Management Tests - def test_user_verification_non_existent(self): - """Test behavior when run_as_user in config does not exist.""" - config_unknown_user = VALID_CONFIG_CONTENT.replace( - "run_as_user = testuser", "run_as_user = unknownuser" - ) - - temp_file_name_wrapper = { - "name": None - } # To share tempfile name with side_effects - - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", - side_effect=KeyError("User 'unknownuser' not found"), - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "bin.maillogsentinel_setup.shutil.copy2" - ) as mock_shutil_copy2, patch( - "bin.maillogsentinel_setup.shutil.move" - ) as mock_shutil_move, patch( - "pathlib.Path.mkdir" - ) as mock_path_mkdir: # General mkdir mock for all parent creations - - mock_sys_exit.side_effect = TestStopExecution - mock_shutil_copy2.return_value = None # Ensure copy doesn't fail - mock_shutil_move.return_value = ( - None # Ensure move doesn't fail (for backups) - ) - mock_path_mkdir.return_value = None # Ensure mkdir doesn't fail - - # Side effect for Path.exists - def path_exists_side_effect( - self_path_obj, - ): # autospec=True passes instance as first arg - # print(f"DEBUG: path_exists_side_effect called with: {self_path_obj}") - # Source config file must "exist" for configparser.read within SUT - if temp_file_name_wrapper["name"] and self_path_obj == Path( - temp_file_name_wrapper["name"] - ): - return True - # For other paths (target config, work dir, state dir), return False to simplify test logic - # and avoid backup attempts etc. - return False - - # Side effect for Path.is_file - def path_is_file_side_effect( - self_path_obj, - ): # autospec=True passes instance as first arg - # print(f"DEBUG: path_is_file_side_effect called with: {self_path_obj}") - # Only the source config path should be a file for SUT's initial check. - if temp_file_name_wrapper["name"] and self_path_obj == Path( - temp_file_name_wrapper["name"] - ): - return True - return False - - with patch( - "pathlib.Path.exists", - side_effect=path_exists_side_effect, - autospec=True, - ), patch( - "pathlib.Path.is_file", - side_effect=path_is_file_side_effect, - autospec=True, - ): - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(config_unknown_user) - temp_file_name_wrapper["name"] = ( - tmp_config_file.name - ) # Allow side effects to see the name - - config_path_for_sut = Path(temp_file_name_wrapper["name"]) - - try: - try: - mls_setup.non_interactive_setup( - config_path_for_sut, self.mock_log_fh - ) - except TestStopExecution: - pass - finally: - if ( - temp_file_name_wrapper["name"] - and Path(temp_file_name_wrapper["name"]).exists() - ): - os.remove(temp_file_name_wrapper["name"]) - - mock_sys_exit.assert_called_once_with(1) - - print( - "\nDEBUG: mock_setup_print calls for test_user_verification_non_existent:" - ) - for i, call_obj in enumerate(mock_setup_print.call_args_list): - print(f" Call {i}: {call_obj}") - print("--- END DEBUG ---\n") - - expected_message = "ERROR: User 'unknownuser' not found." - found_the_log = False - for call_item in mock_setup_print.call_args_list: - if call_item.args and isinstance(call_item.args[0], str): - if expected_message == call_item.args[0]: - found_the_log = True - break - self.assertTrue( - found_the_log, f"Expected log message '{expected_message}' not found." - ) - - def test_add_user_to_group_success(self): - """Test successful addition of user to 'adm' group.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup.shutil.which" - ) as mock_shutil_which, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ), patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "pathlib.Path.mkdir" - ), patch( - "pathlib.Path.exists" - ), patch( - "bin.maillogsentinel_setup._change_ownership", side_effect=TestStopExecution - ) as mock_change_ownership_stopper: - - mock_shutil_which.return_value = "/usr/sbin/usermod" - mock_subprocess_run.return_value = MagicMock( - returncode=0, stdout="usermod success stdout", stderr="" - ) - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - try: - # The mock_change_ownership_stopper from the outer with-context is now the one that will be called - try: - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - except TestStopExecution: - pass - mock_change_ownership_stopper.assert_called_once() - finally: - os.remove(config_path_str) - - usermod_called_correctly = False - for call_args_tuple in mock_subprocess_run.call_args_list: - cmd_list = call_args_tuple.args[0] - if ( - cmd_list[0] == "/usr/sbin/usermod" - and cmd_list[1:3] == ["-aG", "adm"] - and cmd_list[3] == "testuser" - ): - usermod_called_correctly = True - break - self.assertTrue( - usermod_called_correctly, - "usermod -aG adm testuser was not called correctly", - ) - - mock_sys_exit.assert_not_called() - - def test_add_user_to_group_failure(self): - """Test failure of adding user to 'adm' group via usermod.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup.shutil.which", return_value="/usr/sbin/usermod" - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - usermod_cmd = ["/usr/sbin/usermod", "-aG", "adm", "testuser"] - mock_subprocess_run.side_effect = subprocess.CalledProcessError( - returncode=1, cmd=usermod_cmd, stderr="Mock usermod error" - ) - mock_sys_exit.side_effect = TestStopExecution - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - try: - config_parser_instance = configparser.ConfigParser() - config_parser_instance.read(config_path_str) - - with patch("configparser.ConfigParser") as mock_cp_constructor: - mock_cp_constructor.return_value = config_parser_instance - with patch("bin.maillogsentinel_setup._change_ownership"), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch("bin.maillogsentinel_setup.shutil.move"), patch( - "pathlib.Path.mkdir" - ): - try: - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - except TestStopExecution: - pass - finally: - os.remove(config_path_str) - - mock_sys_exit.assert_called_once_with(1) - expected_log_fragment = "ERROR adding user to group: Command '['/usr/sbin/usermod', '-aG', 'adm', 'testuser']' returned non-zero exit status 1." # noqa: E501 - self.assertTrue( - any( - expected_log_fragment in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - def test_usermod_not_found_via_which(self): - """Test behavior when 'usermod' command is not found by shutil.which.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: None if cmd == "usermod" else f"/usr/bin/{cmd}", - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - mock_sys_exit.side_effect = TestStopExecution - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - try: - config_parser_instance = configparser.ConfigParser() - config_parser_instance.read(config_path_str) - with patch("configparser.ConfigParser") as mock_cp_constructor: - mock_cp_constructor.return_value = config_parser_instance - with patch("bin.maillogsentinel_setup._change_ownership"), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch("bin.maillogsentinel_setup.shutil.move"), patch( - "pathlib.Path.mkdir" - ): - try: - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - except TestStopExecution: - pass - finally: - os.remove(config_path_str) - - print("mock_setup_print calls for test_usermod_not_found_via_which:") - for i, call_obj in enumerate( - mock_setup_print.call_args_list - ): # Added index for clarity - print(f"Call {i}: {call_obj}") - - mock_sys_exit.assert_called_once_with(1) - - found_the_log = False - expected_message = "ERROR: 'usermod' not found." - print(f"Searching for: '{expected_message}'") - for call_item in mock_setup_print.call_args_list: - # Ensure call_item.args is not empty and call_item.args[0] is a string - if call_item.args and isinstance(call_item.args[0], str): - logged_message = call_item.args[0] - print( - f" Checking logged: '{logged_message}' (type: {type(logged_message)})" - ) - if ( - expected_message == logged_message - ): # Changed from 'in' to '==' for exact match - found_the_log = True - print(f" Exact match FOUND: '{expected_message}'") - break - else: - print(f" No exact match for: '{expected_message}'") - else: - print( - f" Skipping call_item due to unexpected args: {call_item.args}" - ) - - self.assertTrue( - found_the_log, - f"Expected log message '{expected_message}' not found via exact match.", - ) - - def test_usermod_not_found_at_execution(self): - """Test behavior when 'usermod' is found by which but raises FileNotFoundError on run.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.subprocess.run", - side_effect=FileNotFoundError("usermod gone missing"), - ), patch( - "bin.maillogsentinel_setup.shutil.which", return_value="/usr/sbin/usermod" - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - mock_sys_exit.side_effect = TestStopExecution # Added - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - try: - config_parser_instance = configparser.ConfigParser() - config_parser_instance.read(config_path_str) - with patch("configparser.ConfigParser") as mock_cp_constructor: - mock_cp_constructor.return_value = config_parser_instance - with patch("bin.maillogsentinel_setup._change_ownership"), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch("bin.maillogsentinel_setup.shutil.move"), patch( - "pathlib.Path.mkdir" - ): - try: - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - except TestStopExecution: - pass - finally: - os.remove(config_path_str) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "ERROR adding user to group: usermod gone missing" in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - - # Systemd Setup Tests - def test_systemd_file_creation(self): - """Test creation of systemd unit files.""" - with patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: f"/usr/bin/{cmd}", - ), patch("pathlib.Path.write_text", autospec=True) as mock_write_text, patch( - "tempfile.TemporaryDirectory" - ) as mock_tempfile_dir, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch( - "os.geteuid", return_value=0 - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ) as mock_shutil_move, patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ), patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "pathlib.Path.exists" - ) as mock_general_path_exists: +"""CLI tests for the refactored MailLogSentinel setup utility.""" - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str_val = tmp_config_file.name +from __future__ import annotations - def path_exists_side_effect(*args_passed): - if not args_passed: - return False - path_obj = args_passed[0] - - if path_obj == Path(config_path_str_val): - return True - # The following check for SCRIPT_DIR was problematic and is commented out - # as SCRIPT_DIR is not a global in mls_setup module. - # This side effect might need more specific paths if scripts are checked via Path.exists. - # if path_obj.name == 'ipinfo.py' and path_obj.parent == Path(mls_setup.SCRIPT_DIR): - # return True - if path_obj.parent == Path("/etc/systemd/system"): - return False - return False - - mock_general_path_exists.side_effect = path_exists_side_effect - - mock_created_temp_dir = MagicMock() - mock_created_temp_dir.name = "/mock_temp_units" - mock_tempfile_dir.return_value = mock_created_temp_dir - - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - - try: - mls_setup.created_final_paths = [] - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str_val] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str_val), self.mock_log_fh - ) - finally: - # Ensure temp config file is removed - if Path( - config_path_str_val - ).exists(): # Check existence before removing - os.remove(config_path_str_val) - # DO NOT reset mls_setup.created_final_paths here; setUp handles initialization for each test. - # The list populated by the SUT call should be asserted below. - - self.assertEqual(mock_write_text.call_count, 10) - unit_filenames = [ - "maillogsentinel.service", - "maillogsentinel-extract.timer", - "maillogsentinel-report.service", - "maillogsentinel-report.timer", - "ipinfo-update.service", - "ipinfo-update.timer", - "maillogsentinel-sql-export.service", - "maillogsentinel-sql-export.timer", - "maillogsentinel-sql-import.service", - "maillogsentinel-sql-import.timer", - ] - for unit_filename in unit_filenames: - # With autospec=True on Path.write_text, call.args[0] is the Path instance, call.args[1] is the content. - self.assertTrue( - any( - call.args[0].name == unit_filename - for call in mock_write_text.call_args_list - ), - f"{unit_filename} not written", - ) - self.assertTrue( - any( - Path(call.args[0]).name == unit_filename - and Path(call.args[0]).parent - == Path(mock_created_temp_dir.name) - and Path(call.args[1]).parent == Path("/etc/systemd/system") - for call in mock_shutil_move.call_args_list - ), - f"{unit_filename} not moved to systemd from temp dir", - ) # noqa: E501 - self.assertIn( - str(Path("/etc/systemd/system") / unit_filename), - mls_setup.created_final_paths, - ) - mock_sys_exit.assert_not_called() - - def test_ownership_changes(self): - """Test that _change_ownership is called for relevant paths.""" - with patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ) as mock_default_config_path, patch("os.geteuid", return_value=0), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ) as mock_change_ownership, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ), patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: # noqa: F841 - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - expected_user = config.get("User", "run_as_user") - expected_workdir = Path(config.get("paths", "working_dir")) - expected_statedir = Path(config.get("paths", "state_dir")) - - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config), patch( - "pathlib.Path.exists", return_value=False - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ), patch( - "pathlib.Path.write_text" - ), patch( - "tempfile.TemporaryDirectory" - ): - mls_setup.non_interactive_setup(Path(config_path_str), self.mock_log_fh) - - try: - mock_change_ownership.assert_any_call( - str(mock_default_config_path), expected_user, self.mock_log_fh - ) - mock_change_ownership.assert_any_call( - str(expected_workdir), expected_user, self.mock_log_fh - ) - mock_change_ownership.assert_any_call( - str(expected_statedir), expected_user, self.mock_log_fh - ) - self.assertGreaterEqual(mock_change_ownership.call_count, 3) - mock_sys_exit.assert_not_called() - finally: - os.remove(config_path_str) - - # More Systemd Tests - def test_systemd_backup_existing_unit_files(self): - """Test backup of existing systemd unit files.""" - import sys - - sys.stderr.write("--- TEST_SYSTEMD_BACKUP_EXISTING_UNIT_FILES ENTERED ---\n") - - with patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: f"/usr/bin/{cmd}", - ) as mock_shutil_which, patch("pathlib.Path.write_text"), patch( - "tempfile.TemporaryDirectory" - ) as mock_tempfile_dir, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ) as mock_default_config_path, patch( - "os.geteuid", return_value=0 - ), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ) as mock_shutil_move, patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ), patch( - "bin.maillogsentinel_setup._setup_print_and_log", - wraps=mls_setup._setup_print_and_log, - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit, patch( - "pathlib.Path.exists", autospec=True - ) as mock_path_exists_controller, patch( - "locale.getpreferredencoding", return_value="utf-8" - ) as mock_locale_pref_enc: # noqa: F841 - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str_val = tmp_config_file.name - - mock_created_temp_dir = MagicMock() - mock_created_temp_dir.name = "/mock_temp_units_backup" - mock_tempfile_dir.return_value = mock_created_temp_dir - existing_unit_path = Path("/etc/systemd/system/maillogsentinel.service") - - self.path_exists_calls_log = [] - - def path_exists_logic_for_backup_test_actual(*args_passed): - call_info = {"args": args_passed, "returned": None} - self.path_exists_calls_log.append(call_info) - - if not args_passed: - call_info["returned"] = False - return False - - path_arg_obj = args_passed[0] - - if path_arg_obj == existing_unit_path: - call_info["returned"] = True - return True - - if path_arg_obj == Path(config_path_str_val): - call_info["returned"] = True - return True - - expected_main_script_path = Path( - mock_shutil_which("maillogsentinel.py") - ) - expected_ipinfo_script_path = Path(mock_shutil_which("ipinfo.py")) - - if path_arg_obj == expected_main_script_path: - call_info["returned"] = True - return True - if path_arg_obj == expected_ipinfo_script_path: - call_info["returned"] = True - return True - - config_for_paths_local = configparser.ConfigParser() - config_for_paths_local.read_string(VALID_CONFIG_CONTENT) - expected_workdir_parent = Path( - config_for_paths_local.get("paths", "working_dir") - ).parent - expected_statedir_parent = Path( - config_for_paths_local.get("paths", "state_dir") - ).parent - - if path_arg_obj in [ - Path("/etc"), - Path("/etc/systemd"), - mock_default_config_path.parent, - expected_workdir_parent, - expected_statedir_parent, - ]: - call_info["returned"] = True - return True - - call_info["returned"] = False - return False - - mock_path_exists_controller.side_effect = ( - path_exists_logic_for_backup_test_actual - ) - mock_sys_exit.side_effect = TestStopExecution - - real_open = open - open_calls_log = [] - - def logging_open(*args, **kwargs): - filename_to_open = args[0] - filename_to_open_str = str(filename_to_open) - is_our_temp_file = filename_to_open_str == config_path_str_val - - os_path_exists_before_real_open = None - if is_our_temp_file: - os_path_exists_before_real_open = os.path.exists( - filename_to_open_str - ) - - call_details = { - "args": args, - "kwargs": kwargs, - "opened_real": False, - "is_our_temp_file": is_our_temp_file, - "os_path_exists_before": os_path_exists_before_real_open, - } - open_calls_log.append(call_details) - - if is_our_temp_file: - # print(f"DEBUG: logging_open: Attempting to real_open {filename_to_open_str}. os.path.exists before open: {os_path_exists_before_real_open}") # noqa: E501 - if not os_path_exists_before_real_open: - # print(f"ERROR_DEBUG: File {filename_to_open_str} does not exist according to os.path.exists right before real_open!") # noqa: E501 - pass - - call_details["opened_real"] = True - return real_open(*args, **kwargs) - - raise OSError( - f"Mocked open explicitly denying access to {filename_to_open_str}" - ) - - original_move_side_effect = mock_shutil_move.side_effect - move_calls_log = [] - - def logging_move_side_effect(*args, **kwargs): - move_calls_log.append({"args": args, "kwargs": kwargs}) - if original_move_side_effect and not isinstance( - original_move_side_effect, MagicMock - ): - return original_move_side_effect(*args, **kwargs) - return None - - mock_shutil_move.side_effect = logging_move_side_effect - - original_backed_up_items = mls_setup.backed_up_items - mls_setup.backed_up_items = [] - try: - - # print("\nDEBUG Before SUT call: id(mls_setup.backed_up_items)", id(mls_setup.backed_up_items), "type:", type(mls_setup.backed_up_items)) # noqa: E501 - # with real_open(config_path_str_val, "r") as f_check: - # print(f"DEBUG Content of {config_path_str_val} before SUT call:\n{f_check.read()}") - - sut_stopped_by_exception = False - try: - with patch("builtins.open", new=logging_open): - mls_setup.non_interactive_setup( - Path(config_path_str_val), self.mock_log_fh - ) - except TestStopExecution: - sut_stopped_by_exception = True - # print("DEBUG: SUT call raised TestStopExecution (likely due to config read fail -> sys.exit).") - finally: - # print("DEBUG After SUT call (or during/after exception): id(mls_setup.backed_up_items)", id(mls_setup.backed_up_items), "type:", type(mls_setup.backed_up_items)) # noqa: E501 - # print("\nDEBUG open() calls log (from SUT context):", open_calls_log) - # print("\nDEBUG Path.exists calls log (from finally):") - # for i, call_log_item in enumerate(self.path_exists_calls_log): - # path_arg_display = "N/A (no Path instance in args_passed)" - # if call_log_item['args']: - # path_arg_obj_from_log = call_log_item['args'][0] - # path_arg_display = str(path_arg_obj_from_log) - # print(f" Call {i+1}: Arg = {path_arg_display}, Returned = {call_log_item['returned']}") - # print("\nDEBUG move_calls_log (from finally):", move_calls_log) - # print("DEBUG mls_setup.backed_up_items (from finally):", mls_setup.backed_up_items) - log_writes = "".join( - call.args[0] for call in self.mock_log_fh.write.call_args_list - ) - # print("DEBUG Log content (from finally):", log_writes) - - if not sut_stopped_by_exception: - # print("DEBUG: SUT completed without TestStopExecution. Proceeding to backup assertions.") - backup_call_found = any( - str(call["args"][0]) == str(existing_unit_path) - and ".backup_" in str(call["args"][1]) - for call in move_calls_log - ) # noqa: E501 - self.assertTrue( - backup_call_found, - f"Backup move call for {existing_unit_path} not found. All move calls: {move_calls_log}", - ) # noqa: E501 - self.assertNotIn( - f"ERROR backing up {existing_unit_path}", - log_writes, - "Error message found in log during backup operation.", - ) # noqa: E501 - if backup_call_found: - backup_dst = next( - str(call["args"][1]) - for call in move_calls_log - if str(call["args"][0]) == str(existing_unit_path) - and ".backup_" in str(call["args"][1]) - ) # noqa: E501 - self.assertTrue( - any( - item[0] == backup_dst - and item[1] == str(existing_unit_path) - for item in mls_setup.backed_up_items - ), - f"Backed up item record for '{backup_dst}' (original: {existing_unit_path}) not found in mls_setup.backed_up_items: {mls_setup.backed_up_items}", - ) # noqa: E501 - install_call_found = any( - Path(call["args"][0]).name == existing_unit_path.name - and Path(call["args"][0]).parent - == Path(mock_created_temp_dir.name) - and str(call["args"][1]) == str(existing_unit_path) - for call in move_calls_log - ) - self.assertTrue( - install_call_found, - f"Install move call for {existing_unit_path.name} from temp not found.", - ) - mock_sys_exit.assert_not_called() - else: - mock_sys_exit.assert_called_once() - - open_attempt_for_our_file_details = next( - ( - log_entry - for log_entry in open_calls_log - if log_entry["is_our_temp_file"] - ), - None, - ) # noqa: E501 - - self.assertIsNotNone( - open_attempt_for_our_file_details, - f"builtins.open was not attempted for the temp config file {config_path_str_val}. Log: {open_calls_log}", - ) # noqa: E501 - - if open_attempt_for_our_file_details: - self.assertTrue( - open_attempt_for_our_file_details["opened_real"], - "logging_open intended to use real_open but didn't mark it.", - ) # noqa: E501 - self.assertFalse( - open_attempt_for_our_file_details["os_path_exists_before"], - f"os.path.exists unexpectedly returned True for {config_path_str_val} right before real_open, yet read failed.", - ) # noqa: E501 - - self.assertTrue( - any( - "ERROR: Could not read or parse source configuration file" - in logged_call.args[0] - for logged_call in mock_setup_print.call_args_list - ), - "Expected 'Could not read or parse' log message not found in mock_setup_print calls.", - ) - finally: - if ( - "config_path_str_val" in locals() - and Path(config_path_str_val).exists() - ): - os.remove(config_path_str_val) - mls_setup.backed_up_items = original_backed_up_items - pass - - def test_systemd_control_commands_success(self): - """Test successful execution of systemctl commands.""" - with patch("pathlib.Path.exists", return_value=True), patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: f"/usr/bin/{cmd}", - ), patch("pathlib.Path.write_text"), patch( - "tempfile.TemporaryDirectory" - ) as mock_tempfile_dir, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch( - "os.geteuid", return_value=0 - ), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ), patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: # noqa: F841 - - mock_temp_dir_instance = MagicMock() - mock_temp_dir_instance.name = "/mock_temp_systemd_success" - mock_tempfile_dir.return_value = mock_temp_dir_instance - mock_subprocess_run.return_value = MagicMock( - returncode=0, stdout="", stderr="" - ) - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - try: - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - finally: - os.remove(config_path_str) - - # Expected calls to systemd-analyze for calendar validation - # These come from the VALID_CONFIG_CONTENT and the defaults in non_interactive_setup - # Order matters here as they are called before systemctl daemon-reload. - [ - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - config.get("sql_export_systemd", "frequency", fallback="*:0/4"), - ], - capture_output=True, - text=True, - check=False, - ), - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - config.get("sql_import_systemd", "frequency", fallback="*:0/5"), - ], - capture_output=True, - text=True, - check=False, - ), - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - config.get("systemd", "extraction_schedule", fallback="hourly"), - ], - capture_output=True, - text=True, - check=False, - ), - # report_schedule default is 'daily', which is converted to '*-*-* 23:59:00' if not 'HH:MM' - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - config.get( - "systemd", "report_schedule", fallback="*-*-* 23:59:00" - ), - ], - capture_output=True, - text=True, - check=False, - ), - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - config.get("systemd", "ip_update_schedule", fallback="weekly"), - ], # Updated to match VALID_CONFIG_CONTENT - capture_output=True, - text=True, - check=False, - ), - ] - - expected_systemctl_calls = [ - unittest.mock.call( - ["/usr/bin/usermod", "-aG", "adm", "testuser"], - check=True, - capture_output=True, - text=True, - ), - unittest.mock.call( - ["/usr/bin/systemctl", "daemon-reload"], - check=True, - capture_output=True, - text=True, - ), - unittest.mock.call( - [ - "/usr/bin/systemctl", - "enable", - "--now", - "maillogsentinel-extract.timer", - ], - check=True, - capture_output=True, - text=True, - ), - unittest.mock.call( - [ - "/usr/bin/systemctl", - "enable", - "--now", - "maillogsentinel-report.timer", - ], - check=True, - capture_output=True, - text=True, - ), - unittest.mock.call( - ["/usr/bin/systemctl", "enable", "--now", "ipinfo-update.timer"], - check=True, - capture_output=True, - text=True, - ), - unittest.mock.call( - [ - "/usr/bin/systemctl", - "enable", - "--now", - "maillogsentinel-sql-export.timer", - ], - check=True, - capture_output=True, - text=True, - ), - unittest.mock.call( - [ - "/usr/bin/systemctl", - "enable", - "--now", - "maillogsentinel-sql-import.timer", - ], - check=True, - capture_output=True, - text=True, - ), - ] - - # The usermod call happens before calendar validations in non_interactive_setup - # Then calendar validations, then systemctl daemon-reload, then systemctl enable calls. - # So, the order in expected_calls should be: - # 1. usermod - # 2. calendar validations (order based on non_interactive_setup logic) - # 3. systemctl daemon-reload - # 4. systemctl enable ... (order based on non_interactive_setup logic) - - # Reconstructing expected_calls based on the actual flow in non_interactive_setup: - # 1. usermod - # 2. validate_calendar_expression for sql_export_schedule_str - # 3. validate_calendar_expression for sql_import_schedule_str - # 4. validate_calendar_expression for extraction_schedule_str - # 5. validate_calendar_expression for report_on_calendar - # 6. validate_calendar_expression for ip_update_schedule_str - # 7. systemctl daemon-reload - # 8. systemctl enable for timers (extract, report, ipinfo, sql-export, sql-import) - - current_config = configparser.ConfigParser() - current_config.read_string( - VALID_CONFIG_CONTENT - ) # Read the same config used in SUT - - expected_calls = [expected_systemctl_calls[0]] # usermod - - # Add calendar validation calls in the order they appear in non_interactive_setup - expected_calls.append( - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - current_config.get( - "sql_export_systemd", "frequency", fallback="*:0/4" - ), - ], - capture_output=True, - text=True, - check=False, - ) - ) - expected_calls.append( - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - current_config.get( - "sql_import_systemd", "frequency", fallback="*:0/5" - ), - ], - capture_output=True, - text=True, - check=False, - ) - ) - expected_calls.append( - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - current_config.get( - "systemd", "extraction_schedule", fallback="hourly" - ), - ], - capture_output=True, - text=True, - check=False, - ) - ) - # report_schedule logic: 'daily' -> '*-*-* 23:59:00' or 'HH:MM' -> '*-*-* HH:MM:00' - report_schedule_raw = current_config.get( - "systemd", "report_schedule", fallback="daily" - ) - if report_schedule_raw.lower() == "daily": - report_schedule_validated = ( - "*-*-* 23:59:00" # As per current logic in non_interactive_setup - ) - elif re.fullmatch(r"\d{2}:\d{2}", report_schedule_raw): - h, m = map(int, report_schedule_raw.split(":")) - report_schedule_validated = f"*-*-* {h:02d}:{m:02d}:00" - else: - report_schedule_validated = ( - report_schedule_raw # Assume it's a complex valid string - ) - - expected_calls.append( - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - report_schedule_validated, - ], - capture_output=True, - text=True, - check=False, - ) - ) - expected_calls.append( - unittest.mock.call( - [ - "/usr/bin/systemd-analyze", - "calendar", - "--iterations=1", - current_config.get( - "systemd", "ip_update_schedule", fallback="weekly" - ), - ], - capture_output=True, - text=True, - check=False, - ) - ) - - # Add remaining systemctl calls (daemon-reload and enables) - expected_calls.extend(expected_systemctl_calls[1:]) - - # Note: The actual subprocess calls in non_interactive_setup use text=True and specific paths from shutil.which - # We need to adjust the expected calls to match this. - # The shutil.which mock in this test is: side_effect=lambda cmd: f"/usr/bin/{cmd}" - - # Check if all expected calls are present and in order - # This also implicitly checks the call count if all calls are unique and ordered - mock_subprocess_run.assert_has_calls(expected_calls, any_order=False) - - # Explicitly check call count for robustness, especially if calls might not be unique in some complex scenarios - self.assertEqual(mock_subprocess_run.call_count, len(expected_calls)) - mock_sys_exit.assert_not_called() - - def test_systemd_control_command_daemon_reload_failure(self): - """Test failure of 'systemctl daemon-reload' command.""" - with patch("pathlib.Path.exists", return_value=True), patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: f"/usr/bin/{cmd}", - ), patch("pathlib.Path.write_text"), patch( - "tempfile.TemporaryDirectory" - ) as mock_tempfile_dir, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch( - "os.geteuid", return_value=0 - ), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - mock_temp_dir_instance = MagicMock() - mock_temp_dir_instance.name = "/mock_temp_daemon_reload_fail" - mock_tempfile_dir.return_value = mock_temp_dir_instance - - def subprocess_side_effect(*args, **kwargs): - if args[0] == [ - "/usr/bin/systemctl", - "daemon-reload", - ]: # Corrected command - raise subprocess.CalledProcessError( - 1, args[0], stderr="Mock daemon-reload error" - ) - return MagicMock(returncode=0) - - mock_subprocess_run.side_effect = subprocess_side_effect - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - try: - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - finally: - os.remove(config_path_str) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "ERROR: 'systemctl daemon-reload' failed" in call.args[0] - and "Mock daemon-reload error" in call.args[0] # noqa: E501 - for call in mock_setup_print.call_args_list - ) - ) - - def test_systemd_control_command_enable_timer_failure(self): - """Test failure of 'systemctl enable --now timer' command.""" - with patch("pathlib.Path.exists", return_value=True), patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: f"/usr/bin/{cmd}", - ), patch("pathlib.Path.write_text"), patch( - "tempfile.TemporaryDirectory" - ) as mock_tempfile_dir, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch( - "os.geteuid", return_value=0 - ), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - mock_temp_dir_instance = MagicMock() - mock_temp_dir_instance.name = "/mock_temp_enable_fail" - mock_tempfile_dir.return_value = mock_temp_dir_instance - - def subprocess_side_effect_enable_fail(*args, **kwargs): - # Ensure the command path matches what shutil.which would return - expected_cmd_prefix = ["/usr/bin/systemctl", "enable", "--now"] - if ( - args[0][:3] == expected_cmd_prefix - and "maillogsentinel-extract.timer" in args[0][3] - ): - raise subprocess.CalledProcessError( - 1, args[0], stderr="Mock timer enable error" - ) - return MagicMock(returncode=0) - - mock_subprocess_run.side_effect = subprocess_side_effect_enable_fail - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - try: - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - finally: - os.remove(config_path_str) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "ERROR: 'systemctl enable --now maillogsentinel-extract.timer' failed" - in call.args[0] - and "Mock timer enable error" in call.args[0] # noqa: E501 - for call in mock_setup_print.call_args_list - ) - ) - - def test_systemctl_not_found_via_which(self): - """Test behavior when 'systemctl' command is not found by shutil.which.""" - with patch("pathlib.Path.exists", return_value=True), patch( - "pathlib.Path.write_text" - ), patch("tempfile.TemporaryDirectory") as mock_tempfile_dir, patch( - "bin.maillogsentinel_setup.DEFAULT_CONFIG_PATH_SETUP", - Path("/mock_etc/maillogsentinel.conf"), - ), patch( - "os.geteuid", return_value=0 - ), patch( - "pathlib.Path.is_file", return_value=True - ), patch( - "pathlib.Path.mkdir" - ), patch( - "bin.maillogsentinel_setup.pwd.getpwnam", return_value=MagicMock() - ), patch( - "bin.maillogsentinel_setup.shutil.move" - ), patch( - "bin.maillogsentinel_setup.shutil.copy2" - ), patch( - "bin.maillogsentinel_setup._change_ownership" - ), patch( - "bin.maillogsentinel_setup.subprocess.run" - ) as mock_subprocess_run, patch( - "bin.maillogsentinel_setup.shutil.which", - side_effect=lambda cmd: None if cmd == "systemctl" else f"/usr/bin/{cmd}", - ) as _mock_shutil_which_outer, patch( - "bin.maillogsentinel_setup._setup_print_and_log" - ) as mock_setup_print, patch( - "bin.maillogsentinel_setup.sys.exit" - ) as mock_sys_exit: - - mock_temp_dir_instance = MagicMock() - mock_temp_dir_instance.name = "/mock_temp_systemctl_not_found_which" - mock_tempfile_dir.return_value = mock_temp_dir_instance - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".ini" - ) as tmp_config_file: - tmp_config_file.write(VALID_CONFIG_CONTENT) - config_path_str = tmp_config_file.name - config = configparser.ConfigParser() - config.read_string(VALID_CONFIG_CONTENT) - try: - with patch( - "configparser.ConfigParser.read", return_value=[config_path_str] - ), patch("configparser.ConfigParser", return_value=config): - mls_setup.non_interactive_setup( - Path(config_path_str), self.mock_log_fh - ) - finally: - os.remove(config_path_str) - - mock_sys_exit.assert_called_once_with(1) - self.assertTrue( - any( - "ERROR: 'systemctl' not found." in call.args[0] - for call in mock_setup_print.call_args_list - ) - ) - systemctl_subprocess_calls = [ - call - for call in mock_subprocess_run.call_args_list - if call.args[0] and call.args[0][0] == "/usr/bin/systemctl" - ] - self.assertEqual( - len(systemctl_subprocess_calls), - 0, - f"systemctl commands should not be run if systemctl is not found. Found: {systemctl_subprocess_calls}", - ) - - -class TestValidateCalendarExpression(unittest.TestCase): - def setUp(self): - self.mock_log_fh = MagicMock(spec=io.StringIO) - self.mock_log_fh.closed = False - # Ensure we have a clean slate for any global lists if the function were to modify them - # (it doesn't, but good practice if it did) - mls_setup.backed_up_items = [] - mls_setup.created_final_paths = [] - - @patch("bin.maillogsentinel_setup.shutil.which") - @patch("bin.maillogsentinel_setup.subprocess.run") - def test_valid_expressions(self, mock_subprocess_run, mock_shutil_which): - mock_shutil_which.return_value = "/usr/bin/systemd-analyze" - mock_subprocess_run.return_value = MagicMock(returncode=0, stderr="") - - valid_expressions = [ - "*:0/4", - "hourly", - "08:30", - "Mon *-*-* 10:00:00", - "daily", - "weekly", - "*-*-* 02:00:00", - ] - for expr in valid_expressions: - with self.subTest(expr=expr): - result = mls_setup.validate_calendar_expression( - expr, self.mock_log_fh, "fallback_expr" - ) - self.assertEqual(result, expr) - mock_subprocess_run.assert_called_with( - ["/usr/bin/systemd-analyze", "calendar", "--iterations=1", expr], - capture_output=True, - text=True, - check=False, - ) - - @patch("bin.maillogsentinel_setup.shutil.which") - @patch("bin.maillogsentinel_setup.subprocess.run") - @patch("bin.maillogsentinel_setup._setup_print_and_log") - def test_invalid_expressions( - self, mock_print_log, mock_subprocess_run, mock_shutil_which +import io +from pathlib import Path +from typing import Dict + +import pytest + +import bin.maillogsentinel_setup as cli +from lib.maillogsentinel import config + + +@pytest.fixture(autouse=True) +def _no_color(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli, "detect_color_support", lambda: False) + monkeypatch.setattr(cli, "verify_permissions", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "ensure_systemd_unit", lambda *args, **kwargs: None) + + +def test_non_interactive_dry_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO()) + config_path = tmp_path / "cfg.conf" + exit_code = cli.main( + ["--config", str(config_path), "--dry-run", "--no-color", "--non-interactive"] + ) + captured = capsys.readouterr() + assert exit_code == 0 + assert "Dry-run mode" in captured.out + assert "Configuration diff" in captured.out + + +def test_interactive_updates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + config_path = tmp_path / "cfg.conf" + + def fake_prompt( + text: str, *, default: str | None, options: cli.OutputOptions + ) -> str: + if "Log level" in text: + return "DEBUG" + if "Max log file size" in text: + return "128" + if "Enable DNS cache" in text: + return "n" + return default or "" + + def fake_confirm( + text: str, *, options: cli.OutputOptions, default: bool = True + ) -> bool: + if "Enable DNS cache" in text: + return False + return True + + written: Dict[str, config.Configuration] = {} + + def fake_write( + self: config.ConfigurationWriter, + cfg: config.Configuration, + *, + dry_run: bool = False, ): - mock_shutil_which.return_value = "/usr/bin/systemd-analyze" - mock_subprocess_run.return_value = MagicMock( - returncode=1, stderr="Invalid format" - ) - fallback = "hourly" - - invalid_expressions = [ - "*/4 * * * *", # cron - "every 10 minutes", # natural language - "not-a-valid-expression", - "*:0/nonsense", + written["config"] = cfg + return "content", "diff" + + monkeypatch.setattr(cli, "prompt", fake_prompt) + monkeypatch.setattr(cli, "confirm", fake_confirm) + monkeypatch.setattr(config.ConfigurationWriter, "write", fake_write, raising=False) + + exit_code = cli.main( + [ + "--config", + str(config_path), + "--interactive", + "--no-color", ] - for expr in invalid_expressions: - with self.subTest(expr=expr): - result = mls_setup.validate_calendar_expression( - expr, self.mock_log_fh, fallback - ) - self.assertEqual(result, fallback) - mock_print_log.assert_any_call( - f"WARNING: Invalid Systemd OnCalendar expression: '{expr}'. Error: Invalid format. Falling back to default: {fallback}", - self.mock_log_fh, - ) - - @patch("bin.maillogsentinel_setup.shutil.which") - @patch("bin.maillogsentinel_setup._setup_print_and_log") - def test_empty_expression(self, mock_print_log, mock_shutil_which): - mock_shutil_which.return_value = "/usr/bin/systemd-analyze" - fallback = "daily" - result = mls_setup.validate_calendar_expression("", self.mock_log_fh, fallback) - self.assertEqual(result, fallback) - mock_print_log.assert_any_call( - f"WARNING: Calendar string is empty. Falling back to default: {fallback}", - self.mock_log_fh, - ) - - @patch("bin.maillogsentinel_setup.shutil.which") - @patch("bin.maillogsentinel_setup.subprocess.run") - @patch("bin.maillogsentinel_setup._setup_print_and_log") - def test_systemd_analyze_not_found( - self, mock_print_log, mock_subprocess_run, mock_shutil_which - ): - mock_shutil_which.return_value = None # Simulate not found - test_expr = "*:0/15" - fallback = "hourly" - - # When systemd-analyze is not found, the function should return the original expression - # and log a warning. - result = mls_setup.validate_calendar_expression( - test_expr, self.mock_log_fh, fallback - ) - self.assertEqual(result, fallback) # Should now fallback - mock_print_log.assert_any_call( - f"WARNING: 'systemd-analyze' command not found. Cannot validate OnCalendar expressions. Using provided value '{test_expr}' without validation, assuming it is correct or relying on Systemd's own error handling later." - " This is not ideal. Falling back to default to be safe.", - self.mock_log_fh, - ) - mock_subprocess_run.assert_not_called() - - @patch("bin.maillogsentinel_setup.shutil.which") - @patch("bin.maillogsentinel_setup.subprocess.run") - @patch("bin.maillogsentinel_setup._setup_print_and_log") - def test_subprocess_run_filenotfound_exception( - self, mock_print_log, mock_subprocess_run, mock_shutil_which - ): - mock_shutil_which.return_value = "/usr/bin/systemd-analyze" # Found by which - mock_subprocess_run.side_effect = FileNotFoundError("Mocked FileNotFoundError") - test_expr = "01:00" - fallback = "daily" - - result = mls_setup.validate_calendar_expression( - test_expr, self.mock_log_fh, fallback - ) - self.assertEqual(result, fallback) # Should now fallback - mock_print_log.assert_any_call( - f"WARNING: 'systemd-analyze' command not found during execution attempt. Cannot validate OnCalendar expressions. " - f"Falling back to default: {fallback}", - self.mock_log_fh, - ) - - @patch("bin.maillogsentinel_setup.shutil.which") - @patch("bin.maillogsentinel_setup.subprocess.run") - @patch("bin.maillogsentinel_setup._setup_print_and_log") - def test_subprocess_run_unexpected_exception( - self, mock_print_log, mock_subprocess_run, mock_shutil_which - ): - mock_shutil_which.return_value = "/usr/bin/systemd-analyze" - mock_subprocess_run.side_effect = Exception("Mocked Unexpected Exception") - test_expr = "02:00" - fallback = "*-*-* 03:00:00" - result = mls_setup.validate_calendar_expression( - test_expr, self.mock_log_fh, fallback - ) - self.assertEqual(result, fallback) - mock_print_log.assert_any_call( - f"WARNING: An unexpected error occurred while validating calendar expression '{test_expr}': Mocked Unexpected Exception. Falling back to default: {fallback}", - self.mock_log_fh, - ) - - -if __name__ == "__main__": - unittest.main() + ) + + captured = capsys.readouterr() + assert exit_code == 0 + assert "Setup completed successfully" in captured.out + assert written["config"].general.log_level == "DEBUG" + assert written["config"].general.log_file_max_mb == 128 + assert written["config"].dns_cache.enabled is False + + +def test_invalid_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO()) + config_path = tmp_path / "cfg.conf" + exit_code = cli.main( + [ + "--config", + str(config_path), + "--set", + "general.log_level=INVALID", + "--no-color", + "--non-interactive", + ] + ) + captured = capsys.readouterr() + assert exit_code == 1 + assert "Log level" in captured.out diff --git a/tests/lib/maillogsentinel/test_config.py b/tests/lib/maillogsentinel/test_config.py index 8f2c101..5a30aa3 100644 --- a/tests/lib/maillogsentinel/test_config.py +++ b/tests/lib/maillogsentinel/test_config.py @@ -1,366 +1,121 @@ -import pytest -from pathlib import Path - -# import configparser # No longer used -from unittest.mock import MagicMock, patch - -from lib.maillogsentinel.config import AppConfig, DEFAULT_CONFIG - -# Default values used in AppConfig, for easier reference in tests -# DEFAULTS dictionary is now removed, DEFAULT_CONFIG from the module will be used. +"""Tests for the new configuration module.""" +from __future__ import annotations -@pytest.fixture -def mock_logger(): - return MagicMock() - - -def create_config_file(tmp_path: Path, content: str) -> Path: - config_file = tmp_path / "test_maillogsentinel.conf" - config_file.write_text(content) - return config_file +from pathlib import Path +from typing import Dict +import pytest -def test_appconfig_no_config_file(tmp_path: Path, mock_logger: MagicMock): - """Test AppConfig initialization when config file does not exist.""" - non_existent_path = tmp_path / "non_existent.conf" - config = AppConfig(non_existent_path, logger=mock_logger) +from lib.maillogsentinel import config - assert not config.config_loaded_successfully - mock_logger.warning.assert_called_with( - f"Config file not found at specified path: {non_existent_path}. " - f"Proceeding with default values." - ) - # Check a few default values - assert config.working_dir == Path(DEFAULT_CONFIG["paths"]["working_dir"]) - assert ( - config.state_dir - == Path(DEFAULT_CONFIG["paths"]["working_dir"]) - / DEFAULT_CONFIG["paths"]["state_dir"] - ) - assert config.log_level == DEFAULT_CONFIG["general"]["log_level"] - assert config.report_email is DEFAULT_CONFIG["report"]["email"] - assert config.dns_cache_enabled == DEFAULT_CONFIG["dns_cache"]["enabled"] +def test_configuration_validation_success() -> None: + cfg = config.Configuration() + assert cfg.validate() == [] -def test_appconfig_valid_config_file(tmp_path: Path, mock_logger: MagicMock): - """Test AppConfig with a valid configuration file.""" - content = """ -[paths] -working_dir = /tmp/mywork -state_dir = my_state -mail_log = /var/log/custom_mail.log +def test_configuration_validation_errors() -> None: + cfg = config.Configuration() + cfg.general.log_level = "INVALID" + cfg.paths.mail_log = "relative.log" + cfg.report.recipient = "not-an-email" + errors = cfg.validate() + assert "Log level" in errors[0] + assert any("absolute" in error for error in errors) + assert any("email" in error for error in errors) -[report] -email = test@example.com -subject_prefix = [TEST] +def test_merger_respects_precedence(tmp_path: Path) -> None: + config_path = tmp_path / "config.conf" + config_path.write_text( + """ [general] -log_level = DEBUG -log_file_max_bytes = 2000000 - -[dns_cache] -enabled = false -size = 256 -ttl_seconds = 1800 -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - - assert config.config_loaded_successfully - mock_logger.info.assert_called_with( - f"Successfully loaded configuration from {config_file}" - ) - - assert config.working_dir == Path("/tmp/mywork") - assert config.state_dir == Path("/tmp/mywork/my_state") # Relative to working_dir - assert config.mail_log == Path("/var/log/custom_mail.log") - assert config.report_email == "test@example.com" - assert config.report_subject_prefix == "[TEST]" - assert config.log_level == "DEBUG" - assert config.log_file_max_bytes == 2000000 - assert not config.dns_cache_enabled - assert config.dns_cache_size == 256 - assert config.dns_cache_ttl_seconds == 1800 - +log_level = WARNING -def test_appconfig_state_dir_absolute(tmp_path: Path, mock_logger: MagicMock): - """Test AppConfig when state_dir is an absolute path.""" - content = """ [paths] -working_dir = /tmp/work -state_dir = /tmp/abs_state -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - assert config.state_dir == Path("/tmp/abs_state") - - -def test_appconfig_invalid_config_file_parsing_error( - tmp_path: Path, mock_logger: MagicMock -): - """Test AppConfig with a config file that has parsing errors.""" - content = """ -this_is_not_a_valid_line_outside_a_section -[paths] -working_dir = /tmp/valid_path -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - - assert not config.config_loaded_successfully - mock_logger.error.assert_called_once() - # Check that it fell back to defaults - assert config.working_dir == Path(DEFAULT_CONFIG["paths"]["working_dir"]) - - -# Tests for getter methods -def test_get_str_not_loaded(tmp_path: Path, mock_logger: MagicMock): - config = AppConfig(tmp_path / "dummy.conf", logger=mock_logger) # Config not loaded - assert config._get_str("section", "option", "fallback_val") == "fallback_val" - mock_logger.debug.assert_any_call( - "Config not loaded. Using fallback 'fallback_val' for [section]option." - ) - - -def test_get_int_not_loaded(tmp_path: Path, mock_logger: MagicMock): - config = AppConfig(tmp_path / "dummy.conf", logger=mock_logger) - # We need to mock DEFAULT_CONFIG for this specific test case or ensure 'section'/'option' exists with a known default - # For simplicity here, let's assume we are testing against a known default if we add it to DEFAULT_CONFIG - # Or, more practically, test that it calls _get_default and returns its result. - # For now, let's adjust the test to reflect that it will try to fetch from DEFAULT_CONFIG - # and what the expected fallback logging would be. - # Let's add a temporary entry to DEFAULT_CONFIG for testing this specific scenario or mock _get_default - with patch.dict(DEFAULT_CONFIG, {"section": {"option": 12345}}, clear=True): - assert config._get_int("section", "option") == 12345 - mock_logger.debug.assert_any_call( - "Config not loaded. Using fallback '12345' for [section]option." - ) - - -def test_get_bool_not_loaded(tmp_path: Path, mock_logger: MagicMock): - config = AppConfig(tmp_path / "dummy.conf", logger=mock_logger) - with patch.dict( - DEFAULT_CONFIG, {"section": {"option": False}}, clear=True - ): # Example with False - assert config._get_bool("section", "option") is False - mock_logger.debug.assert_any_call( - "Config not loaded. Using fallback 'False' for [section]option." - ) - - -def test_get_int_value_error(tmp_path: Path, mock_logger: MagicMock): - """Test _get_int when config value is not a valid integer.""" - content = """ -[general] -log_file_max_bytes = not_an_int -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - - assert ( - config.log_file_max_bytes == DEFAULT_CONFIG["general"]["log_file_max_bytes"] - ) # Falls back to default - mock_logger.warning.assert_called_with( - f"Invalid integer value for [general]log_file_max_bytes. " - f"Using fallback {DEFAULT_CONFIG['general']['log_file_max_bytes']}." - ) - - -def test_get_bool_value_error(tmp_path: Path, mock_logger: MagicMock): - """Test _get_bool when config value is not a valid boolean.""" - content = """ -[dns_cache] -enabled = not_a_bool -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - - assert ( - config.dns_cache_enabled == DEFAULT_CONFIG["dns_cache"]["enabled"] - ) # Falls back to default - mock_logger.warning.assert_called_with( - f"Invalid boolean value for [dns_cache]enabled in config file. " # Modified message to reflect new logic in config.py - f"Using fallback {DEFAULT_CONFIG['dns_cache']['enabled']}." +working_dir = /var/log/custom +state_dir = /var/lib/custom +mail_log = /var/log/mail.log +csv_filename = custom.csv +""", + encoding="utf-8", ) + env: Dict[str, str] = { + "MAILLOGSENTINEL__GENERAL__LOG_LEVEL": "ERROR", + "MAILLOGSENTINEL__REPORT__RECIPIENT": "env@example.com", + } -def test_get_section_dict(tmp_path: Path, mock_logger: MagicMock): - content = """ -[report] -email = test@example.com -subject_prefix = [TEST] -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - - report_section = config.get_section_dict("report") - assert report_section == {"email": "test@example.com", "subject_prefix": "[TEST]"} - - -def test_get_section_dict_missing_section(tmp_path: Path, mock_logger: MagicMock): - content = """ -[paths] -working_dir = /tmp -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - assert config.get_section_dict("non_existent_section") == {} - - -def test_get_section_dict_config_not_loaded(tmp_path: Path, mock_logger: MagicMock): - config = AppConfig(tmp_path / "dummy.conf", logger=mock_logger) # Config not loaded - assert config.get_section_dict("any_section") == {} - - -@patch("sys.exit") -def test_exit_if_not_loaded_when_not_loaded( - mock_sys_exit: MagicMock, tmp_path: Path, mock_logger: MagicMock -): - """ - Test exit_if_not_loaded calls sys.exit if config_loaded_successfully is False. - """ - config_path = tmp_path / "non_existent.conf" - config = AppConfig( - config_path, logger=mock_logger - ) # This will set config_loaded_successfully to False + overrides = {"general__log_level": "DEBUG"} - assert not config.config_loaded_successfully - - custom_message = "Test exit message." - config.exit_if_not_loaded(message=custom_message) - - mock_logger.critical.assert_called_with( - custom_message + f" (Config path attempted: {config_path})" + merger = config.ConfigurationMerger( + config_path=config_path, + environment=env, + overrides=overrides, ) - mock_sys_exit.assert_called_once_with(1) - - -@patch("sys.exit") -def test_exit_if_not_loaded_when_loaded( - mock_sys_exit: MagicMock, tmp_path: Path, mock_logger: MagicMock -): - """ - Test exit_if_not_loaded does not call sys.exit if - config_loaded_successfully is True. - """ - content = """ + cfg = merger.load() + + assert cfg.general.log_level == "DEBUG" + assert cfg.report.recipient == "env@example.com" + assert cfg.paths.working_dir == "/var/log/custom" + + +def test_configuration_writer_dry_run(tmp_path: Path) -> None: + cfg = config.Configuration() + path = tmp_path / "maillogsentinel.conf" + writer = config.ConfigurationWriter(path) + content, diff = writer.write(cfg, dry_run=True) + assert "[general]" in content + assert "maillogsentinel.conf" in diff + assert not path.exists() + + +def test_configuration_writer_persists(tmp_path: Path) -> None: + cfg = config.Configuration() + path = tmp_path / "maillogsentinel.conf" + writer = config.ConfigurationWriter(path) + content, diff = writer.write(cfg, dry_run=False) + assert path.exists() + new_content, new_diff = writer.write(cfg, dry_run=False) + assert "No newline at end" not in new_diff + assert new_diff == "" + + +def test_appconfig_wrapper(tmp_path: Path) -> None: + path = tmp_path / "config.conf" + path.write_text( + """ [paths] working_dir = /tmp/work -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig( - config_file, logger=mock_logger - ) # This will set config_loaded_successfully to True - - assert config.config_loaded_successfully - - config.exit_if_not_loaded() - mock_sys_exit.assert_not_called() - - -# Example of testing a specific default when a value is missing -def test_appconfig_missing_specific_value_falls_back_to_default( - tmp_path: Path, mock_logger: MagicMock -): - content = """ -[paths] -# working_dir is missing, should use default -state_dir = my_state -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) - - assert config.working_dir == Path( - DEFAULT_CONFIG["paths"]["working_dir"] - ) # Falls back to default - assert ( - config.state_dir == Path(DEFAULT_CONFIG["paths"]["working_dir"]) / "my_state" - ) # Uses default working_dir for relative path +state_dir = /tmp/state +mail_log = /tmp/mail.log +csv_filename = custom.csv +[report] +recipient = test@example.com -def test_appconfig_new_sections_defaults(tmp_path: Path, mock_logger: MagicMock): - """Test AppConfig loads default values for new SQL-related sections.""" - non_existent_path = tmp_path / "non_existent_for_new_defaults.conf" - config = AppConfig(non_existent_path, logger=mock_logger) - - assert not config.config_loaded_successfully - - # Check defaults for [sqlite_database] - assert config.sqlite_db_type == DEFAULT_CONFIG["sqlite_database"]["db_type"] - assert config.sqlite_db_path == Path( - DEFAULT_CONFIG["sqlite_database"]["db_path"] - ) # Path conversion happens - assert config.sqlite_user == DEFAULT_CONFIG["sqlite_database"]["user"] - assert ( - config.sqlite_password_hash - == DEFAULT_CONFIG["sqlite_database"]["password_hash"] - ) - assert config.sqlite_salt == DEFAULT_CONFIG["sqlite_database"]["salt"] - - # Check defaults for [sql_export_systemd] - assert ( - config.sql_export_frequency == DEFAULT_CONFIG["sql_export_systemd"]["frequency"] - ) - - # Check defaults for [sql_import_systemd] - assert ( - config.sql_import_frequency == DEFAULT_CONFIG["sql_import_systemd"]["frequency"] - ) - - # Check defaults for [sql_export_settings] - assert ( - config.sql_column_mapping_file_path_str - == DEFAULT_CONFIG["sql_export_settings"]["column_mapping_file"] - ) - assert ( - config.sql_target_table_name - == DEFAULT_CONFIG["sql_export_settings"]["table_name"] - ) - - -def test_appconfig_new_sections_from_file(tmp_path: Path, mock_logger: MagicMock): - """Test AppConfig loads values from file for new SQL-related sections.""" - content = """ -[sqlite_database] -db_type = test_sqlite -db_path = /tmp/test.db -user = test_user -# password_hash and salt would be set by setup, not typically in a raw config by user for SQLite - -[sql_export_systemd] -frequency = every 10 minutes - -[sql_import_systemd] -frequency = every 15 minutes +[general] +log_level = WARNING +log_file_max_mb = 10 +log_file_backup_count = 3 -[sql_export_settings] -column_mapping_file = /etc/maillog_map.json -table_name = my_event_log -""" - config_file = create_config_file(tmp_path, content) - config = AppConfig(config_file, logger=mock_logger) +[dns_cache] +enabled = false +size = 64 +ttl = 600 - assert config.config_loaded_successfully +[database] +path = /tmp/db.sqlite - # Check values from file for [sqlite_database] - assert config.sqlite_db_type == "test_sqlite" - assert config.sqlite_db_path == Path("/tmp/test.db") - assert config.sqlite_user == "test_user" - # password_hash and salt will be empty string if not in file, as per _get_str default behavior with DEFAULT_CONFIG - assert ( - config.sqlite_password_hash - == DEFAULT_CONFIG["sqlite_database"]["password_hash"] +[timers] +sql_export = *:0/10 +sql_import = *:0/20 +""", + encoding="utf-8", ) - assert config.sqlite_salt == DEFAULT_CONFIG["sqlite_database"]["salt"] - - # Check values from file for [sql_export_systemd] - assert config.sql_export_frequency == "every 10 minutes" - - # Check values from file for [sql_import_systemd] - assert config.sql_import_frequency == "every 15 minutes" - # Check values from file for [sql_export_settings] - assert config.sql_column_mapping_file_path_str == "/etc/maillog_map.json" - assert config.sql_target_table_name == "my_event_log" + app_config = config.AppConfig(path) + assert app_config.working_dir == Path("/tmp/work") + assert app_config.dns_cache_enabled is False + assert app_config.sql_export_frequency == "*:0/10"