Skip to content

Commit 7a5f0e2

Browse files
committed
Add support for custom nginx error filename
This allows for custom error output filenames to be configured, which is useful when using a shared nginx directory across servers.
1 parent 60486ef commit 7a5f0e2

7 files changed

Lines changed: 167 additions & 35 deletions

nginx_config_reloader/__init__.py

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import argparse
44
import fnmatch
55
import logging
6-
import logging.handlers
76
import os
7+
import re
88
import shutil
99
import signal
1010
import subprocess
@@ -32,6 +32,7 @@
3232
MAIN_CONFIG_DIR,
3333
NGINX,
3434
NGINX_PID_FILE,
35+
SYNC_IGNORE_FILES,
3536
UNPRIVILEGED_GID,
3637
UNPRIVILEGED_UID,
3738
WATCH_IGNORE_FILES,
@@ -46,11 +47,12 @@ class NginxConfigReloader(FileSystemEventHandler):
4647
def __init__(
4748
self,
4849
logger=None,
49-
no_magento_config=False,
50-
no_custom_config=False,
51-
dir_to_watch=DIR_TO_WATCH,
52-
magento2_flag=None,
53-
use_systemd=False,
50+
no_magento_config: bool = False,
51+
no_custom_config: bool = False,
52+
dir_to_watch: str = DIR_TO_WATCH,
53+
magento2_flag: str | None = None,
54+
use_systemd: bool = False,
55+
error_file: str = ERROR_FILE,
5456
):
5557
"""Constructor called by ProcessEvent
5658
@@ -59,6 +61,7 @@ def __init__(
5961
:param bool no_custom_config: True if we should not copy custom configuration
6062
:param str dir_to_watch: The directory to watch
6163
:param str magento2_flag: Magento 2 flag location
64+
:param str error_file: File name for error output file
6265
"""
6366
if not logger:
6467
self.logger = logging
@@ -76,6 +79,8 @@ def __init__(
7679
self.dirty = False
7780
self.applying = False
7881
self._on_config_reload = Signal()
82+
# @TODO(timon): validate the input here
83+
self.error_file = error_file
7984

8085
def on_deleted(self, event):
8186
"""Triggered by inotify on removal of file or removal of dir
@@ -113,7 +118,8 @@ def handle_event(self, event):
113118
return
114119

115120
basename = os.path.basename(event.src_path)
116-
if not any(fnmatch.fnmatch(basename, pat) for pat in WATCH_IGNORE_FILES):
121+
ignore_files = list(WATCH_IGNORE_FILES) + [self.error_file]
122+
if not any(fnmatch.fnmatch(basename, pat) for pat in ignore_files):
117123
self.logger.debug(
118124
f"{event.event_type.upper()} detected on {event.src_path}"
119125
)
@@ -153,37 +159,38 @@ def check_no_forbidden_config_directives_are_present(self):
153159
True if forbidden config directives are present
154160
False if check couldn't find any forbidden config flags
155161
"""
156-
if os.path.isdir(self.dir_to_watch):
157-
for rules in FORBIDDEN_CONFIG_REGEX:
158-
try:
159-
# error file may contain messages that match a forbidden config pattern
160-
# then validation could fail while the actual config is correct.
161-
# we'll exclude the error file from searching for patterns,
162-
# NOTE: exclusion of error_file requires to ensure the
163-
# file is removed before moving it to nginx conf dir
164-
# @TODO: use Python to search for forbidden configs instead
165-
# of spawning external procs. Will have better testing
166-
# and even may consume less system resources
167-
check_external_resources = (
168-
"[ $(grep -r --exclude={} -P '{}' '{}' | wc -l) -lt 1 ]".format(
169-
ERROR_FILE, rules[0], self.dir_to_watch
170-
)
171-
)
172-
subprocess.check_output(check_external_resources, shell=True)
173-
except subprocess.CalledProcessError:
174-
error = f"Unable to load config: {rules[1]}"
175-
self.logger.error(error)
176-
self.write_error_file(error)
177-
return True
162+
if not os.path.isdir(self.dir_to_watch):
178163
return False
179164

165+
for pattern, message in FORBIDDEN_CONFIG_REGEX:
166+
try:
167+
# error file may contain messages that match a forbidden config pattern
168+
# then validation could fail while the actual config is correct.
169+
# we'll exclude the error file from searching for patterns,
170+
# NOTE: exclusion of error_file requires to ensure the
171+
# file is removed before moving it to nginx conf dir
172+
# @TODO: use Python to search for forbidden configs instead
173+
# of spawning external procs. Will have better testing
174+
# and even may consume less system resources
175+
check_external_resources = "[ $(grep -r --exclude={} --exclude={} -P '{}' '{}' | wc -l) -lt 1 ]".format(
176+
ERROR_FILE, self.error_file, pattern, self.dir_to_watch
177+
)
178+
subprocess.check_output(check_external_resources, shell=True)
179+
except subprocess.CalledProcessError:
180+
error = f"Unable to load config: {message}"
181+
self.logger.error(error)
182+
self.write_error_file(error)
183+
return True
184+
185+
return False
186+
180187
def remove_error_file(self):
181188
"""Try removing the error file. Return True on success or False on errors
182189
:rtype: bool
183190
"""
184191
removed = False
185192
try:
186-
os.unlink(os.path.join(self.dir_to_watch, ERROR_FILE))
193+
os.unlink(os.path.join(self.dir_to_watch, self.error_file))
187194
removed = True
188195
except OSError:
189196
pass
@@ -282,7 +289,8 @@ def install_new_custom_config_dir(self):
282289
if os.path.exists(CUSTOM_CONFIG_DIR):
283290
shutil.move(CUSTOM_CONFIG_DIR, BACKUP_CONFIG_DIR)
284291
os.mkdir(CUSTOM_CONFIG_DIR)
285-
safe_copy_files(self.dir_to_watch, CUSTOM_CONFIG_DIR)
292+
ignore_files = list(SYNC_IGNORE_FILES) + [self.error_file]
293+
safe_copy_files(self.dir_to_watch, CUSTOM_CONFIG_DIR, ignore_files)
286294

287295
def restore_old_custom_config_dir(self):
288296
shutil.rmtree(CUSTOM_CONFIG_DIR)
@@ -308,7 +316,7 @@ def get_nginx_pid(self):
308316
return None
309317

310318
def write_error_file(self, error):
311-
with open(os.path.join(self.dir_to_watch, ERROR_FILE), "w") as f:
319+
with open(os.path.join(self.dir_to_watch, self.error_file), "w") as f:
312320
f.write(error)
313321

314322
@property
@@ -366,6 +374,7 @@ def wait_loop(
366374
recursive_watch=False,
367375
use_systemd=False,
368376
no_dbus=False,
377+
error_file: str = ERROR_FILE,
369378
):
370379
"""Main event loop
371380
@@ -382,6 +391,7 @@ def wait_loop(
382391
:param bool recursive_watch: True if we should watch the dir recursively
383392
:param use_systemd: True if we should reload nginx using systemd instead of process signal
384393
:param bool no_dbus: True if we should not use DBus
394+
:param str error_file: Error file to write error output to
385395
:return None:
386396
"""
387397
dir_to_watch = os.path.abspath(dir_to_watch)
@@ -392,6 +402,7 @@ def wait_loop(
392402
no_custom_config=no_custom_config,
393403
dir_to_watch=dir_to_watch,
394404
use_systemd=use_systemd,
405+
error_file=error_file,
395406
)
396407

397408
if not no_dbus:
@@ -474,6 +485,11 @@ def parse_nginx_config_reloader_arguments():
474485
help="Disable DBus interface",
475486
default=False,
476487
)
488+
parser.add_argument(
489+
"--error-file",
490+
help="File name for error output",
491+
default=ERROR_FILE,
492+
)
477493
return parser.parse_args()
478494

