2
\$\begingroup\$

Using systemd's .path unit to run a Python script whenever a httpd config file is changed. The Python script creates a backup of the config and logs the differences of the newly modified and backup file. After that it checks the syntax of the httpd config and gracefully reloads the web server service.

The Python script:

#!/path/to/python 

import difflib
import logging
import shutil
import subprocess
import sys
from pathlib import Path
import yaml

logging.basicConfig(format='%(levelname)s - %(message)s', level=logging.INFO)


class HttpdMonitor:

    def __init__(self, config_file):
        self.config_file = config_file
        self.config = self.load_config()
        self.httpd_conf = self.config['httpd-conf']
        self.backup_file = self.config['httpd-conf'] + '.bak'
        self.syntax_check_cmd = self.config['syntax-check-cmd'].split()
        self.reload_cmd = self.config['reload-cmd'].split()

    def load_config(self):
        with open(self.config_file, 'r') as stream:
            return yaml.safe_load(stream)

    def make_backup(self):
        try:
            shutil.copy2(self.httpd_conf, self.backup_file)
        except IOError as e:
            logging.exception("Making backup failed: %s", e)

    def find_diff(self):
        with open(self.httpd_conf, 'r') as f, open(self.backup_file, 'r') as bak:
            diffs = list(
                difflib.unified_diff(f.readlines(), bak.readlines(), fromfile=self.httpd_conf, tofile=self.backup_file))
            return diffs

    def log_diff(self):
        try:
            diffs = self.find_diff()
            logging.info('Differences found: \n%s', ''.join(diffs))
        except IOError as e:
            logging.exception('Finding diff failed: %s', e)

    def call_httpd_commands(self):
        subprocess.run(self.syntax_check_cmd, check=True)
        subprocess.run(self.reload_cmd, check=True)

    def reload_httpd(self):
        try:
            self.call_httpd_commands()
        except subprocess.CalledProcessError as e:
            logging.exception("Reloading failed: %s", e)

    def run(self):
        if not Path(self.backup_file).is_file():
            self.make_backup()
            return
        self.log_diff()
        self.make_backup()
        self.reload_httpd()


if __name__ == '__main__':
    httpd_monitor = HttpdMonitor(sys.argv[1])
    httpd_monitor.run()

This uses a YAML file to specify the httpd config file and also the commands to run, sort of like this:

---
httpd-conf: /path/to/file
syntax-check-cmd: httpd -t
reload-cmd: httpd -k graceful

Using systemd's .path unit the script get fired whenever there's something changed in the httpd config file. For example httpd-monitor.path:

[Unit]
Description=Monitor the httpd conf for changes

[Path]
PathChanged=/path/to/file
Unit=httpd-monitor.service

[Install]
WantedBy=multi-user.target

And httpd-monitor.service:

[Unit]
Description=Do backup, diff changes and reload httpd

[Service]
Restart=no
Type=simple
ExecStart=/path/to/httpd_monitor.py /path/to/httpd-monitor.yaml

[Install]
WantedBy=multi-user.target

So I'd first run systemd start httpd-monitor.service just to create the initial backup (the if condition within the run() method should take care of it) and then just systemd enable --now httpd-monitor.path to enable the .path unit. Probably not the simplest of solutions so I'm very open to any feedback!

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

As a whole your approach looks good, except for a couple of issues I'd suggest to fix:

  • __init__ method
    Avoid repetitive indexing of nested structures like self.config['httpd-conf'].
    Instead use assigned instance variable with f-string formatting:

    def __init__(self, config_file):
        self.config_file = config_file
        self.config = self.load_config()
        self.httpd_conf = self.config['httpd-conf']
        self.backup_file = f'{self.httpd_conf}.bak'
        self.syntax_check_cmd = self.config['syntax-check-cmd'].split()
        self.reload_cmd = self.config['reload-cmd'].split()
    
  • find_diff method
    No need to convert a generator returned by difflib.unified_diff function into list with diffs = list(...) - the subsequent ''.join(diffs) itself will take care of consuming generator.

  • call_httpd_commands method
    This method is redundant as its main effect is reloading httpd server, but you already have the appropriate method for that purpose - reload_httpd.
    Thus call_httpd_commands method's statements are just moved there and the old method is eliminated:

    def reload_httpd(self):
        try:
            subprocess.run(self.syntax_check_cmd, check=True)
            subprocess.run(self.reload_cmd, check=True)
        except subprocess.CalledProcessError as e:
            logging.exception("Reloading failed: %s", e)
    
\$\endgroup\$
1
  • \$\begingroup\$ Thank you! I wasn't too sure how to write the reload_httpd() method. So I made a separate call_httpd_commands because I remembered from Robert C. Martin's 'Clean Code' book (which seems to be more Java specific tho) that it's 'nicer' to extract functions from error handling. It does look redundant now that I look at it and in this case I have to agree that I should break that 'rule'. \$\endgroup\$
    – user214387
    Commented Dec 5, 2019 at 7:50

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.