Skip to content

Commit f94dc96

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 f94dc96

7 files changed

Lines changed: 186 additions & 35 deletions

nginx_config_reloader/__init__.py

Lines changed: 54 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,7 @@ def __init__(
7679
self.dirty = False
7780
self.applying = False
7881
self._on_config_reload = Signal()
82+
self.error_file = error_file
7983

8084
def on_deleted(self, event):
8185
"""Triggered by inotify on removal of file or removal of dir
@@ -113,7 +117,8 @@ def handle_event(self, event):
113117
return
114118

115119
basename = os.path.basename(event.src_path)
116-
if not any(fnmatch.fnmatch(basename, pat) for pat in WATCH_IGNORE_FILES):
120+
ignore_files = list(WATCH_IGNORE_FILES) + [self.error_file]
121+
if not any(fnmatch.fnmatch(basename, pat) for pat in ignore_files):
117122
self.logger.debug(
118123
f"{event.event_type.upper()} detected on {event.src_path}"
119124
)
@@ -153,37 +158,38 @@ def check_no_forbidden_config_directives_are_present(self):
153158
True if forbidden config directives are present
154159
False if check couldn't find any forbidden config flags
155160
"""
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
161+
if not os.path.isdir(self.dir_to_watch):
178162
return False
179163

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

287294
def restore_old_custom_config_dir(self):
288295
shutil.rmtree(CUSTOM_CONFIG_DIR)
@@ -308,7 +315,7 @@ def get_nginx_pid(self):
308315
return None
309316

310317
def write_error_file(self, error):
311-
with open(os.path.join(self.dir_to_watch, ERROR_FILE), "w") as f:
318+
with open(os.path.join(self.dir_to_watch, self.error_file), "w") as f:
312319
f.write(error)
313320

314321
@property
@@ -366,6 +373,7 @@ def wait_loop(
366373
recursive_watch=False,
367374
use_systemd=False,
368375
no_dbus=False,
376+
error_file: str = ERROR_FILE,
369377
):
370378
"""Main event loop
371379
@@ -382,6 +390,7 @@ def wait_loop(
382390
:param bool recursive_watch: True if we should watch the dir recursively
383391
:param use_systemd: True if we should reload nginx using systemd instead of process signal
384392
:param bool no_dbus: True if we should not use DBus
393+
:param str error_file: Error file to write error output to
385394
:return None:
386395
"""
387396
dir_to_watch = os.path.abspath(dir_to_watch)
@@ -392,6 +401,7 @@ def wait_loop(
392401
no_custom_config=no_custom_config,
393402
dir_to_watch=dir_to_watch,
394403
use_systemd=use_systemd,
404+
error_file=error_file,
395405
)
396406

397407
if not no_dbus:
@@ -474,6 +484,11 @@ def parse_nginx_config_reloader_arguments():
474484
help="Disable DBus interface",
475485
default=False,
476486
)
487+
parser.add_argument(
488+
"--error-file",
489+
help="File name for error output",
490+
default=ERROR_FILE,
491+
)
477492
return parser.parse_args()
478493

479494

@@ -491,6 +506,11 @@ def main():
491506
args = parse_nginx_config_reloader_arguments()
492507
log = get_logger()
493508

509+
error_file_pattern = re.compile(r"[a-zA-Z0-9_\.]*[a-zA-Z0-9_]+")
510+
if not error_file_pattern.fullmatch(args.error_file):
511+
log.error(f"Invalid error file name provided: {args.error_file}")
512+
return 1
513+
494514
if args.monitor:
495515
# Track changed files in the nginx config dir and reload on change
496516
wait_loop(
@@ -501,6 +521,7 @@ def main():
501521
recursive_watch=args.recursivewatch,
502522
use_systemd=args.use_systemd,
503523
no_dbus=args.no_dbus,
524+
error_file=args.error_file,
504525
)
505526
# should never return
506527
return 1
@@ -512,6 +533,7 @@ def main():
512533
no_custom_config=args.nocustomconfig,
513534
dir_to_watch=args.watchdir,
514535
use_systemd=args.use_systemd,
536+
error_file=args.error_file,
515537
).apply_new_config()
516538
return 0
517539

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: 22 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)
@@ -15,6 +19,7 @@
1519