479495

@@ -491,6 +507,11 @@ def main():
491507
args = parse_nginx_config_reloader_arguments()
492508
log = get_logger()
493509

510+
error_file_pattern = re.compile(r"[a-zA-Z0-9_]+")
511+
if not error_file_pattern.fullmatch(args.error_file):
512+
log.error(f"Invalid error file name provided: {args.error_file}")
513+
return 1
514+
494515
if args.monitor:
495516
# Track changed files in the nginx config dir and reload on change
496517
wait_loop(
@@ -501,6 +522,7 @@ def main():
501522
recursive_watch=args.recursivewatch,
502523
use_systemd=args.use_systemd,
503524
no_dbus=args.no_dbus,
525+
error_file=args.error_file,
504526
)
505527
# should never return
506528
return 1
@@ -512,6 +534,7 @@ def main():
512534
no_custom_config=args.nocustomconfig,
513535
dir_to_watch=args.watchdir,
514536
use_systemd=args.use_systemd,
537+
error_file=args.error_file,
515538
).apply_new_config()
516539
return 0
517540

nginx_config_reloader/copy_files.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
logger = logging.getLogger(__name__)
88

99

10-
def safe_copy_files(src, dest):
10+
def safe_copy_files(src, dest, ignore_files: list[str] | None = None):
11+
if not ignore_files:
12+
ignore_files = list(SYNC_IGNORE_FILES)
13+
1114
cmd = [
1215
# Adding a / at the end copies contents of the dir and not the dir itself
1316
# This is achieved with `os.path.join(x, '')`, which ensures a trailing slash
@@ -22,7 +25,7 @@ def safe_copy_files(src, dest):
2225
# Dirs default to 0755 to read. Remove setuid bits. Remove executability for others
2326
'--chmod="D755,-s,Fo-wx"',
2427
]
25-
cmd.extend([f'--exclude="{pattern}"' for pattern in SYNC_IGNORE_FILES])
28+
cmd.extend([f'--exclude="{pattern}"' for pattern in ignore_files])
2629
cmd = " ".join(cmd)
2730
# shell=True to ensure globs are not escaped
2831
check_output(cmd, shell=True, stderr=STDOUT)

