├── tests ├── __init__.py ├── test_dbus_event_loop.py ├── test_as_unprivileged_user.py ├── test_get_pid.py ├── testcase.py ├── test_get_logger.py ├── test_reload_nginx.py ├── test_fix_custom_config_dir_permissions.py ├── test_after_loop.py ├── test_parse_nginx_config_reloader_arguments.py ├── test_directory_is_unmounted.py ├── test_inotify_callbacks.py ├── test_main.py ├── test_assert_forbidden_statements_in_config.py └── test_nginx_config_reloader.py ├── debian ├── compat ├── install ├── rules ├── nginx-config-reloader.service ├── control ├── nginx-config-reloader.upstart ├── usr │ └── share │ │ └── dbus-1 │ │ └── system.d │ │ └── nginx-config-reloader-bus.conf ├── nginx-config-reloader.init └── changelog ├── nginx_config_reloader ├── dbus │ ├── __init__.py │ ├── common.py │ └── server.py ├── utils.py ├── copy_files.py ├── settings.py └── __init__.py ├── pyproject.toml ├── requirements.txt ├── .github └── workflows │ ├── linter.yaml │ └── test.yaml ├── tox.ini ├── setup.py ├── .pre-commit-config.yaml ├── .gitignore ├── README.md ├── _build_local.sh └── mark-release.sh /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /nginx_config_reloader/dbus/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" # Make isort compatible with black 3 | -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | debian/usr/share/dbus-1/system.d/nginx-config-reloader-bus.conf /usr/share/dbus-1/system.d 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyinotify==0.9.6 2 | 3 | mock==5.0.1 4 | pytest==7.2.1 5 | pytest-xdist==3.2.0 6 | tox==4.4.5 7 | black==23.1.0 8 | pre-commit==2.21.0 9 | pygobject 10 | pygobject-stubs 11 | dasbus==1.7 12 | -------------------------------------------------------------------------------- /.github/workflows/linter.yaml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | linter: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-python@v3 11 | - uses: pre-commit/action@v3.0.0 12 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ --with python3 --with systemd --buildsystem=pybuild 5 | 6 | override_dh_auto_test: 7 | # skipping tests. we don't want to test in the build env, 8 | # we do this alkready in jenkins 9 | 10 | override_dh_install: 11 | dh_install 12 | -------------------------------------------------------------------------------- /nginx_config_reloader/dbus/common.py: -------------------------------------------------------------------------------- 1 | from dasbus.connection import SystemMessageBus 2 | from dasbus.identifier import DBusServiceIdentifier 3 | 4 | SYSTEM_BUS = SystemMessageBus() 5 | 6 | NGINX_CONFIG_RELOADER = DBusServiceIdentifier( 7 | namespace=("com", "hypernode", "NginxConfigReloader"), 8 | message_bus=SYSTEM_BUS, 9 | ) 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py37,py38,py39,py310,py311 3 | skipsdist=True 4 | 5 | [gh-actions] 6 | python = 7 | 3.7: py37 8 | 3.8: py38 9 | 3.9: py39 10 | 3.10: py310 11 | 3.11: py311 12 | 13 | [testenv] 14 | deps = -rrequirements.txt 15 | allowlist_externals=bash 16 | commands= 17 | bash -c "pytest tests -n $(nproc)" 18 | -------------------------------------------------------------------------------- /debian/nginx-config-reloader.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Daemon that detects, checks and installs user provided nginx configuration files 3 | After=remote-fs.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/nginx_config_reloader --monitor 7 | StandardOutput=null 8 | StandardError=journal 9 | RestartSec=10 10 | Restart=always 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /nginx_config_reloader/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | 4 | 5 | def directory_is_unmounted(path): 6 | output = subprocess.check_output( 7 | ["systemctl", "list-units", "-t", "mount", "--all", "-o", "json"], 8 | encoding="utf-8", 9 | ) 10 | units = json.loads(output) 11 | for unit in units: 12 | if unit["description"] == path: 13 | return unit["active"] != "active" or unit["sub"] != "mounted" 14 | return False 15 | -------------------------------------------------------------------------------- /tests/test_dbus_event_loop.py: -------------------------------------------------------------------------------- 1 | from nginx_config_reloader import dbus_event_loop 2 | from tests.testcase import TestCase 3 | 4 | 5 | class TestDbusEventLoop(TestCase): 6 | def setUp(self): 7 | self.event_loop = self.set_up_patch("nginx_config_reloader.EventLoop") 8 | 9 | def test_it_runs_dbus_event_loop(self): 10 | dbus_event_loop() 11 | 12 | self.event_loop.assert_called_once_with() 13 | self.event_loop.return_value.run.assert_called_once_with() 14 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: nginx-config-reloader 2 | Maintainer: Hypernode Tech Team 3 | Section: misc 4 | Priority: optional 5 | Standards-Version: 3.9.2 6 | Build-Depends: debhelper (>= 9), python3, python3-pyinotify, python3-setuptools, dh-python 7 | X-Python3-Version: >= 3.5 8 | 9 | Package: nginx-config-reloader 10 | Architecture: all 11 | Depends: ${python3:Depends}, ${misc:Depends}, python3-pyinotify, python3-dasbus 12 | Description: nginx config files reloader 13 | Daemon that installs and reloads nginx configuration 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name="nginx_config_reloader", 5 | version="20250117.131018", 6 | packages=find_packages(exclude=["test*"]), 7 | url="https://github.com/ByteInternet/nginx_config_reloader", 8 | license="", 9 | author="Willem de Groot", 10 | author_email="willem@byte.nl", 11 | description="nginx config file monitor and reloader", 12 | entry_points={ 13 | "console_scripts": ["nginx_config_reloader = nginx_config_reloader:main"] 14 | }, 15 | install_requires=["pyinotify>=0.9.2", "dasbus>=1.7"], 16 | test_suite="tests", 17 | ) 18 | -------------------------------------------------------------------------------- /debian/nginx-config-reloader.upstart: -------------------------------------------------------------------------------- 1 | # nginx-config-reloader - Daemon that detects, checks and installs user provided nginx configuration files 2 | 3 | description "Daemon that detects, checks and installs user provided nginx configuration files" 4 | author "Rick van de Loo " 5 | 6 | start on runlevel [2345] 7 | stop on runlevel [016] 8 | 9 | # Infinite respawn 10 | respawn 11 | respawn limit unlimited 12 | 13 | exec /usr/bin/nginx_config_reloader 14 | 15 | post-stop script 16 | goal=$(initctl status $UPSTART_JOB | cut -d' ' -f 2 | cut -d/ -f 1) 17 | if [ "$goal" != "stop" ]; then 18 | sleep 10; 19 | fi 20 | end script 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: 'v4.4.0' 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - repo: https://github.com/psf/black 10 | rev: '23.1.0' 11 | hooks: 12 | - id: black 13 | - repo: https://github.com/myint/autoflake 14 | rev: 'v2.0.1' 15 | hooks: 16 | - id: autoflake 17 | args: ["--in-place", "--remove-unused-variables", "--remove-all-unused-imports"] 18 | - repo: https://github.com/pycqa/isort 19 | rev: '5.11.5' 20 | hooks: 21 | - id: isort 22 | name: isort (python) 23 | args: ["--profile", "black"] 24 | -------------------------------------------------------------------------------- /tests/test_as_unprivileged_user.py: -------------------------------------------------------------------------------- 1 | from nginx_config_reloader import ( 2 | UNPRIVILEGED_GID, 3 | UNPRIVILEGED_UID, 4 | as_unprivileged_user, 5 | ) 6 | from tests.testcase import TestCase 7 | 8 | 9 | class TestAsUnprivilegedUser(TestCase): 10 | def setUp(self): 11 | self.setgid = self.set_up_patch("nginx_config_reloader.os.setgid") 12 | self.setuid = self.set_up_patch("nginx_config_reloader.os.setuid") 13 | 14 | def test_as_unprivileged_user_sets_gid_to_unprivileged_gid(self): 15 | as_unprivileged_user() 16 | 17 | self.setgid.assert_called_once_with(UNPRIVILEGED_GID) 18 | 19 | def test_as_unprivileged_user_sets_uid_to_unprivileged_uid(self): 20 | as_unprivileged_user() 21 | 22 | self.setuid.assert_called_once_with(UNPRIVILEGED_UID) 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | runs-on: ${{ matrix.os || 'ubuntu-22.04' }} 9 | strategy: 10 | matrix: 11 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 12 | include: 13 | - python-version: '3.7' 14 | os: 'ubuntu-22.04' 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | sudo apt update 24 | sudo apt install libgirepository1.0-dev -y 25 | pip install -r requirements.txt 26 | pip install tox-gh-actions 27 | - name: Test with tox 28 | run: tox 29 | -------------------------------------------------------------------------------- /nginx_config_reloader/dbus/server.py: -------------------------------------------------------------------------------- 1 | from dasbus.server.interface import dbus_interface, dbus_signal 2 | from dasbus.server.property import emits_properties_changed 3 | from dasbus.server.template import InterfaceTemplate 4 | 5 | from nginx_config_reloader.dbus.common import NGINX_CONFIG_RELOADER 6 | 7 | 8 | @dbus_interface(NGINX_CONFIG_RELOADER.interface_name) 9 | class NginxConfigReloaderInterface(InterfaceTemplate): 10 | def connect_signals(self): 11 | self.implementation.reloaded.connect(self.ConfigReloaded) 12 | 13 | @dbus_signal 14 | def ConfigReloaded(self): 15 | """Signal that the config was reloaded""" 16 | 17 | @emits_properties_changed 18 | def Reload(self): 19 | """Mark the last reload at current time.""" 20 | # send_signal=False because we don't want to emit the signal 21 | self.implementation.reload(send_signal=False) 22 | -------------------------------------------------------------------------------- /tests/test_get_pid.py: -------------------------------------------------------------------------------- 1 | import nginx_config_reloader 2 | from tests.testcase import TestCase 3 | 4 | 5 | class TestGetPid(TestCase): 6 | def setUp(self): 7 | self.mock_open = self.set_up_mock_open(read_value="42") 8 | 9 | def test_that_get_pid_returns_pid_from_pidfile(self): 10 | tm = nginx_config_reloader.NginxConfigReloader() 11 | self.assertEqual(tm.get_nginx_pid(), 42) 12 | 13 | def test_that_get_pid_returns_none_if_theres_no_pid_file(self): 14 | self.mock_open.side_effect = IOError("No such file or directory") 15 | tm = nginx_config_reloader.NginxConfigReloader() 16 | self.assertIsNone(tm.get_nginx_pid()) 17 | 18 | def test_that_get_pid_returns_none_if_pidfile_doesnt_contain_pid(self): 19 | self.mock_open = self.set_up_mock_open(read_value="") 20 | tm = nginx_config_reloader.NginxConfigReloader() 21 | self.assertIsNone(tm.get_nginx_pid()) 22 | -------------------------------------------------------------------------------- /debian/usr/share/dbus-1/system.d/nginx-config-reloader-bus.conf: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | .tox/ 31 | .coverage 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | # Rope 45 | .ropeproject 46 | 47 | # Django stuff: 48 | *.log 49 | *.pot 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # JetBrains 55 | .idea 56 | 57 | debian/files 58 | debian/debhelper-* 59 | debian/nginx-config-reloader.*.debhelper 60 | debian/nginx-config-reloader.substvars 61 | debian/nginx-config-reloader/ 62 | 63 | # ctags 64 | tags 65 | ctags 66 | 67 | # vim 68 | .swp 69 | 70 | .venv 71 | venv 72 | -------------------------------------------------------------------------------- /tests/testcase.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | 4 | from mock import Mock, mock_open, patch 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | def set_up_patch(self, patch_target, mock_target=None, **kwargs): 9 | patcher = patch(patch_target, mock_target or Mock(**kwargs)) 10 | self.addCleanup(patcher.stop) 11 | return patcher.start() 12 | 13 | def set_up_context_manager_patch(self, topatch, themock=None, **kwargs): 14 | patcher = self.set_up_patch(topatch, themock=themock, **kwargs) 15 | patcher.return_value.__exit__ = lambda a, b, c, d: None 16 | patcher.return_value.__enter__ = lambda x: None 17 | return patcher 18 | 19 | def set_up_mock_open(self, read_value=""): 20 | py_version = sys.version_info 21 | python2 = py_version < (3, 0) 22 | if python2: 23 | return self.set_up_patch( 24 | "__builtin__.open", mock_open(read_data=read_value) 25 | ) 26 | else: 27 | return self.set_up_patch("builtins.open", mock_open(read_data=read_value)) 28 | -------------------------------------------------------------------------------- /nginx_config_reloader/copy_files.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from subprocess import STDOUT, check_output 4 | 5 | from nginx_config_reloader.settings import SYNC_IGNORE_FILES 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def safe_copy_files(src, dest): 11 | cmd = [ 12 | # Adding a / at the end copies contents of the dir and not the dir itself 13 | # This is achieved with `os.path.join(x, '')`, which ensures a trailing slash 14 | "rsync", 15 | os.path.join(src, ""), 16 | dest, 17 | # Delete and archive without copying over permissions, aka -da but without -p (-a equals -rlptgoD) 18 | "-drltgoD", 19 | "--chown", 20 | "root:root", 21 | "--copy-links", # Follow symlinks and copy actual file 22 | # Dirs default to 0755 to read. Remove setuid bits. Remove executability for others 23 | '--chmod="D755,-s,Fo-wx"', 24 | ] 25 | cmd.extend(['--exclude="{}"'.format(pattern) for pattern in SYNC_IGNORE_FILES]) 26 | cmd = " ".join(cmd) 27 | # shell=True to ensure globs are not escaped 28 | check_output(cmd, shell=True, stderr=STDOUT) 29 | -------------------------------------------------------------------------------- /tests/test_get_logger.py: -------------------------------------------------------------------------------- 1 | from nginx_config_reloader import get_logger 2 | from tests.testcase import TestCase 3 | 4 | 5 | class TestGetLogger(TestCase): 6 | def setUp(self): 7 | self.logging = self.set_up_patch("nginx_config_reloader.logging") 8 | self.handler = self.logging.StreamHandler.return_value 9 | self.logger = self.set_up_patch("nginx_config_reloader.logger") 10 | 11 | def test_get_logger_instantiates_streamhandler(self): 12 | get_logger() 13 | 14 | self.logging.StreamHandler.assert_called_once_with() 15 | 16 | def test_get_logger_sets_custom_formatter(self): 17 | get_logger() 18 | 19 | self.handler.setFormatter.assert_called_once_with( 20 | self.logging.Formatter.return_value 21 | ) 22 | 23 | def test_get_logger_sets_default_logging_level_to_debug(self): 24 | get_logger() 25 | 26 | self.logger.setLevel.assert_called_once_with(self.logging.DEBUG) 27 | 28 | def test_get_logger_adds_custom_logging_handler(self): 29 | get_logger() 30 | 31 | self.logger.addHandler.assert_called_once_with(self.handler) 32 | 33 | def test_get_logger_returns_logger(self): 34 | ret = get_logger() 35 | 36 | self.assertEqual(self.logger, ret) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nginx config reloader 2 | 3 | Utility to check user-supplied nginx config files, install them, and reload 4 | nginx configuration if they work. 5 | 6 | Config files are taken from `/data/web/nginx` and moved to `/etc/nginx/app`. 7 | Nginx is used to test if the config is valid. If it is, the nginx config will 8 | be reloaded. If not, the original configuration files will be restored, and 9 | the error message from nginx will be placed in `/data/web/nginx/nginx_error_output` 10 | 11 | ## Installation 12 | 13 | ```bash 14 | python setup.py install 15 | ``` 16 | 17 | or 18 | 19 | ```bash 20 | pip install -e git+https://github.com/ByteInternet/nginx_config_reloader#egg=nginx_config_reloader 21 | ``` 22 | 23 | ## Usage 24 | 25 | `nginx_config_reloader` to check/copy config files and reload nginx 26 | 27 | `nginx_config_reloader --daemon` to fork to background and monitor changes 28 | 29 | `nginx_config_reloader --monitor` to stay in foreground and monitor changes 30 | 31 | 32 | ## Running tests 33 | 34 | ```bash 35 | pip install -r requirements.txt 36 | tox 37 | ``` 38 | 39 | ## Building debian packages 40 | 41 | To create a package from "master" branch (for production) run the "build.sh" script 42 | 43 | ```bash 44 | ./build.sh 45 | ``` 46 | 47 | This would create a release tag as well. 48 | 49 | If you'd like to create a Debian package of a development branch (without tagging, etc.) 50 | you can use "_build_local.sh" script 51 | 52 | ```bash 53 | ./_build_local.sh 54 | ``` 55 | -------------------------------------------------------------------------------- /tests/test_reload_nginx.py: -------------------------------------------------------------------------------- 1 | import signal 2 | 3 | from nginx_config_reloader import NginxConfigReloader 4 | from tests.testcase import TestCase 5 | 6 | 7 | class TestReloadNginx(TestCase): 8 | def setUp(self) -> None: 9 | self.get_nginx_pid = self.set_up_patch( 10 | "nginx_config_reloader.NginxConfigReloader.get_nginx_pid", 11 | return_value=12345, 12 | ) 13 | self.kill = self.set_up_patch("nginx_config_reloader.os.kill") 14 | self.check_call = self.set_up_patch( 15 | "nginx_config_reloader.subprocess.check_call" 16 | ) 17 | self.reloader = NginxConfigReloader(use_systemd=False) 18 | 19 | def test_reload_nginx_uses_signal_process(self) -> None: 20 | self.reloader.reload_nginx() 21 | self.check_call.assert_not_called() 22 | self.get_nginx_pid.assert_called_once_with() 23 | self.kill.assert_called_once_with(12345, signal.SIGHUP) 24 | 25 | def test_reload_nginx_does_nothing_if_no_process_pid(self) -> None: 26 | self.get_nginx_pid.return_value = None 27 | self.reloader.reload_nginx() 28 | self.check_call.assert_not_called() 29 | self.get_nginx_pid.assert_called_once_with() 30 | self.kill.assert_not_called() 31 | 32 | def test_reload_nginx_uses_systemd(self) -> None: 33 | self.reloader.use_systemd = True 34 | self.reloader.reload_nginx() 35 | self.check_call.assert_called_once_with(["systemctl", "reload", "nginx"]) 36 | self.get_nginx_pid.assert_not_called() 37 | self.kill.assert_not_called() 38 | -------------------------------------------------------------------------------- /tests/test_fix_custom_config_dir_permissions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from unittest.mock import call 4 | 5 | from nginx_config_reloader import NginxConfigReloader, as_unprivileged_user 6 | from tests.testcase import TestCase 7 | 8 | 9 | class TestFixCustomConfigDirPermissions(TestCase): 10 | def setUp(self): 11 | self.check_output = self.set_up_patch("subprocess.check_output") 12 | self.temp_dir = tempfile.mkdtemp() 13 | self.tm = NginxConfigReloader( 14 | no_magento_config=False, 15 | no_custom_config=False, 16 | dir_to_watch=self.temp_dir, 17 | magento2_flag=None, 18 | ) 19 | 20 | def test_fix_custom_config_dir_permissions_chmods_all_dirs_to_755(self): 21 | os.mkdir(self.temp_dir + "/some_dir") 22 | 23 | self.tm.fix_custom_config_dir_permissions() 24 | 25 | self.check_output.assert_has_calls( 26 | [ 27 | call(["chmod", "755", self.temp_dir], preexec_fn=as_unprivileged_user), 28 | call( 29 | ["chmod", "755", self.temp_dir + "/some_dir"], 30 | preexec_fn=as_unprivileged_user, 31 | ), 32 | ] 33 | ) 34 | 35 | def test_fix_custom_config_dir_permissions_ignores_symlinks(self): 36 | other_temp_dir = tempfile.mkdtemp() 37 | os.symlink(other_temp_dir, self.temp_dir + "/some_pointing_dir") 38 | 39 | self.tm.fix_custom_config_dir_permissions() 40 | 41 | self.check_output.assert_called_once_with( 42 | ["chmod", "755", self.temp_dir], preexec_fn=as_unprivileged_user 43 | ) 44 | -------------------------------------------------------------------------------- /tests/test_after_loop.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from tempfile import mkdtemp 3 | from unittest.mock import Mock 4 | 5 | import nginx_config_reloader 6 | from tests.testcase import TestCase 7 | 8 | 9 | class TestAfterLoop(TestCase): 10 | def setUp(self) -> None: 11 | self.source = mkdtemp() 12 | self.notifier = Mock(_eventq=deque(range(5))) 13 | 14 | def test_it_returns_nothing(self): 15 | tm = self._get_nginx_config_reloader_instance() 16 | 17 | result = nginx_config_reloader.after_loop(tm) 18 | 19 | self.assertIsNone(result) 20 | 21 | def test_it_applies_config_if_tree_dirty(self): 22 | tm = self._get_nginx_config_reloader_instance() 23 | tm.apply_new_config = Mock() 24 | tm.dirty = True 25 | 26 | nginx_config_reloader.after_loop(tm) 27 | 28 | tm.apply_new_config.assert_called_once_with() 29 | self.assertFalse(tm.dirty) 30 | 31 | def test_it_does_not_apply_config_if_tree_not_dirty(self): 32 | tm = self._get_nginx_config_reloader_instance() 33 | tm.apply_new_config = Mock() 34 | tm.dirty = False 35 | 36 | nginx_config_reloader.after_loop(tm) 37 | 38 | tm.apply_new_config.assert_not_called() 39 | self.assertFalse(tm.dirty) 40 | 41 | def _get_nginx_config_reloader_instance( 42 | self, 43 | no_magento_config=False, 44 | no_custom_config=False, 45 | magento2_flag=None, 46 | notifier=None, 47 | ): 48 | return nginx_config_reloader.NginxConfigReloader( 49 | no_magento_config=no_magento_config, 50 | no_custom_config=no_custom_config, 51 | dir_to_watch=self.source, 52 | magento2_flag=magento2_flag, 53 | notifier=notifier or self.notifier, 54 | ) 55 | -------------------------------------------------------------------------------- /_build_local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo "============================================================" 5 | echo "Hypernode Nginx Config Reloader Development Build" 6 | echo " - Will build package from a temp Git branch" 7 | echo " - Will NOT tag the build" 8 | echo " - Will NOT push anything" 9 | echo "============================================================" 10 | 11 | ARCH="${ARCH:-amd64}" 12 | DIST="${DIST:-xenial}" 13 | BUILDAREA="${BUILDAREA:-/tmp/nginx-config-reloader-build}" 14 | BUILDPATH="${BUILDAREA}-${DIST}" 15 | 16 | CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` 17 | if [ -z "$BRANCH" ]; then 18 | BRANCH="$CURRENT_BRANCH" 19 | fi 20 | 21 | export VERSION=$(date "+%Y%m%d.%H%M%S") 22 | 23 | git checkout $BRANCH 24 | TEMPBRANCH="$BRANCH-build-$DIST-$VERSION" 25 | git checkout -b $TEMPBRANCH 26 | 27 | echo "Updating setup.py with version $VERSION" 28 | perl -pi -e 's/version="[^"]*",/version=\"$ENV{"VERSION"}\",/g;' setup.py 29 | 30 | echo "Adding setup.py to git index" 31 | git add setup.py 32 | 33 | echo "Committing setup.py version update" 34 | git commit setup.py -m "Update version in setup.py to $VERSION" 35 | 36 | echo "Generating changelog changelog" 37 | gbp dch --debian-tag="%(version)s" --new-version=$VERSION --debian-branch $TEMPBRANCH --release --commit 38 | 39 | 40 | echo "Building package for $DIST" 41 | mkdir -p $BUILDPATH 42 | gbp buildpackage --git-pbuilder --git-export-dir=$BUILDPATH --git-dist=$DIST --git-arch=$ARCH \ 43 | --git-debian-branch=$TEMPBRANCH --git-ignore-new 44 | 45 | echo 46 | echo "*************************************************************" 47 | echo "Package built succesfully!" 48 | echo "--> ${BUILDPATH}/nginx-config-reloader_${VERSION}_all.deb" 49 | echo 50 | echo "Checking out original branch ..." 51 | git checkout $BRANCH 52 | 53 | if [ -z "${KEEP_TEMPBRANCH}" ]; then 54 | echo "Removing temp Git branch "$TEMPBRANCH" ... (to avoid this set KEEP_TEMPBRANCH env variable)" 55 | git branch -D $TEMPBRANCH 56 | echo "" 57 | echo "You can clear things up by" 58 | echo "------------------------------------------------------------" 59 | echo "rm -rf ${BUILDPATH}" 60 | else 61 | echo "You can clear things up by" 62 | echo "------------------------------------------------------------" 63 | echo "git branch -D ${TEMPBRANCH}" 64 | echo "rm -rf ${BUILDPATH}" 65 | fi 66 | -------------------------------------------------------------------------------- /nginx_config_reloader/settings.py: -------------------------------------------------------------------------------- 1 | DIR_TO_WATCH = "/data/web/nginx" 2 | MAIN_CONFIG_DIR = "/etc/nginx" 3 | CUSTOM_CONFIG_DIR = MAIN_CONFIG_DIR + "/app" 4 | BACKUP_CONFIG_DIR = MAIN_CONFIG_DIR + "/app_bak" 5 | UNPRIVILEGED_GID = 1000 # This is the 'app' user on a Hypernode, or generally the first user on any system 6 | UNPRIVILEGED_UID = 1000 # This is the 'app' user on a Hypernode, or generally the first user on any system 7 | 8 | MAGENTO_CONF = MAIN_CONFIG_DIR + "/magento.conf" 9 | MAGENTO1_CONF = MAIN_CONFIG_DIR + "/magento1.conf" 10 | MAGENTO2_CONF = MAIN_CONFIG_DIR + "/magento2.conf" 11 | 12 | NGINX = "/usr/sbin/nginx" 13 | NGINX_PID_FILE = "/var/run/nginx.pid" 14 | ERROR_FILE = "nginx_error_output" 15 | 16 | WATCH_IGNORE_FILES = ( 17 | # glob patterns 18 | ".*", 19 | "*~", 20 | "*.save", 21 | ERROR_FILE, 22 | ) 23 | SYNC_IGNORE_FILES = WATCH_IGNORE_FILES + ("*.flag",) 24 | SYSLOG_SOCKET = "/dev/log" 25 | 26 | # Using include or load_module is forbidden unless 27 | # - it is in a comment 28 | # - the include is a relative path but does not contain .. 29 | # - the include is absolute but in the MAIN_CONFIG_DIR 30 | # - but not in the BACKUP_CONFIG_DIR 31 | # - also takes into account double slashes 32 | # Because of bash escaping problems we define quote's in octal format \042 == ' and \047 == " 33 | 34 | # For security reasons the following nginx configuration parameters are forbidden 35 | FORBIDDEN_CONFIG_REGEX = [ 36 | ( 37 | "client_body_temp_path", 38 | "Usage of configuration parameter client_body_temp_path is not allowed.\n", 39 | ), 40 | ( 41 | "^(?!\\s*#)\\s*(access|error)_log\\s*" 42 | "(\\042|\\047)?\\s*" 43 | "(?!(off|on|/+data/+|syslog:server=(?!unix)))(?=.*\\.\\.|/+(?!data)|\\w)" 44 | "(\\042|\\047)?\\s*", 45 | "It's not allowed store access_log or error_log outside of /data/.\n", 46 | ), 47 | ( 48 | "^(?!\\s*#)\\s*(include|load_module)\\s*" 49 | "(\\042|\\047)?\\s*" 50 | "(?=.*\\.\\.|/+etc/+nginx/+app_bak|/+(?!etc/+nginx))" 51 | "(\\042|\\047)?\\s*", 52 | "You are not allowed to use include or load_module in the nginx config unless the path is relative " 53 | "or in the main nginx config directory. " 54 | "See the NGINX dos and don'ts in this article: " 55 | "https://support.hypernode.com/knowledgebase/how-to-use-nginx/\n", 56 | ), 57 | ("init_by_lua", "Usage of Lua initialization is not allowed.\n"), 58 | ] 59 | -------------------------------------------------------------------------------- /tests/test_parse_nginx_config_reloader_arguments.py: -------------------------------------------------------------------------------- 1 | from mock import call 2 | 3 | import nginx_config_reloader 4 | from nginx_config_reloader import parse_nginx_config_reloader_arguments 5 | from tests.testcase import TestCase 6 | 7 | 8 | class TestParseNginxConfigReloaderArguments(TestCase): 9 | def setUp(self): 10 | self.parser = self.set_up_patch("nginx_config_reloader.argparse.ArgumentParser") 11 | 12 | def test_parse_nginx_config_reloader_arguments_instantiates_argparser(self): 13 | parse_nginx_config_reloader_arguments() 14 | 15 | self.parser.assert_called_once_with() 16 | 17 | def test_parse_nginx_config_reloader_arguments_adds_options(self): 18 | parse_nginx_config_reloader_arguments() 19 | 20 | expected_calls = [ 21 | call( 22 | "--monitor", 23 | "-m", 24 | action="store_true", 25 | help="Monitor files on foreground with output", 26 | ), 27 | call( 28 | "--nomagentoconfig", 29 | action="store_true", 30 | help="Disable Magento configuration", 31 | default=False, 32 | ), 33 | call( 34 | "--nocustomconfig", 35 | action="store_true", 36 | help="Disable copying custom configuration", 37 | default=False, 38 | ), 39 | call( 40 | "--watchdir", 41 | "-w", 42 | help="Set directory to watch", 43 | default=nginx_config_reloader.DIR_TO_WATCH, 44 | ), 45 | call( 46 | "--recursivewatch", 47 | action="store_true", 48 | help="Enable recursive watching of subdirectories", 49 | default=False, 50 | ), 51 | call( 52 | "--use-systemd", 53 | action="store_true", 54 | help="Reload nginx using systemd instead of process signal", 55 | default=False, 56 | ), 57 | call( 58 | "--no-dbus", 59 | action="store_true", 60 | help="Disable DBus interface", 61 | default=False, 62 | ), 63 | ] 64 | self.assertEqual( 65 | self.parser.return_value.add_argument.mock_calls, expected_calls 66 | ) 67 | 68 | def test_parse_nginx_config_reloader_arguments_returns_parsed_arguments(self): 69 | ret = parse_nginx_config_reloader_arguments() 70 | 71 | self.assertEqual(ret, self.parser.return_value.parse_args.return_value) 72 | -------------------------------------------------------------------------------- /tests/test_directory_is_unmounted.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from nginx_config_reloader import directory_is_unmounted 4 | from tests.testcase import TestCase 5 | 6 | 7 | class TestDirectoryIsUnmounted(TestCase): 8 | def setUp(self): 9 | self.check_output = self.set_up_patch( 10 | "nginx_config_reloader.utils.subprocess.check_output", 11 | return_value=json.dumps( 12 | [ 13 | { 14 | "unit": "-.mount", 15 | "load": "loaded", 16 | "active": "active", 17 | "sub": "mounted", 18 | "description": "Root Mount", 19 | }, 20 | { 21 | "unit": "data-web-nginx.mount", 22 | "load": "loaded", 23 | "active": "active", 24 | "sub": "mounted", 25 | "description": "/data/web/nginx", 26 | }, 27 | ] 28 | ), 29 | ) 30 | 31 | def test_it_calls_systemctl_list_units(self): 32 | directory_is_unmounted("/data/web/nginx") 33 | 34 | self.check_output.assert_called_once_with( 35 | ["systemctl", "list-units", "-t", "mount", "--all", "-o", "json"], 36 | encoding="utf-8", 37 | ) 38 | 39 | def test_it_returns_false_if_no_mount_found(self): 40 | self.check_output.return_value = json.dumps( 41 | [ 42 | { 43 | "unit": "-.mount", 44 | "load": "loaded", 45 | "active": "active", 46 | "sub": "mounted", 47 | "description": "Root Mount", 48 | }, 49 | ] 50 | ) 51 | 52 | self.assertFalse(directory_is_unmounted("/data/web/nginx")) 53 | 54 | def test_it_returns_false_if_mount_exists_active_mounted(self): 55 | self.assertFalse(directory_is_unmounted("/data/web/nginx")) 56 | 57 | def test_it_returns_true_if_mount_exists_not_active(self): 58 | self.check_output.return_value = json.dumps( 59 | [ 60 | { 61 | "unit": "-.mount", 62 | "load": "loaded", 63 | "active": "active", 64 | "sub": "mounted", 65 | "description": "Root Mount", 66 | }, 67 | { 68 | "unit": "data-web-nginx.mount", 69 | "load": "loaded", 70 | "active": "inactive", 71 | "sub": "dead", 72 | "description": "/data/web/nginx", 73 | }, 74 | ] 75 | ) 76 | 77 | self.assertTrue(directory_is_unmounted("/data/web/nginx")) 78 | 79 | def test_it_returns_true_if_mount_exists_active_not_mounted(self): 80 | self.check_output.return_value = json.dumps( 81 | [ 82 | { 83 | "unit": "-.mount", 84 | "load": "loaded", 85 | "active": "active", 86 | "sub": "mounted", 87 | "description": "Root Mount", 88 | }, 89 | { 90 | "unit": "data-web-nginx.mount", 91 | "load": "loaded", 92 | "active": "active", 93 | "sub": "dead", 94 | "description": "/data/web/nginx", 95 | }, 96 | ] 97 | ) 98 | 99 | self.assertTrue(directory_is_unmounted("/data/web/nginx")) 100 | -------------------------------------------------------------------------------- /debian/nginx-config-reloader.init: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: nginx_config_reloader 4 | # Required-Start: $network $local_fs 5 | # Required-Stop: 6 | # Default-Start: 2 3 4 5 7 | # Default-Stop: 0 1 6 8 | # Short-Description: nginx config reloader 9 | # Description: Daemon that detects, checks and installs user 10 | # provided nginx configuration files 11 | ### END INIT INFO 12 | 13 | # Author: Maarten van Schaik 14 | 15 | PATH=/sbin:/usr/sbin:/bin:/usr/bin 16 | DESC="nginx config reloader" 17 | NAME=nginx_config_reloader 18 | DAEMON=/usr/bin/nginx_config_reloader 19 | DAEMON_ARGS="-d" 20 | PIDFILE=/var/run/$NAME.pid 21 | SCRIPTNAME=/etc/init.d/$NAME 22 | 23 | # Exit if the package is not installed 24 | [ -x $DAEMON ] || exit 0 25 | 26 | # Read configuration variable file if it is present 27 | [ -r /etc/default/$NAME ] && . /etc/default/$NAME 28 | 29 | # Load the VERBOSE setting and other rcS variables 30 | . /lib/init/vars.sh 31 | 32 | # Define LSB log_* functions. 33 | # Depend on lsb-base (>= 3.0-6) to ensure that this file is present. 34 | . /lib/lsb/init-functions 35 | 36 | # 37 | # Function that starts the daemon/service 38 | # 39 | do_start() 40 | { 41 | # Return 42 | # 0 if daemon has been started 43 | # 1 if daemon was already running 44 | # 2 if daemon could not be started 45 | start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ 46 | || return 1 47 | start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \ 48 | $DAEMON_ARGS \ 49 | || return 2 50 | } 51 | 52 | # 53 | # Function that stops the daemon/service 54 | # 55 | do_stop() 56 | { 57 | # Return 58 | # 0 if daemon has been stopped 59 | # 1 if daemon was already stopped 60 | # 2 if daemon could not be stopped 61 | # other if a failure occurred 62 | start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE 63 | RETVAL="$?" 64 | [ "$RETVAL" = 2 ] && return 2 65 | # Wait for children to finish too if this is a daemon that forks 66 | # and if the daemon is only ever run from this initscript. 67 | # If the above conditions are not satisfied then add some other code 68 | # that waits for the process to drop all resources that could be 69 | # needed by services started subsequently. A last resort is to 70 | # sleep for some time. 71 | start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON 72 | [ "$?" = 2 ] && return 2 73 | # Many daemons don't delete their pidfiles when they exit. 74 | rm -f $PIDFILE 75 | return "$RETVAL" 76 | } 77 | 78 | 79 | case "$1" in 80 | start) 81 | [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC " "$NAME" 82 | do_start 83 | case "$?" in 84 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 85 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 86 | esac 87 | ;; 88 | stop) 89 | [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" 90 | do_stop 91 | case "$?" in 92 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 93 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 94 | esac 95 | ;; 96 | status) 97 | status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? 98 | ;; 99 | restart|force-reload) 100 | log_daemon_msg "Restarting $DESC" "$NAME" 101 | do_stop 102 | case "$?" in 103 | 0|1) 104 | do_start 105 | case "$?" in 106 | 0) log_end_msg 0 ;; 107 | 1) log_end_msg 1 ;; # Old process is still running 108 | *) log_end_msg 1 ;; # Failed to start 109 | esac 110 | ;; 111 | *) 112 | # Failed to stop 113 | log_end_msg 1 114 | ;; 115 | esac 116 | ;; 117 | *) 118 | echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 119 | exit 3 120 | ;; 121 | esac 122 | 123 | : 124 | -------------------------------------------------------------------------------- /tests/test_inotify_callbacks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import unittest 4 | from tempfile import NamedTemporaryFile, mkdtemp 5 | 6 | import mock 7 | import pyinotify 8 | 9 | import nginx_config_reloader 10 | 11 | 12 | class TestInotifyCallbacks(unittest.TestCase): 13 | def setUp(self): 14 | patcher = mock.patch("nginx_config_reloader.NginxConfigReloader.handle_event") 15 | self.addCleanup(patcher.stop) 16 | self.handle_event = patcher.start() 17 | 18 | self.dir = mkdtemp() 19 | with open(os.path.join(self.dir, "existing_file"), "w") as f: 20 | f.write("blablabla") 21 | 22 | wm = pyinotify.WatchManager() 23 | handler = nginx_config_reloader.NginxConfigReloader() 24 | self.notifier = pyinotify.Notifier(wm, default_proc_fun=handler) 25 | wm.add_watch(self.dir, pyinotify.ALL_EVENTS) 26 | 27 | def tearDown(self): 28 | self.notifier.stop() 29 | shutil.rmtree(self.dir, ignore_errors=True) 30 | 31 | def _process_events(self): 32 | while self.notifier.check_events(0): 33 | self.notifier.read_events() 34 | self.notifier.process_events() 35 | 36 | def test_that_handle_event_is_called_when_new_file_is_created(self): 37 | with open(os.path.join(self.dir, "testfile"), "w") as f: 38 | f.write("blablabla") 39 | 40 | self._process_events() 41 | 42 | self.assertEqual(len(self.handle_event.mock_calls), 1) 43 | 44 | def test_that_handle_event_is_called_when_new_dir_is_created(self): 45 | mkdtemp(dir=self.dir) 46 | self._process_events() 47 | 48 | self.assertEqual(len(self.handle_event.mock_calls), 1) 49 | 50 | def test_that_handle_event_is_called_when_a_file_is_removed(self): 51 | os.remove(os.path.join(self.dir, "existing_file")) 52 | 53 | self._process_events() 54 | 55 | self.assertEqual(len(self.handle_event.mock_calls), 1) 56 | 57 | def test_that_handle_event_is_called_when_a_file_is_moved_in(self): 58 | with NamedTemporaryFile(delete=False) as f: 59 | os.rename(f.name, os.path.join(self.dir, "newfile")) 60 | 61 | self._process_events() 62 | 63 | self.assertEqual(len(self.handle_event.mock_calls), 1) 64 | 65 | def test_that_handle_event_is_called_when_a_file_is_moved_out(self): 66 | destdir = mkdtemp() 67 | os.rename( 68 | os.path.join(self.dir, "existing_file"), 69 | os.path.join(destdir, "existing_file"), 70 | ) 71 | 72 | self._process_events() 73 | 74 | self.assertEqual(len(self.handle_event.mock_calls), 1) 75 | 76 | shutil.rmtree(destdir) 77 | 78 | def test_that_handle_event_is_called_when_a_file_is_renamed(self): 79 | os.rename( 80 | os.path.join(self.dir, "existing_file"), os.path.join(self.dir, "new_name") 81 | ) 82 | 83 | self._process_events() 84 | 85 | self.assertGreaterEqual(len(self.handle_event.mock_calls), 1) 86 | 87 | def test_that_listen_target_terminated_is_raised_if_dir_is_renamed(self): 88 | destdir = mkdtemp() 89 | os.rename(self.dir, destdir) 90 | 91 | with self.assertRaises(nginx_config_reloader.ListenTargetTerminated): 92 | self._process_events() 93 | 94 | shutil.rmtree(destdir) 95 | 96 | def test_that_listen_target_terminated_is_not_raised_if_dir_is_removed(self): 97 | shutil.rmtree(self.dir) 98 | 99 | self._process_events() 100 | 101 | 102 | class TestInotifyRecursiveCallbacks(TestInotifyCallbacks): 103 | # Run all callback tests on a subdir 104 | def setUp(self): 105 | patcher = mock.patch("nginx_config_reloader.NginxConfigReloader.handle_event") 106 | self.addCleanup(patcher.stop) 107 | self.handle_event = patcher.start() 108 | 109 | self.rootdir = mkdtemp() 110 | self.dir = mkdtemp(dir=self.rootdir) 111 | with open(os.path.join(self.dir, "existing_file"), "w") as f: 112 | f.write("blablabla") 113 | 114 | wm = pyinotify.WatchManager() 115 | handler = nginx_config_reloader.NginxConfigReloader() 116 | self.notifier = pyinotify.Notifier(wm, default_proc_fun=handler) 117 | wm.add_watch(self.rootdir, pyinotify.ALL_EVENTS, rec=True) 118 | 119 | def tearDown(self): 120 | self.notifier.stop() 121 | shutil.rmtree(self.rootdir, ignore_errors=True) 122 | -------------------------------------------------------------------------------- /mark-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | read -r -d '' USAGE << EOUSAGE 4 | Mark a release for project, ensure commits in master are in debian changelog. 5 | If there are changes to release, update version and debian changelog, 6 | also can tag and push the release. 7 | If all commits are in changelog, won't change anything. 8 | 9 | The last line of output indicates if a release is marked or not. 10 | 11 | usage: $0 [options] 12 | 13 | options: 14 | -a automated mode (commit, tag, push) 15 | -c commit the changes for marking the release 16 | -h show this help and exit 17 | -p git push HEAD and tags 18 | -t tag the commit 19 | -v verbose output 20 | 21 | environment: 22 | AUTHOR commit author (name ) format 23 | DEBFULLNAME changelog writer name 24 | DEBEMAIL changelog writer email 25 | MESSAGE commit message 26 | EOUSAGE 27 | 28 | # ==== opts ==== 29 | COMMIT=false 30 | PUSH=false 31 | TAG=false 32 | VERBOSE=false 33 | 34 | while getopts 'achptv' flag; do 35 | case "$flag" in 36 | a) 37 | COMMIT=true 38 | TAG=true 39 | PUSH=true 40 | ;; 41 | c) 42 | COMMIT=true 43 | ;; 44 | h) 45 | echo "$USAGE" 46 | exit 47 | ;; 48 | p) 49 | PUSH=true 50 | ;; 51 | t) 52 | TAG=true 53 | ;; 54 | v) 55 | VERBOSE=true 56 | ;; 57 | esac 58 | done 59 | 60 | 61 | set -o errexit 62 | 63 | ROOT_DIR=$(realpath $(dirname $0)) 64 | CHANGELOG="$ROOT_DIR/debian/changelog" 65 | VERSION=$(date "+%Y%m%d.%H%M%S") 66 | LOG_USER="${SUDO_USER:-$USER}" 67 | 68 | # === options from env ==== 69 | 70 | # committer info 71 | AUTHOR="${AUTHOR:-}" 72 | MESSAGE="${MESSAGE}" 73 | if [[ -z "$MESSAGE" ]]; then 74 | MESSAGE="[automated] Marking Release $VERSION" 75 | fi 76 | 77 | # changelog writer info 78 | export DEBFULLNAME="${DEBFULLNAME:-Hypernode team}" 79 | export DEBEMAIL="${DEBMAIL:-hypernode@byte.nl}" 80 | 81 | 82 | log() { 83 | logger --tag 'nginx_config_reloader-mark-release' "[$$ @$LOG_USER] $1" 84 | if $VERBOSE; then 85 | echo "$1" 86 | fi 87 | } 88 | 89 | # deb_changelog_updated: returns 0 if there are changes to be released or 1 otherwise 90 | deb_changelog_updated() { 91 | log "checking if debian changelog needs update" 92 | # use gbp dch itself to see if there are any changes detected. 93 | # this way the logic of detecting changes is more consistent with 94 | # the rest of the system. 95 | gbp dch --debian-tag="%(version)s" --debian-branch=master 96 | # find changelog diff to see if there new commits in changelog. 97 | # commits are usually added like this to the changelog: 98 | # [ author ] 99 | # * commit message 100 | # first look for commit messages, if nothing found, then see if there is an empty author section, 101 | # as a fallback. 102 | local changed=$(git diff --text --ignore-all-space --unified=0 --no-color $CHANGELOG | grep --line-regexp --perl-regexp '\+\s{2,}\* .+') 103 | if [[ -z "$changed" ]]; then 104 | changed=$(git diff --text --ignore-all-space --unified=0 --no-color $CHANGELOG | grep --line-regexp --perl-regexp '\+\\s{2,}\[.+\].*') 105 | fi 106 | log "reverting possible changes in debian changelog during update detection" 107 | git checkout -- $CHANGELOG 108 | if [[ -n "$changed" ]]; then 109 | return 0 110 | fi 111 | return 1 112 | } 113 | 114 | mark_release() { 115 | log "marking release $VERSION ..." 116 | 117 | local setuppy="$ROOT_DIR/setup.py" 118 | log "updating setup.py" 119 | sed -i "s/version=\".*\",/version=\"$VERSION\",/" $setuppy 120 | git add $setuppy 121 | log "generating debian changelog" 122 | gbp dch --debian-tag="%(version)s" --new-version=$VERSION --debian-branch=master 123 | git add $CHANGELOG 124 | } 125 | 126 | commit() { 127 | log "comitting the release changes" 128 | if [[ -z "$AUTHOR" ]]; then 129 | git commit --no-edit --message="$MESSAGE" 130 | else 131 | git commit --no-edit --message="$MESSAGE" --author "$AUTHOR" 132 | fi 133 | } 134 | 135 | 136 | if ! deb_changelog_updated; then 137 | log "detected no change, skipping marking release!" 138 | echo 'no change' 139 | exit 140 | fi 141 | 142 | mark_release 143 | 144 | if $COMMIT; then 145 | commit 146 | if $TAG; then 147 | log "creating git tag '$VERSION' ..." 148 | git tag $VERSION 149 | fi 150 | 151 | if $PUSH; then 152 | log "pushing changes to origin ..." 153 | git push origin HEAD 154 | if $TAG; then 155 | git push origin $VERSION 156 | fi 157 | fi 158 | else 159 | log "not committing, skipping tagging and pushing!" 160 | fi 161 | 162 | echo "marked release $VERSION" 163 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from tempfile import mkdtemp 3 | 4 | from mock import Mock 5 | 6 | from nginx_config_reloader import main 7 | from tests.testcase import TestCase 8 | 9 | 10 | class TestMain(TestCase): 11 | def setUp(self): 12 | self.source = mkdtemp() 13 | self.parse_nginx_config_reloader_arguments = self.set_up_patch( 14 | "nginx_config_reloader.parse_nginx_config_reloader_arguments" 15 | ) 16 | self.parse_nginx_config_reloader_arguments.return_value = Mock( 17 | monitor=False, 18 | allow_includes=False, 19 | nomagentoconfig=False, 20 | nocustomconfig=False, 21 | watchdir=self.source, 22 | recursivewatch=False, 23 | use_systemd=False, 24 | no_dbus=False, 25 | ) 26 | self.get_logger = self.set_up_context_manager_patch( 27 | "nginx_config_reloader.get_logger" 28 | ) 29 | self.wait_loop = self.set_up_context_manager_patch( 30 | "nginx_config_reloader.wait_loop" 31 | ) 32 | self.reloader = self.set_up_context_manager_patch( 33 | "nginx_config_reloader.NginxConfigReloader" 34 | ) 35 | 36 | def tearDown(self): 37 | shutil.rmtree(self.source, ignore_errors=True) 38 | 39 | def test_main_gets_logger(self): 40 | main() 41 | 42 | self.get_logger.assert_called_once_with() 43 | 44 | def test_main_parses_nginx_config_reloader_arguments(self): 45 | main() 46 | 47 | self.parse_nginx_config_reloader_arguments.assert_called_once_with() 48 | 49 | def test_main_reloads_config_once_if_monitor_mode_not_specified(self): 50 | main() 51 | 52 | self.reloader.assert_called_once_with( 53 | logger=self.get_logger.return_value, 54 | no_magento_config=self.parse_nginx_config_reloader_arguments.return_value.nomagentoconfig, 55 | no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig, 56 | dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir, 57 | use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd, 58 | ) 59 | self.reloader.return_value.apply_new_config.assert_called_once_with() 60 | 61 | def test_main_does_not_watch_the_config_dir_if_monitor_mode_not_specified(self): 62 | main() 63 | 64 | self.assertFalse(self.wait_loop.called) 65 | 66 | def test_main_returns_zero_if_no_errors_after_reloading_once(self): 67 | ret = main() 68 | 69 | self.assertEqual(0, ret) 70 | 71 | def test_main_watches_the_config_dir_if_monitor_specified(self): 72 | self.parse_nginx_config_reloader_arguments.return_value.monitor = True 73 | 74 | main() 75 | 76 | self.wait_loop.assert_called_once_with( 77 | logger=self.get_logger.return_value, 78 | no_magento_config=self.parse_nginx_config_reloader_arguments.return_value.nomagentoconfig, 79 | no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig, 80 | dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir, 81 | recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch, 82 | use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd, 83 | no_dbus=self.parse_nginx_config_reloader_arguments.return_value.no_dbus, 84 | ) 85 | 86 | def test_main_watches_the_config_dir_if_monitor_mode_is_specified_and_includes_allowed( 87 | self, 88 | ): 89 | self.parse_nginx_config_reloader_arguments.return_value.allow_includes = True 90 | self.parse_nginx_config_reloader_arguments.return_value.monitor = True 91 | 92 | main() 93 | 94 | self.wait_loop.assert_called_once_with( 95 | logger=self.get_logger.return_value, 96 | no_magento_config=self.parse_nginx_config_reloader_arguments.return_value.nomagentoconfig, 97 | no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig, 98 | dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir, 99 | recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch, 100 | use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd, 101 | no_dbus=self.parse_nginx_config_reloader_arguments.return_value.no_dbus, 102 | ) 103 | 104 | def test_main_does_not_reload_the_config_once_if_monitor_mode_is_specified(self): 105 | self.parse_nginx_config_reloader_arguments.return_value.monitor = True 106 | 107 | main() 108 | 109 | self.assertFalse(self.reloader.called) 110 | 111 | def test_main_returns_nonzero_if_monitor_mode_and_loop_returns(self): 112 | self.parse_nginx_config_reloader_arguments.return_value.monitor = True 113 | 114 | ret = main() 115 | 116 | self.assertEqual(1, ret) 117 | 118 | def test_main_passes_no_dbus_to_wait_loop(self): 119 | self.parse_nginx_config_reloader_arguments.return_value.no_dbus = True 120 | self.parse_nginx_config_reloader_arguments.return_value.monitor = True 121 | 122 | main() 123 | 124 | self.wait_loop.assert_called_once_with( 125 | logger=self.get_logger.return_value, 126 | no_magento_config=self.parse_nginx_config_reloader_arguments.return_value.nomagentoconfig, 127 | no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig, 128 | dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir, 129 | recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch, 130 | use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd, 131 | no_dbus=True, 132 | ) 133 | -------------------------------------------------------------------------------- /tests/test_assert_forbidden_statements_in_config.py: -------------------------------------------------------------------------------- 1 | import pipes 2 | from subprocess import CalledProcessError, check_output 3 | 4 | from nginx_config_reloader import FORBIDDEN_CONFIG_REGEX, NginxConfigReloader 5 | from tests.testcase import TestCase 6 | 7 | 8 | class TestAssertNoForbiddenStatementsInConfig(TestCase): 9 | def setUp(self): 10 | self.isdir = self.set_up_patch("nginx_config_reloader.os.path.isdir") 11 | self.isdir.return_value = True 12 | self.check_output = self.set_up_patch( 13 | "nginx_config_reloader.subprocess.check_output" 14 | ) 15 | 16 | def test_assert_no_includes_in_config_does_not_check_config_if_no_dir_to_watch( 17 | self, 18 | ): 19 | self.isdir.return_value = False 20 | 21 | NginxConfigReloader.check_no_forbidden_config_directives_are_present( 22 | NginxConfigReloader() 23 | ) 24 | 25 | self.assertFalse(self.check_output.called) 26 | 27 | def test_include_prevention_legal_includes(self): 28 | TEST_CASES = [ 29 | "include /etc/nginx/fastcgi_params", 30 | 'include "/etc/nginx/php-handler.conf";', 31 | "include '/etc/nginx/php-handler.conf';", 32 | "include ' /etc/nginx/php-handler.conf';", 33 | "include /etc/nginx/fastcgi_params", 34 | "include handler.conf", 35 | "include relative_file.conf", 36 | "include /etc/nginx/app/server.*;", 37 | "include /etc/nginx//fastcgi_params", 38 | "include /etc//nginx/fastcgi_params", 39 | ] 40 | 41 | for line in TEST_CASES: 42 | check_output( 43 | "[ $(echo {} | grep -P '{}' | wc -l) -lt 1 ]".format( 44 | pipes.quote(line), FORBIDDEN_CONFIG_REGEX[2][0] 45 | ), 46 | shell=True, 47 | ) 48 | 49 | def test_include_prevention_illegal_includes(self): 50 | TEST_CASES = [ 51 | "include /data/web/nginx/someexample.allow;", 52 | "include /etc/nginx/../../data/web/banaan.config", 53 | 'include "/etc/nginx/../../data/web/banaan.config"', 54 | "include '/etc/nginx/../data/web/banaan.config'", 55 | "include /data/web/banaan.config", 56 | "include somedir/../../../../danger", 57 | "include '/data/web/banaan.config'", 58 | 'include "/data/web/banaan.config"', 59 | "include /etc/nginx/app_bak/server.*;", 60 | 'include "/data//web/banaan.config"', 61 | 'include "//data/web/banaan.config"', 62 | 'include "/data/web//banaan.config"', 63 | 'include " /data/web//banaan.config"', 64 | ] 65 | 66 | for line in TEST_CASES: 67 | with self.assertRaises(CalledProcessError): 68 | check_output( 69 | "[ $(echo {} | grep -P '{}' | wc -l) -lt 1 ]".format( 70 | pipes.quote(line), FORBIDDEN_CONFIG_REGEX[2][0] 71 | ), 72 | shell=True, 73 | ) 74 | 75 | def test_forbidden_config_client_body_temp_path_regex_unhappy_case(self): 76 | TEST_CASES = [ 77 | "client_body_temp_path /tmp/path", 78 | " client_body_temp_path /tmp/path", 79 | "client_body_temp_path '/tmp/path'", 80 | 'client_body_temp_path "/tmp/path"', 81 | "client_body_temp_path ' /tmp/path'", 82 | ] 83 | 84 | for test in TEST_CASES: 85 | with self.assertRaises(CalledProcessError): 86 | check_output( 87 | "[ $(echo {} | grep -P '{}' | wc -l) -lt 1 ]".format( 88 | pipes.quote(test), FORBIDDEN_CONFIG_REGEX[0][0] 89 | ), 90 | shell=True, 91 | ) 92 | 93 | def test_forbidden_access_or_error_log_configuration_options(self): 94 | TEST_CASES = [ 95 | "access_log /var/log/nginx/acceptatie.log;", 96 | " error_log /var/log/nginx/acceptatie.error.log info;", 97 | " access_log //var//log/nginx/staging.log hypernode;", 98 | "access_log /var/log/../../staging.log hypernode;", 99 | " access_log /data/var/log/../../../access.log;", 100 | "access_log /tmp/staging.log;", 101 | "access_log output.log;", # would be placed in /usr/share/nginx/output.log 102 | "access_log '/var/log/nginx/acceptatie.log;'", 103 | ' error_log "/var/log/nginx/acceptatie.error.log info;"', 104 | "access_log '/tmp/staging.log ';", 105 | 'access_log "output.log";', # would be placed in /usr/share/nginx/output.log 106 | 'access_log "/usr/output.log";', 107 | "access_log '/tmp/staging.log ';", 108 | "access_log ' /tmp/staging.log';", 109 | "access_log ../../../some.log;", 110 | 'access_log "../some.log";', 111 | "access_log syslog:server=unix:/run/systemd/journal/stdout;", 112 | "error_log syslog:server=unix:/run/systemd/journal/stdout;", 113 | ] 114 | 115 | for test in TEST_CASES: 116 | with self.assertRaises(CalledProcessError): 117 | check_output( 118 | "[ $(echo {} | grep -P '{}' | wc -l) -lt 1 ]".format( 119 | pipes.quote(test), FORBIDDEN_CONFIG_REGEX[1][0] 120 | ), 121 | shell=True, 122 | ) 123 | 124 | def test_allowed_access_or_error_log_configuration_options(self): 125 | TEST_CASES = [ 126 | "access_log /data/var/log/access.log;", 127 | " error_log /data/var/log/access.log;", 128 | " access_log //data//var//log//access.log;", 129 | "access_log '/data/var/log/access.log';", 130 | ' error_log "/data/var/log/access.log;"', 131 | ' access_log "//data//var//log//access.log;"', 132 | "#access_log /tmp/log.log;", 133 | "access_log syslog:server=log.erikhyperdev.nl:2110 octologs_json;", 134 | "access_log syslog:server=[2001:db8::1]:12345,facility=local7,tag=nginx,severity=info combined;", 135 | ] 136 | 137 | for test in TEST_CASES: 138 | check_output( 139 | "[ $(echo {} | grep -P '{}' | wc -l) -lt 1 ]".format( 140 | pipes.quote(test), FORBIDDEN_CONFIG_REGEX[1][0] 141 | ), 142 | shell=True, 143 | ) 144 | 145 | def test_forbidden_config_init_by_lua_regex_matches_target_directives(self): 146 | TEST_CASES = ["init_by_lua", "init_by_lua_block", "init_by_lua_file"] 147 | 148 | for test in TEST_CASES: 149 | with self.assertRaises(CalledProcessError): 150 | check_output( 151 | "[ $(echo {} | grep -P '{}' | wc -l) -lt 1 ]".format( 152 | pipes.quote(test), FORBIDDEN_CONFIG_REGEX[3][0] 153 | ), 154 | shell=True, 155 | ) 156 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | nginx-config-reloader (20250117.131018) UNRELEASED; urgency=medium 2 | 3 | [ Rick van de Loo ] 4 | * add dh-python to cow dep 5 | 6 | [ Sander Roosingh ] 7 | * Add mark-release.sh 8 | 9 | [ Timon de Groot ] 10 | * Fix error caused by writing bytes (instead of str) to error file 11 | * Add str to byte conversion 12 | 13 | [ Rick van de Loo ] 14 | * fix mark release detect change 15 | 16 | [ Timon de Groot ] 17 | * Add python 2.7 compatibility check 18 | * Add tox.ini 19 | * Reverse the unicode wrapping 20 | 21 | [ Hypernode team ] 22 | 23 | [ Erik Lamers ] 24 | * Allow syslog option in access/error_log config 25 | 26 | [ Alexander Grooff ] 27 | * Add integration tests for files being placed 28 | * Add permission tests 29 | * Use existing integration test 30 | * Copy files using rsync 31 | * Place rsync error in error output file 32 | * Don't run rsync as unpriv 33 | * Separate settings, copy_files for easier mocking 34 | * Mock function instead of check_output 35 | * Python 2.7 compatibility 36 | * Ensure trailing slash with os.path.join 37 | 38 | [ Rick van de Loo ] 39 | * coalesce-events-in-nginx-config-reloader 40 | * [WIP] proof of concept delayed reload 41 | * skip superfluous config reloads 42 | * don't use internal to check queue status 43 | * take into account None notifier in handle_event 44 | 45 | [ Nick de Dycker ] 46 | * Clear inotify eventq before every reload to prevent too many nginx reloads 47 | * Use clear() instead of pop() 48 | 49 | [ Timon de Groot ] 50 | * Add option --use-systemd to reload nginx with systemctl 51 | * Update test procedure to just tox and pytest 52 | * ci: Add Github Actions configuration to run tests 53 | * Fix DeprecationWarning in forbidden config regexes 54 | * Fix directory permissions process always failing 55 | * Fix not listening to sub events after subdir is removed and created 56 | * Ignore symlinks when setting permissions 57 | * Run chmod as unprivileged user 58 | * Apply linting with pre-commit, black, autoflake and isort 59 | 60 | [ Alexander Grooff ] 61 | * feat: reload through pubsub via NATS 62 | * chore: pin nats version 63 | * test: nats server arguments 64 | * fix: enforce apply config instead of reload 65 | * fix: prevent concurrent runs 66 | * feat: reload once for all queued messages 67 | * chore: add NATS init logging 68 | * fix: fix initial applying state 69 | * feat: use nats-python fork 70 | 71 | [ Timon de Groot ] 72 | * Replace NATS with dbus 73 | * dbus: Fix reload command 74 | * debian: Remove dh-systemd build dep 75 | * reloader: Do not apply config if target dir is unmounted 76 | * debian/service: Start after remote-fs.target 77 | 78 | [ Jonathan ] 79 | * Fix CI to support py 3.7 again (#63) 80 | * Add permission error when user cannot write to /etc/nginx folder (#62) 81 | 82 | -- Hypernode team Fri, 17 Jan 2025 13:10:18 +0000 83 | 84 | nginx-config-reloader (20191127.113351) xenial; urgency=medium 85 | 86 | [ Owen Ashby ] 87 | * started porting to python3 88 | * fixed failing test and pep8 violations 89 | * changed python version to 3.5 90 | 91 | [ Roas ] 92 | * Unittests pass on py2 and py3 93 | * Update version in setup.py to 20191126.145143 94 | * Change build config to python3 95 | * Run tests for both py2 and py3 96 | * Try to specify buildsystem 97 | * Changes debian rules 98 | 99 | [ Rick van de Loo ] 100 | * Update version in setup.py to 20191127.113351 101 | 102 | -- Rick van de Loo Wed, 27 Nov 2019 11:33:53 +0100 103 | 104 | nginx-config-reloader (20190731.120623) xenial; urgency=medium 105 | 106 | [ Alexander Grooff ] 107 | * Automatically add watch on subdirs 108 | * Update version in setup.py to 20190731.120623 109 | 110 | -- alex Wed, 31 Jul 2019 12:06:24 +0200 111 | 112 | nginx-config-reloader (20190717.144331) xenial; urgency=medium 113 | 114 | [ Alexander Grooff ] 115 | * Process event on dir created 116 | 117 | [ Rick van de Loo ] 118 | * Update version in setup.py to 20190717.144331 119 | 120 | -- Rick van de Loo Wed, 17 Jul 2019 14:43:32 +0200 121 | 122 | nginx-config-reloader (20190716.180209) xenial; urgency=medium 123 | 124 | [ Alexander Grooff ] 125 | * Don't put recursive_watch into the reloader 126 | 127 | [ Rick van de Loo ] 128 | * Update version in setup.py to 20190716.180209 129 | 130 | -- Rick van de Loo Tue, 16 Jul 2019 18:02:09 +0200 131 | 132 | nginx-config-reloader (20190716.162928) xenial; urgency=medium 133 | 134 | [ Alexander Grooff ] 135 | * Watch the directory recursively 136 | * Add option to toggle recursive watch 137 | * Default behaviour is not recursive watch 138 | 139 | [ Rick van de Loo ] 140 | * Update version in setup.py to 20190716.162921 141 | * Update version in setup.py to 20190716.162928 142 | 143 | -- Rick van de Loo Tue, 16 Jul 2019 16:29:29 +0200 144 | 145 | nginx-config-reloader (20190204.093745) xenial; urgency=medium 146 | 147 | * Update version in setup.py to 20190204.093745 148 | 149 | -- Farzad Ghanei Mon, 04 Feb 2019 09:37:46 +0100 150 | 151 | nginx-config-reloader (20190201.143828) xenial; urgency=medium 152 | 153 | [ Farzad Ghanei ] 154 | * Exclude ERROR_FILE when checking for forbidden config pattenrs 155 | * config reloader always removes error file before installing configs 156 | * Git ignore ctags file 157 | 158 | [ Rick van de Loo ] 159 | * Update version in setup.py to 20190201.143828 160 | 161 | -- Rick van de Loo Fri, 01 Feb 2019 14:38:29 +0100 162 | 163 | nginx-config-reloader (20181224.111316) xenial; urgency=medium 164 | 165 | [ Timon de Groot ] 166 | * Add *.save to WATCH_IGNORE_FILES 167 | 168 | [ Rick van de Loo ] 169 | * Update version in setup.py to 20181224.111310 170 | * Update version in setup.py to 20181224.111316 171 | 172 | -- Rick van de Loo Mon, 24 Dec 2018 11:13:16 +0100 173 | 174 | nginx-config-reloader (20181211.152109) xenial; urgency=medium 175 | 176 | * Add _build_local to build Debian package in development env 177 | * Add build.sh script to build the Debian package easier 178 | * Update README.md with build instructions using the build scripts 179 | * Allow setting DIST/ARCH via env variables in build scripts 180 | * Disallow using init_by_lua* directives 181 | * Update version in setup.py to 20181211.152109 182 | 183 | -- Farzad Ghanei Tue, 11 Dec 2018 15:21:10 +0100 184 | 185 | nginx-config-reloader (20180628.161335) xenial; urgency=medium 186 | 187 | * fix permissions custom config dir 188 | * drop to nonprivileged user to perform chmod 189 | 190 | -- Rick van de Loo Thu, 28 Jun 2018 16:13:39 +0200 191 | 192 | nginx-config-reloader (20180221.170426) xenial; urgency=medium 193 | 194 | [ Andreas Lutro ] 195 | * add a second watch to facilitate symlinking the nginx directory 196 | 197 | -- Rick van de Loo Wed, 21 Feb 2018 17:05:19 +0100 198 | 199 | nginx-config-reloader (20171018.120533) xenial; urgency=medium 200 | 201 | [ Timon de Groot ] 202 | * Add argument --nomagentoconfig 203 | * Add argument --nocustomconfig 204 | * Add argument --watchdir 205 | * Integrate new arguments into code instead of hacking variable modification 206 | * Update tests for new functionality 207 | * Add tests for disabling Magento/custom configuration 208 | * Add tests for default Magento/custom configuration behavior 209 | 210 | [ Rick van de Loo ] 211 | * add travis file 212 | * remove unused constants 213 | * remove test for removed flag 214 | 215 | -- Rick van de Loo Wed, 18 Oct 2017 12:05:39 +0200 216 | 217 | nginx-config-reloader (20170512.135642) xenial; urgency=medium 218 | 219 | [ Rick van de Loo ] 220 | * put run once mode back in 221 | * always restart nginx_config_reloader 222 | 223 | [ Daniel Genis ] 224 | * disallow usage of client_body_temp_path 225 | * deny placing log files outside of /data/ 226 | * add test for ../../some.log 227 | * rename test file showing it tests all forbidden config params 228 | * use py3 compatible format 229 | * remove allow absolute includes argument, refactor pre loading config assertions 230 | * remove obsolete test statements post merge 231 | 232 | -- Daniel Genis Fri, 12 May 2017 13:57:12 +0200 233 | 234 | nginx-config-reloader (20170208.172203) xenial; urgency=medium 235 | 236 | * Compatibility with Xenial/systemd 237 | * Remove unused code 238 | * Remove unused deps 239 | 240 | -- Willem de Groot Wed, 08 Feb 2017 17:22:19 +0100 241 | 242 | nginx-config-reloader (20161011.103915) xenial; urgency=medium 243 | 244 | [ Rick van de Loo ] 245 | * do not allow includes in the config by default 246 | * allow includes from the system nginx conf dir 247 | 248 | [ Daniel Genis ] 249 | * add regex for matching illegal includes 250 | * improve regex for a more complete case 251 | 252 | [ Rick van de Loo ] 253 | * run ILLEGAL_INCLUDE_REGEX on config dir 254 | 255 | -- Rick van de Loo Tue, 11 Oct 2016 10:39:24 +0200 256 | 257 | nginx-config-reloader (20160422.163914) xenial; urgency=medium 258 | 259 | [ Willem de Groot ] 260 | * add upstart script with infinite respawn 261 | * Make nginx config reloader more resilient after oom kills or crashes 262 | 263 | -- Rick van de Loo Fri, 22 Apr 2016 16:39:19 +0200 264 | 265 | nginx-config-reloader (20160412.145423) xenial; urgency=medium 266 | 267 | * update buildpackage instructions 268 | * add upstart script with infinite respawn 269 | 270 | -- Rick van de Loo Tue, 12 Apr 2016 14:57:45 +0200 271 | 272 | nginx-config-reloader (20160108.111855) trusty; urgency=medium 273 | 274 | * Rerelease 275 | 276 | -- Allard Hoeve Fri, 08 Jan 2016 11:18:56 +0100 277 | 278 | nginx-config-reloader (20160106.155202) trusty; urgency=medium 279 | 280 | * Also reload Nginx on flag file changes 281 | 282 | -- Allard Hoeve Wed, 06 Jan 2016 15:52:05 +0100 283 | 284 | nginx-config-reloader (20160106.151345) trusty; urgency=medium 285 | 286 | [ Jeroen van Heugten ] 287 | * Watch Magento1/2 configuration flag 288 | 289 | [ Gertjan Oude Lohuis ] 290 | * Add tests for magento2 flag and configuration 291 | * Atomically create symlink to magento config 292 | * Make sure all tmpfiles are deleted 293 | 294 | -- Allard Hoeve Wed, 06 Jan 2016 15:13:47 +0100 295 | 296 | nginx-config-reloader (20140716.165657) saucy; urgency=low 297 | 298 | [ Maarten ] 299 | * Fix typo in warning message 300 | 301 | [ Allard Hoeve ] 302 | * Remove error logfile when Nginx config correct 303 | 304 | -- Allard Hoeve Wed, 16 Jul 2014 16:56:58 +0200 305 | 306 | nginx-config-reloader (20140218.114728) unstable; urgency=low 307 | 308 | * Handle case where file is moved into dir 309 | 310 | -- Maarten van Schaik Tue, 18 Feb 2014 11:47:32 +0100 311 | 312 | nginx-config-reloader (20140215.194537) unstable; urgency=low 313 | 314 | * Handle files being moved to watch dir 315 | 316 | -- Maarten van Schaik Sat, 15 Feb 2014 19:45:41 +0100 317 | 318 | nginx-config-reloader (20140215.192424) unstable; urgency=low 319 | 320 | * Daemonize before running the loop 321 | 322 | -- Maarten van Schaik Sat, 15 Feb 2014 19:24:29 +0100 323 | 324 | nginx-config-reloader (20140214.172601) unstable; urgency=low 325 | 326 | * Handle removal or nonexistence of config dir 327 | * Handle crash on failed dir copy 328 | 329 | -- Maarten van Schaik Fri, 14 Feb 2014 17:26:05 +0100 330 | 331 | nginx-config-reloader (20140214.154334) unstable; urgency=low 332 | 333 | * Clarify some things after review 334 | * Fix crash when dest dir doesn't exist yet 335 | 336 | -- Maarten van Schaik Fri, 14 Feb 2014 15:43:38 +0100 337 | 338 | nginx-config-reloader (20140214.131840) unstable; urgency=low 339 | 340 | * Add init script 341 | 342 | -- Maarten van Schaik Fri, 14 Feb 2014 13:18:43 +0100 343 | 344 | nginx-config-reloader (20140214.120430) unstable; urgency=low 345 | 346 | * Initial release. 347 | 348 | -- Maarten van Schaik Fri, 14 Feb 2014 12:04:34 +0100 349 | -------------------------------------------------------------------------------- /nginx_config_reloader/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import absolute_import 3 | 4 | import argparse 5 | import fnmatch 6 | import logging 7 | import logging.handlers 8 | import os 9 | import shutil 10 | import signal 11 | import subprocess 12 | import sys 13 | import threading 14 | import time 15 | from typing import Optional 16 | 17 | import pyinotify 18 | from dasbus.loop import EventLoop 19 | from dasbus.signal import Signal 20 | 21 | from nginx_config_reloader.copy_files import safe_copy_files 22 | from nginx_config_reloader.dbus.common import NGINX_CONFIG_RELOADER, SYSTEM_BUS 23 | from nginx_config_reloader.dbus.server import NginxConfigReloaderInterface 24 | from nginx_config_reloader.settings import ( 25 | BACKUP_CONFIG_DIR, 26 | CUSTOM_CONFIG_DIR, 27 | DIR_TO_WATCH, 28 | ERROR_FILE, 29 | FORBIDDEN_CONFIG_REGEX, 30 | MAGENTO1_CONF, 31 | MAGENTO2_CONF, 32 | MAGENTO_CONF, 33 | MAIN_CONFIG_DIR, 34 | NGINX, 35 | NGINX_PID_FILE, 36 | UNPRIVILEGED_GID, 37 | UNPRIVILEGED_UID, 38 | WATCH_IGNORE_FILES, 39 | ) 40 | from nginx_config_reloader.utils import directory_is_unmounted 41 | 42 | logger = logging.getLogger(__name__) 43 | dbus_loop: Optional[EventLoop] = None 44 | 45 | 46 | class NginxConfigReloader(pyinotify.ProcessEvent): 47 | def my_init( 48 | self, 49 | logger=None, 50 | no_magento_config=False, 51 | no_custom_config=False, 52 | dir_to_watch=DIR_TO_WATCH, 53 | magento2_flag=None, 54 | notifier=None, 55 | use_systemd=False, 56 | ): 57 | """Constructor called by ProcessEvent 58 | 59 | :param logging.Logger logger: The logger object 60 | :param bool no_magento_config: True if we should not install Magento configuration 61 | :param bool no_custom_config: True if we should not copy custom configuration 62 | :param str dir_to_watch: The directory to watch 63 | :param str magento2_flag: Magento 2 flag location 64 | """ 65 | if not logger: 66 | self.logger = logging 67 | else: 68 | self.logger = logger 69 | self.no_magento_config = no_magento_config 70 | self.no_custom_config = no_custom_config 71 | self.dir_to_watch = dir_to_watch 72 | if not magento2_flag: 73 | self.magento2_flag = dir_to_watch + "/magento2.flag" 74 | else: 75 | self.magento2_flag = magento2_flag 76 | self.logger.info(self.dir_to_watch) 77 | self.notifier = notifier 78 | self.use_systemd = use_systemd 79 | self.dirty = False 80 | self.applying = False 81 | self._on_config_reload = Signal() 82 | 83 | def process_IN_DELETE(self, event): 84 | """Triggered by inotify on removal of file or removal of dir 85 | 86 | If the dir itself is removed, inotify will stop watching and also 87 | trigger IN_IGNORED. 88 | """ 89 | if not event.dir: # Will also capture IN_DELETE_SELF 90 | self.handle_event(event) 91 | 92 | def process_IN_MOVED(self, event): 93 | """Triggered by inotify when a file is moved from or to the dir""" 94 | self.handle_event(event) 95 | 96 | def process_IN_CREATE(self, event): 97 | """Triggered by inotify when a dir is created in the watch dir""" 98 | if event.dir: 99 | self.handle_event(event) 100 | 101 | def process_IN_CLOSE_WRITE(self, event): 102 | """Triggered by inotify when a file is written in the dir""" 103 | self.handle_event(event) 104 | 105 | def process_IN_MOVE_SELF(self, event): 106 | """Triggered by inotify when watched dir is moved""" 107 | raise ListenTargetTerminated 108 | 109 | def handle_event(self, event): 110 | if not any(fnmatch.fnmatch(event.name, pat) for pat in WATCH_IGNORE_FILES): 111 | self.logger.info("{} detected on {}.".format(event.maskname, event.name)) 112 | self.dirty = True 113 | 114 | def install_magento_config(self): 115 | # Check if configs are present 116 | os.stat(MAGENTO1_CONF) 117 | os.stat(MAGENTO2_CONF) 118 | 119 | # Create new temporary filename for new config 120 | MAGENTO_CONF_NEW = MAGENTO_CONF + "_new" 121 | 122 | # Remove tmp link if it exists (leftover?) 123 | try: 124 | os.unlink(MAGENTO_CONF_NEW) 125 | except OSError: 126 | pass 127 | 128 | # Symlink new config to temporary filename 129 | if os.path.isfile(self.magento2_flag): 130 | os.symlink(MAGENTO2_CONF, MAGENTO_CONF_NEW) 131 | else: 132 | os.symlink(MAGENTO1_CONF, MAGENTO_CONF_NEW) 133 | 134 | # Move temporary symlink to actual location, overwriting existing link or file 135 | os.rename(MAGENTO_CONF_NEW, MAGENTO_CONF) 136 | 137 | def check_can_write_to_main_config_dir(self): 138 | return os.access(MAIN_CONFIG_DIR, os.W_OK) 139 | 140 | def check_no_forbidden_config_directives_are_present(self): 141 | """ 142 | Loop over the :FORBIDDEN_CONFIG_REGEX: to check if nginx config directory contains forbidden configuration 143 | options 144 | :return bool: 145 | True if forbidden config directives are present 146 | False if check couldn't find any forbidden config flags 147 | """ 148 | if os.path.isdir(self.dir_to_watch): 149 | for rules in FORBIDDEN_CONFIG_REGEX: 150 | try: 151 | # error file may contain messages that match a forbidden config pattern 152 | # then validation could fail while the actual config is correct. 153 | # we'll exclude the error file from searching for patterns, 154 | # NOTE: exclusion of error_file requires to ensure the 155 | # file is removed before moving it to nginx conf dir 156 | # @TODO: use Python to search for forbidden configs instead 157 | # of spawning external procs. Will have better testing 158 | # and even may consume less system resources 159 | check_external_resources = ( 160 | "[ $(grep -r --exclude={} -P '{}' '{}' | wc -l) -lt 1 ]".format( 161 | ERROR_FILE, rules[0], self.dir_to_watch 162 | ) 163 | ) 164 | subprocess.check_output(check_external_resources, shell=True) 165 | except subprocess.CalledProcessError: 166 | error = "Unable to load config: {}".format(rules[1]) 167 | self.logger.error(error) 168 | self.write_error_file(error) 169 | return True 170 | return False 171 | 172 | def remove_error_file(self): 173 | """Try removing the error file. Return True on success or False on errors 174 | :rtype: bool 175 | """ 176 | removed = False 177 | try: 178 | os.unlink(os.path.join(self.dir_to_watch, ERROR_FILE)) 179 | removed = True 180 | except OSError: 181 | pass 182 | return removed 183 | 184 | def apply_new_config(self): 185 | # Wrapper function to prevent multiple config applications 186 | if self.applying: 187 | logger.debug(f"A config is already being applied. Skipping this one.") 188 | return False 189 | 190 | self.applying = True 191 | try: 192 | res = self._apply() 193 | except Exception as e: 194 | logger.exception(e) 195 | res = False 196 | self.applying = False 197 | return res 198 | 199 | def _apply(self): 200 | logger.debug("Applying new config") 201 | if self.check_no_forbidden_config_directives_are_present(): 202 | return False 203 | 204 | if not self.check_can_write_to_main_config_dir(): 205 | self.logger.error( 206 | "No write permissions to main nginx config directory, please check your permissions." 207 | ) 208 | return False 209 | 210 | if not self.no_magento_config: 211 | try: 212 | self.install_magento_config() 213 | except OSError: 214 | self.logger.error("Installation of magento config failed") 215 | return False 216 | 217 | if not self.no_custom_config: 218 | try: 219 | self.fix_custom_config_dir_permissions() 220 | self.install_new_custom_config_dir() 221 | except (OSError, subprocess.CalledProcessError) as e: 222 | error_output = str(e) 223 | if hasattr(e, "output"): 224 | extra_output = e.output 225 | if isinstance(e.output, bytes): 226 | extra_output = extra_output.decode() 227 | error_output += "\n\n{}".format(extra_output) 228 | self.logger.error("Installation of custom config failed") 229 | self.restore_old_custom_config_dir() 230 | self.write_error_file(error_output) 231 | return False 232 | 233 | try: 234 | subprocess.check_output([NGINX, "-t"], stderr=subprocess.STDOUT) 235 | except subprocess.CalledProcessError as e: 236 | self.logger.info("Config check failed") 237 | if not self.no_custom_config: 238 | self.restore_old_custom_config_dir() 239 | 240 | if isinstance(e.output, bytes): 241 | self.write_error_file(e.output.decode()) 242 | else: 243 | self.write_error_file(e.output) 244 | 245 | return False 246 | else: 247 | self.remove_error_file() 248 | 249 | self.reload_nginx() 250 | 251 | return True 252 | 253 | def fix_custom_config_dir_permissions(self): 254 | try: 255 | subprocess.check_output( 256 | ["chmod", "755", self.dir_to_watch], 257 | preexec_fn=as_unprivileged_user, 258 | ) 259 | for root, dirs, _ in os.walk(self.dir_to_watch): 260 | for name in dirs: 261 | path = os.path.join(root, name) 262 | if os.path.islink(path): 263 | continue 264 | subprocess.check_output( 265 | ["chmod", "755", path], 266 | preexec_fn=as_unprivileged_user, 267 | ) 268 | except subprocess.CalledProcessError: 269 | self.logger.info("Failed fixing permissions on watched directory") 270 | 271 | def install_new_custom_config_dir(self): 272 | self.remove_error_file() 273 | shutil.rmtree(BACKUP_CONFIG_DIR, ignore_errors=True) 274 | if os.path.exists(CUSTOM_CONFIG_DIR): 275 | shutil.move(CUSTOM_CONFIG_DIR, BACKUP_CONFIG_DIR) 276 | os.mkdir(CUSTOM_CONFIG_DIR) 277 | safe_copy_files(self.dir_to_watch, CUSTOM_CONFIG_DIR) 278 | 279 | def restore_old_custom_config_dir(self): 280 | shutil.rmtree(CUSTOM_CONFIG_DIR) 281 | if os.path.exists(BACKUP_CONFIG_DIR): 282 | shutil.move(BACKUP_CONFIG_DIR, CUSTOM_CONFIG_DIR) 283 | 284 | def reload_nginx(self): 285 | if self.use_systemd: 286 | subprocess.check_call(["systemctl", "reload", "nginx"]) 287 | else: 288 | pid = self.get_nginx_pid() 289 | if not pid: 290 | self.logger.warning("Not reloading, nginx not running") 291 | else: 292 | self.logger.info("Reloading nginx config") 293 | os.kill(pid, signal.SIGHUP) 294 | 295 | def get_nginx_pid(self): 296 | try: 297 | with open(NGINX_PID_FILE, "r") as f: 298 | return int(f.read()) 299 | except (IOError, ValueError): 300 | return None 301 | 302 | def write_error_file(self, error): 303 | with open(os.path.join(self.dir_to_watch, ERROR_FILE), "w") as f: 304 | f.write(error) 305 | 306 | @property 307 | def reloaded(self): 308 | """Signal for the reload event.""" 309 | return self._on_config_reload 310 | 311 | def reload(self, send_signal=True): 312 | if directory_is_unmounted(self.dir_to_watch): 313 | self.logger.warning( 314 | f"Directory {self.dir_to_watch} is unmounted, not reloading!" 315 | ) 316 | return 317 | 318 | self.apply_new_config() 319 | if send_signal: 320 | self._on_config_reload.emit() 321 | 322 | 323 | class ListenTargetTerminated(BaseException): 324 | pass 325 | 326 | 327 | def after_loop(nginx_config_reloader: NginxConfigReloader) -> None: 328 | if nginx_config_reloader.dirty: 329 | try: 330 | nginx_config_reloader.reload() 331 | except: 332 | pass 333 | nginx_config_reloader.dirty = False 334 | nginx_config_reloader.applying = False 335 | 336 | 337 | def dbus_event_loop(): 338 | dbus_loop = EventLoop() 339 | dbus_loop.run() 340 | 341 | 342 | def wait_loop( 343 | logger=None, 344 | no_magento_config=False, 345 | no_custom_config=False, 346 | dir_to_watch=DIR_TO_WATCH, 347 | recursive_watch=False, 348 | use_systemd=False, 349 | no_dbus=False, 350 | ): 351 | """Main event loop 352 | 353 | There is an outer loop that checks the availability of the directory to watch. 354 | As soon as it becomes available, it starts an inotify-monitor that monitors 355 | configuration changes in an inner event loop. When the monitored directory is 356 | renamed or removed, the inotify-handler raises an exception to break out of the 357 | inner loop and we're back here in the outer loop. 358 | 359 | :param logging.Logger logger: The logger object 360 | :param bool no_magento_config: True if we should not install Magento configuration 361 | :param bool no_custom_config: True if we should not copy custom configuration 362 | :param str dir_to_watch: The directory to watch 363 | :param bool recursive_watch: True if we should watch the dir recursively 364 | :param use_systemd: True if we should reload nginx using systemd instead of process signal 365 | :param bool no_dbus: True if we should not use DBus 366 | :return None: 367 | """ 368 | dir_to_watch = os.path.abspath(dir_to_watch) 369 | 370 | wm = pyinotify.WatchManager() 371 | notifier = pyinotify.Notifier(wm) 372 | 373 | class SymlinkChangedHandler(pyinotify.ProcessEvent): 374 | def process_IN_DELETE(self, event): 375 | if event.pathname == dir_to_watch: 376 | raise ListenTargetTerminated("watched directory was deleted") 377 | 378 | nginx_config_changed_handler = NginxConfigReloader( 379 | logger=logger, 380 | no_magento_config=no_magento_config, 381 | no_custom_config=no_custom_config, 382 | dir_to_watch=dir_to_watch, 383 | notifier=notifier, 384 | use_systemd=use_systemd, 385 | ) 386 | 387 | if not no_dbus: 388 | SYSTEM_BUS.publish_object( 389 | NGINX_CONFIG_RELOADER.object_path, 390 | NginxConfigReloaderInterface(nginx_config_changed_handler), 391 | ) 392 | SYSTEM_BUS.register_service(NGINX_CONFIG_RELOADER.service_name) 393 | dbus_thread = threading.Thread(target=dbus_event_loop) 394 | dbus_thread.start() 395 | 396 | while True: 397 | while not os.path.exists(dir_to_watch): 398 | logger.warning( 399 | "Configuration dir {} not found, waiting...".format(dir_to_watch) 400 | ) 401 | time.sleep(5) 402 | 403 | wm.add_watch( 404 | dir_to_watch, 405 | pyinotify.ALL_EVENTS, 406 | nginx_config_changed_handler, 407 | rec=recursive_watch, 408 | auto_add=True, 409 | ) 410 | wm.watch_transient_file( 411 | dir_to_watch, pyinotify.ALL_EVENTS, SymlinkChangedHandler 412 | ) 413 | 414 | # Install initial configuration 415 | nginx_config_changed_handler.reload(send_signal=False) 416 | 417 | try: 418 | logger.info("Listening for changes to {}".format(dir_to_watch)) 419 | notifier.coalesce_events() 420 | notifier.loop(callback=lambda _: after_loop(nginx_config_changed_handler)) 421 | except pyinotify.NotifierError as err: 422 | logger.critical(err) 423 | except ListenTargetTerminated: 424 | logger.warning("Configuration dir lost, waiting for it to reappear") 425 | 426 | 427 | def as_unprivileged_user(): 428 | os.setgid(UNPRIVILEGED_GID) 429 | os.setuid(UNPRIVILEGED_UID) 430 | 431 | 432 | def parse_nginx_config_reloader_arguments(): 433 | parser = argparse.ArgumentParser() 434 | parser.add_argument( 435 | "--monitor", 436 | "-m", 437 | action="store_true", 438 | help="Monitor files on foreground with output", 439 | ) 440 | parser.add_argument( 441 | "--nomagentoconfig", 442 | action="store_true", 443 | help="Disable Magento configuration", 444 | default=False, 445 | ) 446 | parser.add_argument( 447 | "--nocustomconfig", 448 | action="store_true", 449 | help="Disable copying custom configuration", 450 | default=False, 451 | ) 452 | parser.add_argument( 453 | "--watchdir", "-w", help="Set directory to watch", default=DIR_TO_WATCH 454 | ) 455 | parser.add_argument( 456 | "--recursivewatch", 457 | action="store_true", 458 | help="Enable recursive watching of subdirectories", 459 | default=False, 460 | ) 461 | parser.add_argument( 462 | "--use-systemd", 463 | action="store_true", 464 | help="Reload nginx using systemd instead of process signal", 465 | default=False, 466 | ) 467 | parser.add_argument( 468 | "--no-dbus", 469 | action="store_true", 470 | help="Disable DBus interface", 471 | default=False, 472 | ) 473 | return parser.parse_args() 474 | 475 | 476 | def get_logger(): 477 | handler = logging.StreamHandler() 478 | handler.setFormatter( 479 | logging.Formatter("%(asctime)s %(name)-12s %(levelname)-8s %(message)s") 480 | ) 481 | logger.setLevel(logging.DEBUG) 482 | logger.addHandler(handler) 483 | return logger 484 | 485 | 486 | def main(): 487 | args = parse_nginx_config_reloader_arguments() 488 | log = get_logger() 489 | 490 | if args.monitor: 491 | # Track changed files in the nginx config dir and reload on change 492 | wait_loop( 493 | logger=log, 494 | no_magento_config=args.nomagentoconfig, 495 | no_custom_config=args.nocustomconfig, 496 | dir_to_watch=args.watchdir, 497 | recursive_watch=args.recursivewatch, 498 | use_systemd=args.use_systemd, 499 | no_dbus=args.no_dbus, 500 | ) 501 | # should never return 502 | return 1 503 | else: 504 | # Reload the config once 505 | NginxConfigReloader( 506 | logger=log, 507 | no_magento_config=args.nomagentoconfig, 508 | no_custom_config=args.nocustomconfig, 509 | dir_to_watch=args.watchdir, 510 | use_systemd=args.use_systemd, 511 | ).apply_new_config() 512 | return 0 513 | 514 | 515 | if __name__ == "__main__": 516 | sys.exit(main()) 517 | -------------------------------------------------------------------------------- /tests/test_nginx_config_reloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import signal 4 | import stat 5 | import subprocess 6 | from collections import deque 7 | from tempfile import NamedTemporaryFile, mkdtemp, mkstemp 8 | from unittest.mock import Mock 9 | 10 | import mock 11 | 12 | import nginx_config_reloader 13 | from tests.testcase import TestCase 14 | 15 | 16 | class TestConfigReloader(TestCase): 17 | def setUp(self): 18 | self.get_pid = self.set_up_patch( 19 | "nginx_config_reloader.NginxConfigReloader.get_nginx_pid" 20 | ) 21 | self.get_pid.return_value = 42 22 | self.fix_custom_config_dir_permissions = self.set_up_patch( 23 | "nginx_config_reloader.NginxConfigReloader.fix_custom_config_dir_permissions" 24 | ) 25 | 26 | self.source = mkdtemp() 27 | self.dest = mkdtemp() 28 | self.backup = mkdtemp() 29 | self.main = mkdtemp() 30 | _, self.mag_conf = mkstemp(text=True) 31 | _, self.mag1_conf = mkstemp(text=True) 32 | _, self.mag2_conf = mkstemp(text=True) 33 | 34 | nginx_config_reloader.MAIN_CONFIG_DIR = self.main 35 | nginx_config_reloader.DIR_TO_WATCH = self.source 36 | nginx_config_reloader.CUSTOM_CONFIG_DIR = self.dest 37 | nginx_config_reloader.BACKUP_CONFIG_DIR = self.backup 38 | 39 | nginx_config_reloader.MAGENTO_CONF = self.mag_conf 40 | nginx_config_reloader.MAGENTO1_CONF = self.mag1_conf 41 | nginx_config_reloader.MAGENTO2_CONF = self.mag2_conf 42 | 43 | self.test_config = self.set_up_patch("subprocess.check_output") 44 | self.kill = self.set_up_patch("os.kill") 45 | self.error_file = os.path.join( 46 | nginx_config_reloader.DIR_TO_WATCH, nginx_config_reloader.ERROR_FILE 47 | ) 48 | self.notifier = Mock(_eventq=deque(range(5))) 49 | 50 | def tearDown(self): 51 | shutil.rmtree(self.source, ignore_errors=True) 52 | shutil.rmtree(self.dest, ignore_errors=True) 53 | shutil.rmtree(self.backup, ignore_errors=True) 54 | shutil.rmtree(self.main, ignore_errors=True) 55 | for f in [self.mag_conf, self.mag1_conf, self.mag2_conf]: 56 | try: 57 | os.unlink(f) 58 | except OSError: 59 | pass 60 | 61 | def test_that_apply_new_config_moves_files_to_dest_dir(self): 62 | self._write_file(self._source("myfile"), "config contents") 63 | 64 | tm = self._get_nginx_config_reloader_instance() 65 | tm.apply_new_config() 66 | 67 | contents = self._read_file(self._dest("myfile")) 68 | self.assertEqual(contents, "config contents") 69 | 70 | def test_that_apply_config_moves_files_to_dest_dir_if_it_doesnt_yet_exist(self): 71 | self._write_file(self._source("myfile"), "config contents") 72 | shutil.rmtree(self.dest, ignore_errors=True) 73 | 74 | tm = self._get_nginx_config_reloader_instance() 75 | tm.apply_new_config() 76 | 77 | contents = self._read_file(self._dest("myfile")) 78 | self.assertEqual(contents, "config contents") 79 | 80 | def test_that_apply_new_config_defaults_to_magento1_config(self): 81 | self._write_file(self.mag1_conf, "magento1 config") 82 | self._write_file(self.mag2_conf, "magento2 config") 83 | 84 | tm = self._get_nginx_config_reloader_instance() 85 | tm.apply_new_config() 86 | 87 | contents = self._read_file(self.mag_conf) 88 | self.assertTrue(os.path.islink(self.mag_conf)) 89 | self.assertEqual(contents, "magento1 config") 90 | 91 | def test_that_apply_new_config_does_not_install_configs_if_magento1_config_doesnt_exist( 92 | self, 93 | ): 94 | mock_install_custom = self.set_up_patch( 95 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 96 | ) 97 | 98 | os.unlink(self.mag1_conf) 99 | 100 | tm = self._get_nginx_config_reloader_instance() 101 | ret = tm.apply_new_config() 102 | 103 | self.assertFalse(ret) 104 | self.assertFalse(mock_install_custom.called) 105 | 106 | def test_that_apply_new_config_keeps_current_magento_config_if_symlinking_new_config_goes_wrong( 107 | self, 108 | ): 109 | self._write_file(self.mag_conf, "magento1 config") 110 | 111 | mock_symlink = self.set_up_patch("os.symlink") 112 | mock_symlink.side_effect = OSError 113 | 114 | mock_install_custom = self.set_up_patch( 115 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 116 | ) 117 | 118 | tm = self._get_nginx_config_reloader_instance() 119 | ret = tm.apply_new_config() 120 | 121 | self.assertFalse(ret) 122 | self.assertFalse(mock_install_custom.called) 123 | 124 | self.assertTrue(os.path.exists(self.mag_conf)) 125 | contents = self._read_file(self.mag_conf) 126 | self.assertEqual(contents, "magento1 config") 127 | 128 | def test_that_apply_new_config_does_not_install_configs_if_magento2_config_doesnt_exist( 129 | self, 130 | ): 131 | mock_install_custom = self.set_up_patch( 132 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 133 | ) 134 | 135 | os.unlink(self.mag2_conf) 136 | 137 | tm = self._get_nginx_config_reloader_instance() 138 | ret = tm.apply_new_config() 139 | 140 | self.assertFalse(ret) 141 | self.assertFalse(mock_install_custom.called) 142 | 143 | def test_that_apply_new_config_enables_magento1_config_if_customer_sets_flag(self): 144 | self._write_file(self.mag1_conf, "magento1 config") 145 | self._write_file(self.mag2_conf, "magento2 config") 146 | 147 | with NamedTemporaryFile() as f: 148 | nginx_config_reloader.MAGENTO2_FLAG = f.name 149 | 150 | tm = self._get_nginx_config_reloader_instance(magento2_flag=f.name) 151 | tm.apply_new_config() 152 | 153 | contents = self._read_file(self.mag_conf) 154 | self.assertTrue(os.path.islink(self.mag_conf)) 155 | self.assertEqual(contents, "magento2 config") 156 | 157 | def test_that_apply_new_config_keeps_files_in_source_dir(self): 158 | self._write_file(self._source("myfile"), "config contents") 159 | 160 | tm = self._get_nginx_config_reloader_instance() 161 | tm.apply_new_config() 162 | 163 | contents = self._read_file(self._source("myfile")) 164 | self.assertEqual(contents, "config contents") 165 | 166 | def test_that_apply_new_config_sends_hup_to_nginx(self): 167 | tm = self._get_nginx_config_reloader_instance() 168 | tm.apply_new_config() 169 | 170 | self.kill.assert_called_once_with(42, signal.SIGHUP) 171 | 172 | def test_that_apply_new_config_removes_error_file_when_config_correct_and_ignores_all_oserrors( 173 | self, 174 | ): 175 | self.set_up_patch( 176 | "nginx_config_reloader.NginxConfigReloader.install_magento_config" 177 | ) 178 | 179 | # This test triggers an OSError because the tempdir we created does not 180 | # have any error files on disk. So this test tests: 1. the unlink call, 2. the OSErrors 181 | # OSErrors could be: missing file, no permission to remove 182 | with mock.patch("os.unlink") as mock_unlink: 183 | self.test_config.return_value = True 184 | 185 | tm = self._get_nginx_config_reloader_instance() 186 | tm.apply_new_config() 187 | 188 | error_file = os.path.join( 189 | nginx_config_reloader.DIR_TO_WATCH, nginx_config_reloader.ERROR_FILE 190 | ) 191 | mock_unlink.assert_has_calls([mock.call(error_file), mock.call(error_file)]) 192 | 193 | def test_that_apply_new_config_restores_files_if_config_check_fails(self): 194 | self._write_file(self._source("conffile"), "failing config") 195 | self._write_file(self._dest("conffile"), "working config") 196 | self.test_config.side_effect = subprocess.CalledProcessError(1, "nginx", "oops") 197 | 198 | tm = self._get_nginx_config_reloader_instance() 199 | tm.apply_new_config() 200 | 201 | contents = self._read_file(self._dest("conffile")) 202 | self.assertEqual(contents, "working config") 203 | 204 | def test_that_apply_new_config_restores_files_if_dest_didnt_exist_yet(self): 205 | self._write_file(self._source("conffile"), "failing config") 206 | shutil.rmtree(self.dest, ignore_errors=True) 207 | self.test_config.side_effect = subprocess.CalledProcessError(1, "nginx", "oops") 208 | 209 | tm = self._get_nginx_config_reloader_instance() 210 | tm.apply_new_config() 211 | 212 | self.assertFalse(os.path.exists(self.dest)) 213 | 214 | def test_that_apply_new_config_doesnt_hup_nginx_if_config_check_fails(self): 215 | self.test_config.side_effect = subprocess.CalledProcessError(1, "nginx", "oops") 216 | 217 | tm = self._get_nginx_config_reloader_instance() 218 | tm.apply_new_config() 219 | 220 | self.assertEqual(len(self.kill.mock_calls), 0) 221 | 222 | def test_that_apply_new_config_writes_error_message_to_source_dir_if_body_temp_path_check_fails( 223 | self, 224 | ): 225 | self.test_config.side_effect = subprocess.CalledProcessError( 226 | 1, "nginx", "oops!" 227 | ) 228 | 229 | tm = self._get_nginx_config_reloader_instance() 230 | tm.apply_new_config() 231 | 232 | contents = self._read_file(self._source(nginx_config_reloader.ERROR_FILE)) 233 | self.assertIn(nginx_config_reloader.FORBIDDEN_CONFIG_REGEX[0][1], contents) 234 | 235 | def test_that_apply_new_config_writes_error_message_to_source_dir_if_include_is_rejected( 236 | self, 237 | ): 238 | self.isdir = self.set_up_context_manager_patch( 239 | "nginx_config_reloader.os.path.isdir" 240 | ) 241 | self.isdir.return_value = True 242 | self.test_config.side_effect = subprocess.CalledProcessError( 243 | 1, "", "" 244 | ) # grep with -q 245 | 246 | tm = self._get_nginx_config_reloader_instance() 247 | tm.apply_new_config() 248 | 249 | contents = self._read_file(self._source(nginx_config_reloader.ERROR_FILE)) 250 | self.assertIn(nginx_config_reloader.FORBIDDEN_CONFIG_REGEX[0][1], contents) 251 | 252 | def test_that_apply_new_config_does_not_check_includes_if_dir_to_watch_does_not_exist( 253 | self, 254 | ): 255 | self.isdir = self.set_up_context_manager_patch( 256 | "nginx_config_reloader.os.path.isdir" 257 | ) 258 | self.isdir.return_value = False 259 | 260 | self.test_config.side_effect = subprocess.CalledProcessError( 261 | 1, "", "" 262 | ) # grep with -q 263 | 264 | tm = self._get_nginx_config_reloader_instance() 265 | tm.apply_new_config() 266 | 267 | contents = self._read_file(self._source(nginx_config_reloader.ERROR_FILE)) 268 | self.assertEqual(contents, "") 269 | 270 | def test_that_apply_new_config_doesnt_kill_if_no_pidfile(self): 271 | self.get_pid.return_value = None 272 | 273 | tm = self._get_nginx_config_reloader_instance() 274 | tm.apply_new_config() 275 | 276 | self.assertEqual(len(self.kill.mock_calls), 0) 277 | 278 | def test_that_apply_new_config_doesnt_fail_on_failed_rsync(self): 279 | safe_copy_files = self.set_up_patch("nginx_config_reloader.safe_copy_files") 280 | safe_copy_files.side_effect = OSError("Rsync error") 281 | 282 | tm = self._get_nginx_config_reloader_instance() 283 | result = tm.apply_new_config() 284 | 285 | self.assertEqual( 286 | len(self.test_config.mock_calls), 287 | len(nginx_config_reloader.FORBIDDEN_CONFIG_REGEX), 288 | ) 289 | self.assertEqual(len(self.kill.mock_calls), 0) 290 | self.assertFalse(result) 291 | 292 | def test_that_apply_new_config_does_install_magento_config_by_default(self): 293 | self.set_up_patch( 294 | "nginx_config_reloader.NginxConfigReloader.install_magento_config" 295 | ) 296 | 297 | tm = self._get_nginx_config_reloader_instance() 298 | tm.apply_new_config() 299 | 300 | self.assertTrue(tm.install_magento_config.called) 301 | 302 | def test_that_apply_new_config_does_install_custom_config_dir_by_default(self): 303 | self.set_up_patch( 304 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 305 | ) 306 | 307 | tm = self._get_nginx_config_reloader_instance() 308 | tm.apply_new_config() 309 | 310 | self.assertTrue(tm.install_new_custom_config_dir.called) 311 | 312 | def test_that_apply_new_config_fixes_custom_config_dir_permissions_by_default(self): 313 | self.set_up_patch( 314 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 315 | ) 316 | 317 | tm = self._get_nginx_config_reloader_instance() 318 | tm.apply_new_config() 319 | 320 | self.fix_custom_config_dir_permissions.assert_called_once_with() 321 | 322 | def test_that_apply_new_config_does_not_install_magento_config_if_specified(self): 323 | self.set_up_patch( 324 | "nginx_config_reloader.NginxConfigReloader.install_magento_config" 325 | ) 326 | 327 | tm = self._get_nginx_config_reloader_instance(no_magento_config=True) 328 | tm.apply_new_config() 329 | 330 | self.assertFalse(tm.install_magento_config.called) 331 | 332 | def test_that_apply_new_config_does_not_install_custom_config_dir_if_specified( 333 | self, 334 | ): 335 | self.set_up_patch( 336 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 337 | ) 338 | 339 | tm = self._get_nginx_config_reloader_instance(no_custom_config=True) 340 | tm.apply_new_config() 341 | 342 | self.assertFalse(tm.install_new_custom_config_dir.called) 343 | 344 | def test_that_apply_new_config_does_not_fix_custom_config_dir_permissions_if_specified( 345 | self, 346 | ): 347 | self.set_up_patch( 348 | "nginx_config_reloader.NginxConfigReloader.install_new_custom_config_dir" 349 | ) 350 | 351 | tm = self._get_nginx_config_reloader_instance(no_custom_config=True) 352 | tm.apply_new_config() 353 | 354 | self.assertFalse(self.fix_custom_config_dir_permissions.called) 355 | 356 | def test_that_reload_calls_apply_new_config(self): 357 | directory_is_unmounted = self.set_up_patch( 358 | "nginx_config_reloader.directory_is_unmounted", 359 | return_value=False, 360 | ) 361 | apply_new_config = self.set_up_patch( 362 | "nginx_config_reloader.NginxConfigReloader.apply_new_config" 363 | ) 364 | 365 | tm = self._get_nginx_config_reloader_instance() 366 | tm.reload() 367 | 368 | directory_is_unmounted.assert_called_once_with( 369 | nginx_config_reloader.DIR_TO_WATCH 370 | ) 371 | apply_new_config.assert_called_once_with() 372 | 373 | def test_that_reload_does_not_apply_new_config_if_directory_is_unmounted(self): 374 | directory_is_unmounted = self.set_up_patch( 375 | "nginx_config_reloader.directory_is_unmounted", 376 | return_value=True, 377 | ) 378 | signal = Mock() 379 | self.set_up_patch( 380 | "nginx_config_reloader.Signal", 381 | return_value=signal, 382 | ) 383 | apply_new_config = self.set_up_patch( 384 | "nginx_config_reloader.NginxConfigReloader.apply_new_config" 385 | ) 386 | 387 | tm = self._get_nginx_config_reloader_instance() 388 | tm.reload() 389 | 390 | directory_is_unmounted.assert_called_once_with( 391 | nginx_config_reloader.DIR_TO_WATCH 392 | ) 393 | apply_new_config.assert_not_called() 394 | signal.emit.assert_not_called() 395 | 396 | def test_that_reload_does_not_send_signal_if_specified(self): 397 | self.set_up_patch( 398 | "nginx_config_reloader.directory_is_unmounted", 399 | return_value=False, 400 | ) 401 | signal = Mock() 402 | self.set_up_patch( 403 | "nginx_config_reloader.Signal", 404 | return_value=signal, 405 | ) 406 | apply_new_config = self.set_up_patch( 407 | "nginx_config_reloader.NginxConfigReloader.apply_new_config" 408 | ) 409 | 410 | tm = self._get_nginx_config_reloader_instance() 411 | tm.reload(send_signal=False) 412 | 413 | apply_new_config.assert_called_once_with() 414 | signal.emit.assert_not_called() 415 | 416 | def test_that_error_file_is_not_moved_to_dest_dir(self): 417 | self._write_file(self._source(nginx_config_reloader.ERROR_FILE), "some error") 418 | 419 | tm = self._get_nginx_config_reloader_instance() 420 | tm.apply_new_config() 421 | 422 | self.assertFalse(os.path.exists(self._dest(nginx_config_reloader.ERROR_FILE))) 423 | 424 | def test_that_files_starting_with_dot_are_not_moved_to_dest_dir(self): 425 | self._write_file(self._source(".config.swp"), "asdf") 426 | 427 | tm = self._get_nginx_config_reloader_instance() 428 | tm.apply_new_config() 429 | 430 | self.assertFalse(os.path.exists(self._dest(".config.swp"))) 431 | 432 | def test_that_flags_are_not_moved_to_dest_dir(self): 433 | self._write_file(self._source("whatever.flag"), "") 434 | 435 | tm = self._get_nginx_config_reloader_instance() 436 | tm.apply_new_config() 437 | 438 | self.assertFalse(os.path.exists(self._dest("whatever.flag"))) 439 | 440 | def test_that_handle_event_applies_config(self): 441 | tm = self._get_nginx_config_reloader_instance() 442 | tm.handle_event(Event("some_file")) 443 | 444 | self.assertTrue(tm.dirty) 445 | 446 | def test_that_handle_event_sets_dirty_to_true(self): 447 | tm = self._get_nginx_config_reloader_instance() 448 | tm.handle_event(Event("some_file")) 449 | 450 | self.assertTrue(tm.dirty) 451 | 452 | def test_that_flags_trigger_config_reload(self): 453 | tm = self._get_nginx_config_reloader_instance() 454 | tm.handle_event(Event("magento2.flag")) 455 | 456 | self.assertTrue(tm.dirty) 457 | 458 | def test_that_handle_event_does_not_need_reload_on_change_of_error_file(self): 459 | tm = self._get_nginx_config_reloader_instance() 460 | tm.handle_event(Event(nginx_config_reloader.ERROR_FILE)) 461 | 462 | self.assertFalse(tm.dirty) 463 | 464 | def test_that_handle_event_does_not_need_reload_on_change_of_invisible_file(self): 465 | tm = self._get_nginx_config_reloader_instance() 466 | tm.handle_event(Event(".config.swp")) 467 | 468 | self.assertFalse(tm.dirty) 469 | 470 | def test_remove_error_file_unlinks_the_error_file(self): 471 | mock_os = self.set_up_patch("nginx_config_reloader.os") 472 | mock_os.path.join.return_value = self.error_file 473 | tm = self._get_nginx_config_reloader_instance() 474 | self.assertTrue(tm.remove_error_file()) 475 | mock_os.unlink.assert_called_once_with(self.error_file) 476 | self.assertTrue(mock_os.path.join.called) 477 | 478 | def test_remove_error_file_returns_false_on_errors(self): 479 | mock_os = self.set_up_patch("nginx_config_reloader.os") 480 | mock_os.unlink.side_effect = OSError("mocked error") 481 | tm = self._get_nginx_config_reloader_instance() 482 | self.assertFalse(tm.remove_error_file()) 483 | 484 | def test_that_install_new_custom_config_dir_always_removes_the_error_file_before_copying_configs( 485 | self, 486 | ): 487 | mock_remove_error_file = self.set_up_patch( 488 | "nginx_config_reloader.NginxConfigReloader.remove_error_file" 489 | ) 490 | # ensure all IO operations would fail other than error_file removal 491 | mock_shutil = self.set_up_patch("nginx_config_reloader.shutil") 492 | mock_shutil.rmtree.side_effect = RuntimeError("mock error") 493 | mock_shutil.move.side_effect = RuntimeError("mock error") 494 | mock_shutil.copytree.side_effect = RuntimeError("mock error") 495 | 496 | tm = self._get_nginx_config_reloader_instance() 497 | with self.assertRaises(RuntimeError): 498 | tm.install_new_custom_config_dir() 499 | 500 | self.assertTrue(mock_remove_error_file.called) 501 | 502 | def test_recursive_symlink_is_not_copied(self): 503 | os.mkdir(os.path.join(self.source, "new_dir")) 504 | os.symlink(self.source, os.path.join(self.source, "new_dir/recursive_symlink")) 505 | tm = self._get_nginx_config_reloader_instance() 506 | tm.apply_new_config() 507 | self.assertFalse(os.path.exists(self._dest("new_dir/recursive_symlink"))) 508 | 509 | def test_backup_is_placed_if_custom_config_fails_to_be_placed(self): 510 | safe_copy_files = self.set_up_patch("nginx_config_reloader.safe_copy_files") 511 | safe_copy_files.side_effect = OSError("Rsync error") 512 | os.mkdir(self._dest("old_dir")) 513 | 514 | tm = self._get_nginx_config_reloader_instance() 515 | tm.apply_new_config() 516 | self.assertTrue(os.path.exists(self._dest("old_dir"))) 517 | 518 | def test_other_files_are_not_placed_on_rsync_error(self): 519 | safe_copy_files = self.set_up_patch("nginx_config_reloader.safe_copy_files") 520 | safe_copy_files.side_effect = OSError("Rsync error") 521 | 522 | os.mkdir(self._source("new_dir")) 523 | tm = self._get_nginx_config_reloader_instance() 524 | tm.apply_new_config() 525 | self.assertFalse(os.path.exists(self._dest("new_dir"))) 526 | 527 | def test_rsync_error_is_placed_in_error_file(self): 528 | safe_copy_files = self.set_up_patch("nginx_config_reloader.safe_copy_files") 529 | safe_copy_files.side_effect = OSError("Rsync error") 530 | 531 | os.mkdir(os.path.join(self.source, "new_dir")) 532 | # Python 2.7 doesnt allow kwargs for symlink. Order is src -> dest 533 | os.symlink(self.source, os.path.join(self.source, "new_dir/recursive_symlink")) 534 | tm = self._get_nginx_config_reloader_instance() 535 | tm.apply_new_config() 536 | self.assertTrue(os.path.exists(self.error_file)) 537 | with open(self.error_file) as fp: 538 | self.assertIn("Rsync error", fp.read()) 539 | 540 | def test_reloader_doesnt_crash_if_source_dir_is_empty(self): 541 | shutil.rmtree(self.source, ignore_errors=True) 542 | os.mkdir(self.source) 543 | 544 | # Doesn't crash 545 | tm = self._get_nginx_config_reloader_instance() 546 | tm.apply_new_config() 547 | 548 | def test_files_are_copied(self): 549 | with open(os.path.join(self.source, "server.test.cnf"), "w") as fp: 550 | fp.write("test") 551 | tm = self._get_nginx_config_reloader_instance() 552 | tm.apply_new_config() 553 | self.assertTrue(os.path.exists(os.path.join(self.dest, "server.test.cnf"))) 554 | with open(os.path.join(self.dest, "server.test.cnf")) as fp: 555 | self.assertIn("test", fp.read()) 556 | 557 | def test_new_dir_is_placed(self): 558 | os.mkdir(os.path.join(self.source, "new_dir")) 559 | tm = self._get_nginx_config_reloader_instance() 560 | tm.apply_new_config() 561 | self.assertTrue(os.path.exists(os.path.join(self.dest, "new_dir"))) 562 | 563 | def test_dotfiles_are_ignored(self): 564 | os.mkdir(os.path.join(self.source, ".git")) 565 | tm = self._get_nginx_config_reloader_instance() 566 | tm.apply_new_config() 567 | self.assertFalse(os.path.exists(os.path.join(self.dest, ".git"))) 568 | 569 | def test_symlink_to_file_is_copied_to_file(self): 570 | with open(os.path.join(self.source, "server.test.cnf"), "w") as fp: 571 | fp.write("test") 572 | os.symlink( 573 | os.path.join(self.source, "server.test.cnf"), 574 | os.path.join(self.source, "symlink"), 575 | ) 576 | tm = self._get_nginx_config_reloader_instance() 577 | tm.apply_new_config() 578 | self.assertFalse(os.path.islink(os.path.join(self.dest, "symlink"))) 579 | self.assertTrue(os.path.isfile(os.path.join(self.dest, "symlink"))) 580 | 581 | def test_symlink_to_dir_is_copied_to_dir(self): 582 | os.mkdir(os.path.join(self.source, "new_dir")) 583 | os.symlink( 584 | os.path.join(self.source, "new_dir"), os.path.join(self.source, "symlink") 585 | ) 586 | tm = self._get_nginx_config_reloader_instance() 587 | tm.apply_new_config() 588 | self.assertFalse(os.path.islink(os.path.join(self.dest, "symlink"))) 589 | self.assertTrue(os.path.isdir(os.path.join(self.dest, "symlink"))) 590 | 591 | def test_sticky_bits_are_removed_from_dir(self): 592 | os.mkdir(os.path.join(self.source, "new_dir")) 593 | os.chmod(os.path.join(self.source, "new_dir"), 0o4755) 594 | tm = self._get_nginx_config_reloader_instance() 595 | tm.apply_new_config() 596 | self.assertEqual( 597 | str(oct(os.stat(os.path.join(self.dest, "new_dir")).st_mode))[-5:], "40755" 598 | ) 599 | 600 | def test_sticky_bits_are_removed_from_file(self): 601 | with open(os.path.join(self.source, "server.test.cnf"), "w") as fp: 602 | fp.write("test") 603 | os.chmod(os.path.join(self.source, "server.test.cnf"), 0o4644) 604 | tm = self._get_nginx_config_reloader_instance() 605 | tm.apply_new_config() 606 | self.assertEqual( 607 | str(oct(os.stat(os.path.join(self.dest, "server.test.cnf")).st_mode))[-4:], 608 | "0644", 609 | ) 610 | 611 | def test_dir_is_chmodded_to_0755(self): 612 | os.mkdir(os.path.join(self.source, "new_dir")) 613 | os.chmod(os.path.join(self.source, "new_dir"), 0o777) 614 | tm = self._get_nginx_config_reloader_instance() 615 | tm.apply_new_config() 616 | self.assertEqual( 617 | str(oct(os.stat(os.path.join(self.dest, "new_dir")).st_mode))[-5:], "40755" 618 | ) 619 | 620 | def test_execute_permissions_are_stripped_for_others(self): 621 | with open(os.path.join(self.source, "server.test.cnf"), "w") as fp: 622 | fp.write("test") 623 | os.chmod(os.path.join(self.source, "server.test.cnf"), 0o777) 624 | tm = self._get_nginx_config_reloader_instance() 625 | tm.apply_new_config() 626 | self.assertFalse( 627 | os.stat(os.path.join(self.dest, "server.test.cnf")).st_mode & stat.S_IXOTH 628 | ) 629 | 630 | def test_write_permissions_are_stripped_for_others(self): 631 | with open(os.path.join(self.source, "server.test.cnf"), "w") as fp: 632 | fp.write("test") 633 | os.chmod(os.path.join(self.source, "server.test.cnf"), 0o777) 634 | tm = self._get_nginx_config_reloader_instance() 635 | tm.apply_new_config() 636 | self.assertFalse( 637 | os.stat(os.path.join(self.dest, "server.test.cnf")).st_mode & stat.S_IWOTH 638 | ) 639 | 640 | def test_permissions_are_masked_for_file_in_subdir(self): 641 | os.mkdir(os.path.join(self.source, "new_dir")) 642 | with open(os.path.join(self.source, "new_dir/server.test.cnf"), "w") as fp: 643 | fp.write("test") 644 | os.chmod(os.path.join(self.source, "new_dir/server.test.cnf"), 0o777) 645 | tm = self._get_nginx_config_reloader_instance() 646 | tm.apply_new_config() 647 | self.assertFalse( 648 | os.stat(os.path.join(self.dest, "new_dir/server.test.cnf")).st_mode 649 | & stat.S_IXOTH 650 | ) 651 | 652 | def test_no_permission_to_main_config_dir(self): 653 | os.chmod(self.main, 0o400) # Read-only 654 | 655 | tm = self._get_nginx_config_reloader_instance() 656 | try: 657 | result = tm.check_can_write_to_main_config_dir() 658 | self.assertFalse(result) 659 | finally: 660 | # Restore permissions after test 661 | os.chmod(self.main, 0o700) 662 | 663 | def _get_nginx_config_reloader_instance( 664 | self, 665 | no_magento_config=False, 666 | no_custom_config=False, 667 | magento2_flag=None, 668 | notifier=None, 669 | ): 670 | return nginx_config_reloader.NginxConfigReloader( 671 | no_magento_config=no_magento_config, 672 | no_custom_config=no_custom_config, 673 | dir_to_watch=self.source, 674 | magento2_flag=magento2_flag, 675 | notifier=notifier or self.notifier, 676 | ) 677 | 678 | def _write_file(self, name, contents): 679 | with open(name, "w") as f: 680 | f.write(contents) 681 | 682 | def _read_file(self, name): 683 | with open(name) as f: 684 | return f.read() 685 | 686 | def _source(self, name): 687 | return os.path.join(self.source, name) 688 | 689 | def _dest(self, name): 690 | return os.path.join(self.dest, name) 691 | 692 | 693 | class Event: 694 | def __init__(self, name): 695 | self.name = name 696 | self.maskname = "IN_CLOSE_WRITE" 697 | --------------------------------------------------------------------------------