1620
class TestAssertNoForbiddenStatementsInConfig(TestCase):
1721
def setUp(self):
22+
self.custom_error_file = "nginx_error_output.hnclusterweb1"
1823
self.isdir = self.set_up_patch("nginx_config_reloader.os.path.isdir")
1924
self.isdir.return_value = True
2025
self.check_output = self.set_up_patch(
@@ -32,6 +37,22 @@ def test_assert_no_includes_in_config_does_not_check_config_if_no_dir_to_watch(
3237

3338
self.assertFalse(self.check_output.called)
3439

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

tests/test_main.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
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

89

910
class TestMain(TestCase):
1011
def setUp(self):
1112
self.source = mkdtemp()
13+
self.custom_error_file = "nginx_error_output.hnclusterweb1"
1214
self.parse_nginx_config_reloader_arguments = self.set_up_patch(
1315
"nginx_config_reloader.parse_nginx_config_reloader_arguments"
1416
)
@@ -21,6 +23,7 @@ def setUp(self):
2123
recursivewatch=False,
2224
use_systemd=False,
2325
no_dbus=False,
26+
error_file=self.custom_error_file,
2427
)
2528
self.get_logger = self.set_up_context_manager_patch(
2629
"nginx_config_reloader.get_logger"
@@ -54,6 +57,7 @@ def test_main_reloads_config_once_if_monitor_mode_not_specified(self):
5457
no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig,
5558
dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir,
5659
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
60+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
5761
)
5862
self.reloader.return_value.apply_new_config.assert_called_once_with()
5963

@@ -80,6 +84,7 @@ def test_main_watches_the_config_dir_if_monitor_specified(self):
8084
recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch,
8185
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
8286
no_dbus=self.parse_nginx_config_reloader_arguments.return_value.no_dbus,
87+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
8388
)
8489

8590
def test_main_watches_the_config_dir_if_monitor_mode_is_specified_and_includes_allowed(
@@ -98,6 +103,7 @@ def test_main_watches_the_config_dir_if_monitor_mode_is_specified_and_includes_a
98103
recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch,
99104
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
100105
no_dbus=self.parse_nginx_config_reloader_arguments.return_value.no_dbus,
106+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
101107
)
102108

103109
def test_main_does_not_reload_the_config_once_if_monitor_mode_is_specified(self):
@@ -128,4 +134,43 @@ def test_main_passes_no_dbus_to_wait_loop(self):
128134
recursive_watch=self.parse_nginx_config_reloader_arguments.return_value.recursivewatch,
129135
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
130136
no_dbus=True,
137+
error_file=self.parse_nginx_config_reloader_arguments.return_value.error_file,
138+
)
139+
140+
def test_main_rejects_invalid_error_file_name(self):
141+
self.parse_nginx_config_reloader_arguments.return_value.error_file = "bad/name"
142+
143+
ret = main()
144+
145+
self.assertEqual(1, ret)
146+
self.get_logger.return_value.error.assert_called_once_with(
147+
"Invalid error file name provided: bad/name"
148+
)
149+
self.assertFalse(self.wait_loop.called)
150+
self.assertFalse(self.reloader.called)
151+
152+
def test_main_accepts_default_error_file_name(self):
153+
self.parse_nginx_config_reloader_arguments.return_value.error_file = (
154+
nginx_config_reloader.ERROR_FILE
155+
)
156+
157+
ret = main()
158+
159+
self.assertEqual(0, ret)
160+
161+
def test_main_accepts_custom_error_file_name_with_dots(self):
162+
self.parse_nginx_config_reloader_arguments.return_value.error_file = (
163+
self.custom_error_file
164+
)
165+
166+
ret = main()
167+
168+
self.assertEqual(0, ret)
169+
self.reloader.assert_called_once_with(
170+
logger=self.get_logger.return_value,
171+
no_magento_config=self.parse_nginx_config_reloader_arguments.return_value.nomagentoconfig,
172+
no_custom_config=self.parse_nginx_config_reloader_arguments.return_value.nocustomconfig,
173+
dir_to_watch=self.parse_nginx_config_reloader_arguments.return_value.watchdir,
174+
use_systemd=self.parse_nginx_config_reloader_arguments.return_value.use_systemd,
175+
error_file=self.custom_error_file,
131176
)

0 commit comments

Comments
 (0)