diff --git a/motioneye/extra/motioneye.conf.sample b/motioneye/extra/motioneye.conf.sample index 80d5345ec..0822ca050 100644 --- a/motioneye/extra/motioneye.conf.sample +++ b/motioneye/extra/motioneye.conf.sample @@ -13,6 +13,11 @@ media_path /var/lib/motioneye # the log level (use quiet, error, warning, info or debug) log_level info +# whether to write logs to files in log_path (motioneye.log, motion.log) +# instead of standard error / standard output; +# the log_path setting above only has an effect when this is enabled +log_to_file false + # the IP address to listen on # (0.0.0.0 for all interfaces, 127.0.0.1 for localhost) listen 0.0.0.0 diff --git a/motioneye/handlers/log.py b/motioneye/handlers/log.py index 0ca2babaa..3577831e8 100644 --- a/motioneye/handlers/log.py +++ b/motioneye/handlers/log.py @@ -39,6 +39,9 @@ def get(self, name): path, filename = log + if path.startswith('/') and not os.path.exists(path): + raise HTTPError(404, 'log file not found') + self.set_header('Content-Type', 'text/plain') self.set_header('Content-Disposition', 'attachment; filename=' + filename + ';') diff --git a/motioneye/meyectl.py b/motioneye/meyectl.py index e53732429..42fc29c15 100755 --- a/motioneye/meyectl.py +++ b/motioneye/meyectl.py @@ -101,6 +101,7 @@ def load_settings(): config_file = None debug = False + log_to_file = False for i in range(1, len(sys.argv)): arg = sys.argv[i] @@ -111,6 +112,9 @@ def load_settings(): elif arg == '-d': debug = True + elif arg == '-l': + log_to_file = True + conf_path_given = [False] run_path_given = [False] log_path_given = [False] @@ -202,9 +206,14 @@ def parse_conf_line(line): if debug: settings.LOG_LEVEL = logging.DEBUG + if log_to_file: + settings.LOG_TO_FILE = True + + +def configure_logging(cmd, log_to_file=None): + if log_to_file is None: + log_to_file = settings.LOG_TO_FILE -def configure_logging(cmd, log_to_file=False): - sys.stderr.write(f'configure_logging cmd {cmd}: {log_to_file}\n') if log_to_file or cmd != 'motioneye': fmt = f'%(asctime)s: [{cmd}] %(levelname)8s: %(message)s' @@ -221,7 +230,6 @@ def configure_logging(cmd, log_to_file=False): else: log_file = None - sys.stderr.write(f'configure logging to file: {log_file}\n') logging.basicConfig( filename=log_file, level=settings.LOG_LEVEL, @@ -288,7 +296,7 @@ def make_arg_parser(command=None): ) parser.add_argument( '-l', - help='log to file instead of standard error', + help='log to file instead of standard error, overriding the config file setting', action='store_true', dest='log_to_file', ) diff --git a/motioneye/motionctl.py b/motioneye/motionctl.py index 0479aae51..1ca3706e2 100644 --- a/motioneye/motionctl.py +++ b/motioneye/motionctl.py @@ -75,6 +75,17 @@ def find_motion(): return _motion_binary_cache +def _get_motion_log_file(): + """ + Open motion's log file, or return None to let motion + inherit motionEye's stdout/stderr. + """ + if not settings.LOG_TO_FILE: + return None + + return open(join(settings.LOG_PATH, 'motion.log'), 'w') + + def start(deferred=False): from motioneye import config, mjpgclient @@ -99,7 +110,6 @@ def start(deferred=False): logging.debug(f'starting motion executable "{binary}" version "{version}"') motion_cfg_path = join(settings.CONF_PATH, 'motion.conf') - motion_log_path = join(settings.LOG_PATH, 'motion.log') motion_pid_path = join(settings.RUN_PATH, 'motion.pid') args = [binary, '-n', '-c', motion_cfg_path, '-d'] @@ -116,7 +126,7 @@ def start(deferred=False): else: # fatal, quiet args.append('1') - log_file = open(motion_log_path, 'w') + log_file = _get_motion_log_file() process = Popen( args, stdout=log_file, stderr=log_file, close_fds=True, cwd=settings.CONF_PATH diff --git a/motioneye/sendmail.py b/motioneye/sendmail.py index d5e68041a..5f7b80153 100644 --- a/motioneye/sendmail.py +++ b/motioneye/sendmail.py @@ -200,7 +200,7 @@ def main(parser, args): options = parse_options(parser, args) - meyectl.configure_logging('sendmail', options.log_to_file) + meyectl.configure_logging('sendmail') logging.debug('hello!') diff --git a/motioneye/sendtelegram.py b/motioneye/sendtelegram.py index 80ea0e16c..78ec0321e 100644 --- a/motioneye/sendtelegram.py +++ b/motioneye/sendtelegram.py @@ -146,7 +146,7 @@ def main(parser, args): signal.signal(signal.SIGCHLD, signal.SIG_DFL) options = parse_options(parser, args) - meyectl.configure_logging('telegram', options.log_to_file) + meyectl.configure_logging('telegram') logging.debug(options) message = 'Motion has been detected by camera "%(camera)s/%(hostname)s" at %(moment)s (%(timezone)s).' diff --git a/motioneye/server.py b/motioneye/server.py index 785159d3b..975f8f11e 100644 --- a/motioneye/server.py +++ b/motioneye/server.py @@ -483,7 +483,12 @@ def main(parser, args, command): options = parse_options(parser, args) - meyectl.configure_logging('motioneye', options.background or options.log_to_file) + # daemon mode redirects stdout/stderr to /dev/null, + # so logging to file is the only way to preserve any output + if options.background: + settings.LOG_TO_FILE = True + + meyectl.configure_logging('motioneye') meyectl.configure_tornado() if command == 'start': diff --git a/motioneye/settings.py b/motioneye/settings.py index c14d6f845..d3206b396 100644 --- a/motioneye/settings.py +++ b/motioneye/settings.py @@ -52,6 +52,10 @@ # the log level (use FATAL, ERROR, WARNING, INFO or DEBUG) LOG_LEVEL = logging.INFO +# whether to write logs to files in log_path +# instead of standard error / standard output +LOG_TO_FILE = False + # the IP address to listen on # (0.0.0.0 for all interfaces, 127.0.0.1 for localhost) LISTEN = '0.0.0.0' diff --git a/motioneye/shell.py b/motioneye/shell.py index 73857dcab..58097e074 100644 --- a/motioneye/shell.py +++ b/motioneye/shell.py @@ -27,7 +27,7 @@ def main(parser, args): options = parse_options(parser, args) - meyectl.configure_logging('shell', options.log_to_file) + meyectl.configure_logging('shell') meyectl.configure_tornado() logging.debug('hello!') diff --git a/motioneye/webhook.py b/motioneye/webhook.py index f7d5e3deb..52efdfbe1 100644 --- a/motioneye/webhook.py +++ b/motioneye/webhook.py @@ -35,7 +35,7 @@ def main(parser, args): options = parse_options(parser, args) - meyectl.configure_logging('webhook', options.log_to_file) + meyectl.configure_logging('webhook') meyectl.configure_tornado() logging.debug('hello!') diff --git a/tests/test_handlers/test_log.py b/tests/test_handlers/test_log.py new file mode 100644 index 000000000..953a7d417 --- /dev/null +++ b/tests/test_handlers/test_log.py @@ -0,0 +1,66 @@ +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests verifying the log handler when the requested log file is absent.""" + +import os +import unittest +from shutil import rmtree +from tempfile import mkdtemp +from unittest.mock import patch + +from motioneye.handlers.log import LogHandler +from tests.test_handlers import HandlerTestCase + + +class LogHandlerTest(HandlerTestCase): + handler_cls = LogHandler + + def setUp(self): + self.log_dir = mkdtemp() + super().setUp() + + def tearDown(self): + super().tearDown() + rmtree(self.log_dir) + + def _fetch_motion_log(self): + cookie = self.make_session_cookie('admin') + return self.fetch('/log/motion/', headers={'Cookie': cookie}) + + def test_missing_log_file_returns_404(self): + # with log_to_file disabled motion.log is never written (#3330); + # the handler must not blow up trying to open it + path = os.path.join(self.log_dir, 'motion.log') + with patch.dict(LogHandler.LOGS, {'motion': (path, 'motion.log')}): + response = self._fetch_motion_log() + + self.assertEqual(404, response.code) + + def test_existing_log_file_is_served(self): + path = os.path.join(self.log_dir, 'motion.log') + with open(path, 'w') as f: + f.write('a motion log line\n') + + with patch.dict(LogHandler.LOGS, {'motion': (path, 'motion.log')}): + response = self._fetch_motion_log() + + self.assertEqual(200, response.code) + self.assertEqual(b'a motion log line\n', response.body) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_meyectl.py b/tests/test_meyectl.py new file mode 100644 index 000000000..14c28e93e --- /dev/null +++ b/tests/test_meyectl.py @@ -0,0 +1,81 @@ +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests verifying that the log_to_file setting is read from the config file.""" + +import os +import sys +import unittest +from shutil import rmtree +from tempfile import mkdtemp +from unittest.mock import patch + +from motioneye import meyectl, settings + + +class LoadSettingsLogToFileTest(unittest.TestCase): + # load_settings() assigns to these module globals directly, so patching + # cannot undo it; snapshot and restore them to keep other tests isolated + _SETTINGS = ( + 'CONF_PATH', + 'RUN_PATH', + 'LOG_PATH', + 'MEDIA_PATH', + 'LOG_LEVEL', + 'LOG_TO_FILE', + 'config_file', + ) + + def setUp(self): + self.conf_dir = mkdtemp() + self.saved = {name: getattr(settings, name) for name in self._SETTINGS} + + def tearDown(self): + for name, value in self.saved.items(): + setattr(settings, name, value) + + rmtree(self.conf_dir) + + def _load_settings(self, conf, argv=()): + conf_file = os.path.join(self.conf_dir, 'motioneye.conf') + with open(conf_file, 'w') as f: + f.write(conf) + + with patch.object( + sys, 'argv', ['meyectl', 'startserver', '-c', conf_file, *argv] + ): + meyectl.load_settings() + + def test_log_to_file_disabled_by_default(self): + self._load_settings('log_level info\n') + self.assertEqual(False, settings.LOG_TO_FILE) + + def test_log_to_file_false_from_config(self): + # the string is parsed as a bool, not fed to int() (#3330) + self._load_settings('log_to_file false\n') + self.assertEqual(False, settings.LOG_TO_FILE) + + def test_log_to_file_true_from_config(self): + self._load_settings('log_to_file true\n') + self.assertEqual(True, settings.LOG_TO_FILE) + + def test_l_argument_overrides_config(self): + self._load_settings('log_to_file false\n', argv=('-l',)) + self.assertEqual(True, settings.LOG_TO_FILE) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_motionctl.py b/tests/test_motionctl.py new file mode 100644 index 000000000..9a98f5bef --- /dev/null +++ b/tests/test_motionctl.py @@ -0,0 +1,53 @@ +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests verifying where motion's output is sent, depending on log_to_file.""" + +import os +import unittest +from shutil import rmtree +from tempfile import mkdtemp +from unittest.mock import patch + +from motioneye import motionctl, settings + + +class MotionLogFileTest(unittest.TestCase): + def setUp(self): + self.log_dir = mkdtemp() + + def tearDown(self): + rmtree(self.log_dir) + + def test_no_log_file_when_log_to_file_disabled(self): + # None makes Popen pass motionEye's own stdout/stderr on to motion + with patch.object(settings, 'LOG_TO_FILE', False): + self.assertIsNone(motionctl._get_motion_log_file()) + + def test_log_file_opened_when_log_to_file_enabled(self): + with patch.object(settings, 'LOG_TO_FILE', True), patch.object( + settings, 'LOG_PATH', self.log_dir + ): + log_file = motionctl._get_motion_log_file() + + try: + self.assertEqual(os.path.join(self.log_dir, 'motion.log'), log_file.name) + finally: + log_file.close() + + +if __name__ == '__main__': + unittest.main()