tests/test_assert_forbidden_statements_in_config.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
import pytest
66

7-
from nginx_config_reloader import FORBIDDEN_CONFIG_REGEX, NginxConfigReloader
7+
from nginx_config_reloader import (
8+
ERROR_FILE,
9+
FORBIDDEN_CONFIG_REGEX,
10+
NginxConfigReloader,
11+
)
812
from tests.testcase import TestCase
913

1014
# Skip marker for tests that require grep -P (PCRE support, not available on macOS)
@@ -32,6 +36,22 @@ def test_assert_no_includes_in_config_does_not_check_config_if_no_dir_to_watch(
3236

3337
self.assertFalse(self.check_output.called)
3438

39+
def test_check_no_forbidden_config_excludes_default_and_custom_error_files(self):
40+
reloader = NginxConfigReloader(
41+
dir_to_watch="/tmp/nginx", error_file="custom_error_output"
42+
)
43+
44+
reloader.check_no_forbidden_config_directives_are_present()
45+
46+
self.assertEqual(
47+
len(self.check_output.call_args_list), len(FORBIDDEN_CONFIG_REGEX)
48+
)
49+
for call_args in self.check_output.call_args_list:
50+
command = call_args.args[0]
51+
self.assertIn(f"--exclude={ERROR_FILE}", command)
52+
self.assertIn("--exclude=custom_error_output", command)
53+
self.assertIn("'/tmp/nginx'", command)
54+
3555
@requires_pcre_grep
3656
def test_include_prevention_legal_includes(self):
3757
TEST_CASES = [

tests/test_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from tempfile import mkdtemp
33
from unittest.mock import Mock
44

5+
import nginx_config_reloader
56
from nginx_config_reloader import main
67
from tests.testcase import TestCase
78

@@ -21,6 +22,7 @@ def setUp(self):
2122
recursivewatch=False,
2223
use_systemd=False,
2324
no_dbus=False,
25+
error_file="custom_error_output",
2426
)
2527
self.get_logger = self.set_up_context_manager_patch(
2628
"nginx_config_reloader.get_logger"
@@ -54,6 +56,7 @@ def test_main_reloads_config_once_if_monitor_mode_not_specified(self):
5456
no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig,
5557
dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir,
5658
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
59+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
5760
)
5861
self.reloader.return_value.apply_new_config.assert_called_once_with()
5962

@@ -80,6 +83,7 @@ def test_main_watches_the_config_dir_if_monitor_specified(self):
8083
recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch,
8184
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
8285
no_dbus=self.parse_nginx_config_reloader_arguments.return_value.no_dbus,
86+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
8387
)
8488

8589
def test_main_watches_the_config_dir_if_monitor_mode_is_specified_and_includes_allowed(
@@ -98,6 +102,7 @@ def test_main_watches_the_config_dir_if_monitor_mode_is_specified_and_includes_a
98102
recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch,
99103
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
100104
no_dbus=self.parse_nginx_config_reloader_arguments.return_value.no_dbus,
105+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
101106
)
102107

103108
def test_main_does_not_reload_the_config_once_if_monitor_mode_is_specified(self):
@@ -128,4 +133,26 @@ def test_main_passes_no_dbus_to_wait_loop(self):
128133
recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch,
129134
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
130135
no_dbus=True,
136+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
131137
)
138+
139+
def test_main_rejects_invalid_error_file_name(self):
140+
self.parse_nginx_config_reloader_arguments.return_value.error_file = "bad/name"
141+
142+
ret = main()
143+
144+
self.assertEqual(1, ret)
145+
self.get_logger.return_value.error.assert_called_once_with(
146+
"Invalid error file name provided: bad/name"
147+
)
148+
self.assertFalse(self.wait_loop.called)
149+
self.assertFalse(self.reloader.called)
150+
151+
def test_main_accepts_default_error_file_name(self):
152+
self.parse_nginx_config_reloader_arguments.return_value.error_file = (
153+
nginx_config_reloader.ERROR_FILE
154+
)
155+
156+
ret = main()
157+
158+
self.assertEqual(0, ret)

0 commit comments

Comments
 (0)