firewatcher 1.63__tar.gz → 1.64__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {firewatcher-1.63/firewatcher.egg-info → firewatcher-1.64}/PKG-INFO +2 -2
  2. {firewatcher-1.63 → firewatcher-1.64}/README.md +1 -1
  3. {firewatcher-1.63 → firewatcher-1.64/firewatcher.egg-info}/PKG-INFO +2 -2
  4. {firewatcher-1.63 → firewatcher-1.64}/firewatcher.py +45 -4
  5. {firewatcher-1.63 → firewatcher-1.64}/tests/test_cli.py +1 -1
  6. {firewatcher-1.63 → firewatcher-1.64}/tests/test_systemd.py +181 -0
  7. {firewatcher-1.63 → firewatcher-1.64}/LICENSE +0 -0
  8. {firewatcher-1.63 → firewatcher-1.64}/MANIFEST.in +0 -0
  9. {firewatcher-1.63 → firewatcher-1.64}/examples/firewatcher.conf +0 -0
  10. {firewatcher-1.63 → firewatcher-1.64}/examples/nvme_failure.regex +0 -0
  11. {firewatcher-1.63 → firewatcher-1.64}/examples/sys_msg.txt +0 -0
  12. {firewatcher-1.63 → firewatcher-1.64}/firewatcher.egg-info/SOURCES.txt +0 -0
  13. {firewatcher-1.63 → firewatcher-1.64}/firewatcher.egg-info/dependency_links.txt +0 -0
  14. {firewatcher-1.63 → firewatcher-1.64}/firewatcher.egg-info/entry_points.txt +0 -0
  15. {firewatcher-1.63 → firewatcher-1.64}/firewatcher.egg-info/top_level.txt +0 -0
  16. {firewatcher-1.63 → firewatcher-1.64}/setup.cfg +0 -0
  17. {firewatcher-1.63 → firewatcher-1.64}/setup.py +0 -0
  18. {firewatcher-1.63 → firewatcher-1.64}/tests/conftest.py +0 -0
  19. {firewatcher-1.63 → firewatcher-1.64}/tests/test_capture.py +0 -0
  20. {firewatcher-1.63 → firewatcher-1.64}/tests/test_compressor.py +0 -0
  21. {firewatcher-1.63 → firewatcher-1.64}/tests/test_config.py +0 -0
  22. {firewatcher-1.63 → firewatcher-1.64}/tests/test_log_source.py +0 -0
  23. {firewatcher-1.63 → firewatcher-1.64}/tests/test_matching.py +0 -0
  24. {firewatcher-1.63 → firewatcher-1.64}/tests/test_notify.py +0 -0
  25. {firewatcher-1.63 → firewatcher-1.64}/tests/test_patterns.py +0 -0
  26. {firewatcher-1.63 → firewatcher-1.64}/tests/test_position.py +0 -0
  27. {firewatcher-1.63 → firewatcher-1.64}/tests/test_slugify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firewatcher
3
- Version: 1.63
3
+ Version: 1.64
4
4
  Summary: Watch logs for incident patterns and persist surrounding context off-box
5
5
  Home-page: https://github.com/yufei-pan/firewatcher
6
6
  Author: Yufei Pan
@@ -131,7 +131,7 @@ Useful flags:
131
131
  | Flag | Description |
132
132
  |------|-------------|
133
133
  | `--unit-name NAME` | Unit name (default: `firewatcher`) |
134
- | `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output) |
134
+ | `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output). Each path is made absolute and normalized as text (`.`, `..`, `//`, a trailing `/`; symlinks are not followed, as systemd follows none), quoted for you when it has spaces, quotes or backslashes, and refused if it has a newline or is not UTF-8 (systemd cannot read either back there) |
135
135
  | `--no-enable` | Write the unit and reload, but do not enable or start it |
136
136
 
137
137
  ```bash
@@ -104,7 +104,7 @@ Useful flags:
104
104
  | Flag | Description |
105
105
  |------|-------------|
106
106
  | `--unit-name NAME` | Unit name (default: `firewatcher`) |
107
- | `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output) |
107
+ | `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output). Each path is made absolute and normalized as text (`.`, `..`, `//`, a trailing `/`; symlinks are not followed, as systemd follows none), quoted for you when it has spaces, quotes or backslashes, and refused if it has a newline or is not UTF-8 (systemd cannot read either back there) |
108
108
  | `--no-enable` | Write the unit and reload, but do not enable or start it |
109
109
 
110
110
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firewatcher
3
- Version: 1.63
3
+ Version: 1.64
4
4
  Summary: Watch logs for incident patterns and persist surrounding context off-box
5
5
  Home-page: https://github.com/yufei-pan/firewatcher
6
6
  Author: Yufei Pan
@@ -131,7 +131,7 @@ Useful flags:
131
131
  | Flag | Description |
132
132
  |------|-------------|
133
133
  | `--unit-name NAME` | Unit name (default: `firewatcher`) |
134
- | `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output) |
134
+ | `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output). Each path is made absolute and normalized as text (`.`, `..`, `//`, a trailing `/`; symlinks are not followed, as systemd follows none), quoted for you when it has spaces, quotes or backslashes, and refused if it has a newline or is not UTF-8 (systemd cannot read either back there) |
135
135
  | `--no-enable` | Write the unit and reload, but do not enable or start it |
136
136
 
137
137
  ```bash
@@ -31,7 +31,7 @@ import syslog
31
31
  import threading
32
32
  import zlib
33
33
 
34
- version = '1.63'
34
+ version = '1.64'
35
35
  __version__ = version
36
36
 
37
37
  DEFAULT_OUTPUT_FOLDER = '/var/log/captured_messages/'
@@ -914,11 +914,41 @@ def resolve_settings(args, environ=None):
914
914
  settings.ntfy_url = (settings.ntfy_url or '').strip()
915
915
  settings.unit_name = getattr(args, 'unit_name', None) or DEFAULT_UNIT_NAME
916
916
  settings.no_enable = bool(getattr(args, 'no_enable', False))
917
- settings.requires_mounts_for = list(getattr(args, 'requires_mounts_for', None) or [])
917
+ settings.requires_mounts_for = _mount_paths(getattr(args, 'requires_mounts_for', None))
918
918
  validate_settings(settings)
919
919
  return settings
920
920
 
921
921
 
922
+ def _mount_paths(paths):
923
+ """The --requires-mounts-for paths for the unit, with empty ones dropped.
924
+
925
+ systemd ignores a relative or unnormalized path in RequiresMountsFor= with a warning, so each path is
926
+ made absolute and normalized as text ('.', '..', '//' and a trailing '/'). The file system is not
927
+ touched: symlinks are not followed (systemd follows none either, and one inside a remote share would
928
+ lead off it), and a hung NFS mount or an automount point can't stall or trigger anything. The work is
929
+ done on the path's bytes, so the locale doesn't matter. A newline or carriage return can't be written
930
+ in any form systemd reads back, and systemd rejects a path that is not UTF-8, so those are refused,
931
+ before anything is installed."""
932
+ result = []
933
+ for path in paths or []:
934
+ if not path:
935
+ continue
936
+ try:
937
+ raw = os.path.abspath(os.fsencode(path))
938
+ except OSError as exc: # a relative path, and the current directory is gone
939
+ raise ConfigError('--requires-mounts-for cannot make %r absolute: %s' % (path, exc))
940
+ raw = b'/' + raw.lstrip(b'/') # POSIX normpath keeps a leading '//'; systemd does not accept it
941
+ if b'\n' in raw or b'\r' in raw:
942
+ raise ConfigError('--requires-mounts-for cannot take a path with a newline or carriage return, '
943
+ 'which systemd cannot read back from a unit file: %r' % os.fsdecode(raw))
944
+ try:
945
+ result.append(raw.decode('utf-8'))
946
+ except UnicodeError:
947
+ raise ConfigError('--requires-mounts-for cannot take a path that is not UTF-8, which systemd '
948
+ 'rejects: %r' % os.fsdecode(raw))
949
+ return result
950
+
951
+
922
952
  def validate_settings(settings):
923
953
  if settings.llm_enabled and (not settings.llm_base_url or not settings.llm_model):
924
954
  raise ConfigError('LLM analysis requires base_url and model (--llm-base-url and --llm-model, or llm.base_url and llm.model in the config)')
@@ -2295,6 +2325,17 @@ def _quote_unit_arg(value, program=False):
2295
2325
 
2296
2326
 
2297
2327
 
2328
+ def _quote_unit_path(path):
2329
+ """A path for RequiresMountsFor=, a space-separated list in which systemd expands % specifiers. A path
2330
+ with whitespace, a quote or a backslash goes in double quotes, with the backslash and the double quote
2331
+ each prefixed by a backslash: that is all systemd reads back there. It has no \\n, so a newline does not
2332
+ survive (such a path is refused in _mount_paths), unlike in an ExecStart argument."""
2333
+ path = path.replace('%', '%%')
2334
+ if any(ch.isspace() or ch in '"\'\\' for ch in path):
2335
+ path = '"' + path.replace('\\', '\\\\').replace('"', '\\"') + '"'
2336
+ return path
2337
+
2338
+
2298
2339
  def render_unit_file(args, executable=None, config_exists=None, raw=None):
2299
2340
  """Render a systemd unit. Does not require systemd to be present."""
2300
2341
  resolved, raw = _prepare_unit_settings(args, raw=raw)
@@ -2323,7 +2364,7 @@ def render_unit_file(args, executable=None, config_exists=None, raw=None):
2323
2364
  'Wants=' + ' '.join(wants),
2324
2365
  ]
2325
2366
  for mount in requires:
2326
- lines.append('RequiresMountsFor=' + mount)
2367
+ lines.append('RequiresMountsFor=' + _quote_unit_path(mount))
2327
2368
  stop_sec = max(30, int(resolved.llm_timeout) + int(resolved.ntfy_timeout) + 15)
2328
2369
  lines.extend([
2329
2370
  '',
@@ -2503,7 +2544,7 @@ def build_parser():
2503
2544
  svc.add_argument('--print-unit', action='store_true', help='Print the systemd unit that --install-service would write, then exit. Works without systemd.')
2504
2545
  svc.add_argument('--uninstall-service', action='store_true', help='Disable and remove the systemd unit. Warns and exits if systemd is not available.')
2505
2546
  parser.add_argument('--unit-name', type=str, default=DEFAULT_UNIT_NAME, help='systemd unit name (default: firewatcher).')
2506
- parser.add_argument('--requires-mounts-for', action='append', default=[], metavar='PATH', help='Add RequiresMountsFor= to the unit (repeatable). Use when -o is on NFS or another remote filesystem.')
2547
+ parser.add_argument('--requires-mounts-for', action='append', default=[], metavar='PATH', help='Add RequiresMountsFor= to the unit (repeatable). Use when -o is on NFS or another remote filesystem. The path is made absolute and normalized (symlinks are not followed); one with a newline or not in UTF-8 is refused.')
2507
2548
  parser.add_argument('--no-enable', action='store_true', help='With --install-service, write the unit and daemon-reload but do not enable or start it.')
2508
2549
 
2509
2550
  parser.add_argument('--config', default=None, help='INI config file. Default: /etc/firewatcher/firewatcher.conf when that file exists. An explicit path must exist. CLI flags override the file.')
@@ -104,7 +104,7 @@ def test_parser_rejects_conflicting_service_flags():
104
104
  def test_module_has_no_nebula_product_name():
105
105
  src = open(fw.__file__, encoding='utf-8').read().lower()
106
106
  assert 'nebula' not in src
107
- assert fw.version == '1.63'
107
+ assert fw.version == '1.64'
108
108
  assert fw.__version__ == fw.version
109
109
 
110
110
 
@@ -535,3 +535,184 @@ def test_quote_unit_arg_keeps_a_bare_semicolon_and_newlines_inside_one_argument(
535
535
  # A bare ';' ends an ExecStart command, and a raw newline would end the line (or add a directive).
536
536
  assert fw._quote_unit_arg(';') == '";"'
537
537
  assert fw._quote_unit_arg('m\nExecStartPre=/bin/false\r') == '"m\\nExecStartPre=/bin/false\\r"'
538
+
539
+
540
+ # --- RequiresMountsFor= paths ---
541
+ # Checked against systemd 257: a bind mount at each path, then which mount unit the unit's After= named.
542
+
543
+ def _mounts_line(*paths):
544
+ argv = ['--print-unit']
545
+ for path in paths:
546
+ argv += ['--requires-mounts-for', path]
547
+ unit = fw.render_unit_file(parse_cli(argv + ['/etc/firewatcher/patterns.d']), executable='/bin/fw')
548
+ return [line for line in unit.splitlines() if line.startswith('RequiresMountsFor=')]
549
+
550
+
551
+ def _systemd_reads(value):
552
+ """How systemd reads one RequiresMountsFor= value: words split on whitespace, quotes group, a backslash
553
+ inside double quotes takes the next character, %% is one %."""
554
+ words, word, quoted, started, chars = [], '', False, False, iter(value)
555
+ for ch in chars:
556
+ if quoted and ch == '\\':
557
+ word += next(chars)
558
+ elif ch == '"':
559
+ quoted, started = not quoted, True
560
+ elif ch.isspace() and not quoted:
561
+ if started or word:
562
+ words.append(word)
563
+ word, started = '', False
564
+ else:
565
+ word += ch
566
+ if started or word:
567
+ words.append(word)
568
+ return [w.replace('%%', '%') for w in words]
569
+
570
+
571
+ @pytest.mark.parametrize('path, line', [
572
+ ('/mnt/logs/captured_messages', 'RequiresMountsFor=/mnt/logs/captured_messages'),
573
+ ('/mnt/a$b', 'RequiresMountsFor=/mnt/a$b'),
574
+ ('/mnt/a;b', 'RequiresMountsFor=/mnt/a;b'),
575
+ ('/mnt/a#b', 'RequiresMountsFor=/mnt/a#b'),
576
+ ('/mnt/my logs', 'RequiresMountsFor="/mnt/my logs"'),
577
+ ('/mnt/a\tb', 'RequiresMountsFor="/mnt/a\tb"'),
578
+ ('/mnt/100%', 'RequiresMountsFor=/mnt/100%%'),
579
+ ('/mnt/%h y', 'RequiresMountsFor="/mnt/%%h y"'),
580
+ ('/mnt/a"b', 'RequiresMountsFor="/mnt/a\\"b"'),
581
+ ('/mnt/a\\b', 'RequiresMountsFor="/mnt/a\\\\b"'),
582
+ ("/mnt/a'b", 'RequiresMountsFor="/mnt/a\'b"'),
583
+ ('/mnt/a b\\', 'RequiresMountsFor="/mnt/a b\\\\"'),
584
+ ('/mnt/a"b c\\d', 'RequiresMountsFor="/mnt/a\\"b c\\\\d"'),
585
+ ])
586
+ def test_requires_mounts_for_line_is_quoted_so_systemd_reads_the_path_back(path, line):
587
+ assert _mounts_line(path) == [line]
588
+ assert _systemd_reads(line.partition('=')[2]) == [path]
589
+
590
+
591
+ def test_requires_mounts_for_paths_are_normalized_as_text(tmp_path):
592
+ # systemd ignores '..', '//' and '.' ("not normalized"); normalize as text, follow no symlinks.
593
+ assert _mounts_line('/mnt/logs/') == ['RequiresMountsFor=/mnt/logs']
594
+ assert _mounts_line('//mnt/./logs') == ['RequiresMountsFor=/mnt/logs']
595
+ assert _mounts_line('/mnt/nfs/../logs') == ['RequiresMountsFor=/mnt/logs']
596
+ assert _mounts_line('/mnt/a..b') == ['RequiresMountsFor=/mnt/a..b']
597
+ (tmp_path / 'nfs').mkdir()
598
+ (tmp_path / 'data').symlink_to(tmp_path / 'nfs')
599
+ assert _mounts_line(str(tmp_path / 'data' / 'logs')) == ['RequiresMountsFor=%s' % (tmp_path / 'data' / 'logs')]
600
+
601
+
602
+ def test_requires_mounts_for_touches_no_file(monkeypatch):
603
+ # A hung hard NFS mount or an automount point must not stall or trigger anything.
604
+ def no_fs(*args, **kwargs):
605
+ raise AssertionError('the file system was touched')
606
+
607
+ for name in ('lstat', 'stat', 'readlink'):
608
+ monkeypatch.setattr(fw.os, name, no_fs)
609
+ assert fw._mount_paths(['/mnt/nfs/../logs', '/mnt/x']) == ['/mnt/logs', '/mnt/x']
610
+
611
+
612
+ def test_requires_mounts_for_refuses_a_newline_the_current_directory_brings_in(tmp_path, monkeypatch):
613
+ odd = tmp_path / 'a\nb'
614
+ odd.mkdir()
615
+ monkeypatch.chdir(str(odd))
616
+ with pytest.raises(fw.ConfigError) as err:
617
+ fw.resolve_settings(parse_cli(['--print-unit', '--requires-mounts-for', 'logs', 'p.d']), environ={})
618
+ assert 'newline' in str(err.value)
619
+
620
+
621
+ def test_requires_mounts_for_keeps_a_utf8_path_whatever_python_decoded(tmp_path):
622
+ # Python 3.6 under a C locale hands over 'caf\u00e9' as surrogate-escaped bytes; the unit gets the text.
623
+ path = b'/mnt/caf\xc3\xa9'.decode('ascii', 'surrogateescape')
624
+ assert _mounts_line(path) == ['RequiresMountsFor=/mnt/caf\u00e9']
625
+
626
+
627
+ def test_requires_mounts_for_refuses_a_path_that_is_not_utf8():
628
+ path = b'/mnt/caf\xe9'.decode('utf-8', 'surrogateescape')
629
+ with pytest.raises(fw.ConfigError) as err:
630
+ fw.resolve_settings(parse_cli(['--print-unit', '--requires-mounts-for', path, 'p.d']), environ={})
631
+ assert 'not UTF-8' in str(err.value)
632
+
633
+
634
+ def test_requires_mounts_for_relative_path_with_the_current_directory_gone(monkeypatch):
635
+ def gone():
636
+ raise FileNotFoundError(2, 'No such file or directory')
637
+
638
+ monkeypatch.setattr(fw.os, 'getcwd', gone)
639
+ monkeypatch.setattr(fw.os, 'getcwdb', gone)
640
+ with pytest.raises(fw.ConfigError) as err:
641
+ fw.resolve_settings(parse_cli(['--print-unit', '--requires-mounts-for', 'logs', 'p.d']), environ={})
642
+ assert 'cannot make' in str(err.value)
643
+
644
+
645
+ def test_requires_mounts_for_relative_path_in_a_non_utf8_directory_is_refused(tmp_path, monkeypatch):
646
+ # The current directory's bytes count too: the resolved path must be UTF-8 as a whole.
647
+ odd = os.path.join(os.fsencode(str(tmp_path)), b'caf\xe9')
648
+ os.mkdir(odd)
649
+ monkeypatch.chdir(odd)
650
+ with pytest.raises(fw.ConfigError) as err:
651
+ fw.resolve_settings(parse_cli(['--print-unit', '--requires-mounts-for', 'logs', 'p.d']), environ={})
652
+ assert 'not UTF-8' in str(err.value)
653
+
654
+
655
+ def test_requires_mounts_for_keeps_each_path_a_separate_line_and_adds_remote_fs():
656
+ lines = _mounts_line('/mnt/my logs', '/mnt/b')
657
+ assert lines == ['RequiresMountsFor="/mnt/my logs"', 'RequiresMountsFor=/mnt/b']
658
+ unit = fw.render_unit_file(parse_cli(['--print-unit', '--requires-mounts-for', '/mnt/my logs', 'p.d']),
659
+ executable='/bin/fw')
660
+ assert 'remote-fs.target' in unit
661
+
662
+
663
+ def test_requires_mounts_for_makes_a_relative_path_absolute(tmp_path, monkeypatch):
664
+ monkeypatch.chdir(tmp_path)
665
+ assert _mounts_line('nfs/logs') == ['RequiresMountsFor=' + str(tmp_path / 'nfs' / 'logs')]
666
+ assert _mounts_line('./a b') == ['RequiresMountsFor="%s"' % (tmp_path / 'a b')]
667
+ assert fw.resolve_settings(parse_cli(['--requires-mounts-for', 'x', 'p.d']),
668
+ environ={}).requires_mounts_for == [str(tmp_path / 'x')]
669
+
670
+
671
+ def test_requires_mounts_for_empty_path_is_dropped():
672
+ assert _mounts_line('') == []
673
+
674
+
675
+ @pytest.mark.parametrize('path', ['/mnt/a\nb', '/mnt/a\rb', 'rel\nx'])
676
+ def test_requires_mounts_for_refuses_a_newline_or_carriage_return(path, capsys):
677
+ with pytest.raises(fw.ConfigError) as exc:
678
+ fw.resolve_settings(parse_cli(['--requires-mounts-for', path, 'p.d']), environ={})
679
+ assert '--requires-mounts-for' in str(exc.value)
680
+ assert 'newline' in str(exc.value)
681
+ with pytest.raises(SystemExit) as exit_info:
682
+ fw.main(['--print-unit', '--requires-mounts-for', path])
683
+ assert exit_info.value.code == 2
684
+ assert '--requires-mounts-for' in capsys.readouterr().err
685
+
686
+
687
+ def test_install_refuses_a_bad_mount_path_before_writing_anything(tmp_path, capsys):
688
+ unit_dir = tmp_path / 'system'
689
+ patterns = tmp_path / 'patterns.d'
690
+ out = tmp_path / 'captures'
691
+ calls = []
692
+ parsed = parse_cli(['--install-service', '-o', str(out), '--requires-mounts-for', '/mnt/a\nb', str(patterns)])
693
+ rc = fw.install_service(parsed, systemd_available=True, unit_dir=str(unit_dir),
694
+ systemctl=systemctl_recorder(calls), executable='/bin/fw')
695
+ assert rc == 2
696
+ err = capsys.readouterr().err
697
+ assert '--requires-mounts-for' in err and 'newline' in err
698
+ assert not unit_dir.exists() and not patterns.exists() and not out.exists()
699
+ assert not os.path.exists(fw.DEFAULT_CONFIG_PATH)
700
+ assert calls == []
701
+
702
+
703
+ def test_main_install_refuses_a_bad_mount_path_with_exit_2_and_no_traceback(tmp_path, capsys):
704
+ out = tmp_path / 'captures'
705
+ with pytest.raises(SystemExit) as exit_info:
706
+ fw.main(['--install-service', '-o', str(out), '--requires-mounts-for', '/mnt/a\rb', str(tmp_path / 'p.d')])
707
+ assert exit_info.value.code == 2
708
+ assert 'Traceback' not in capsys.readouterr().err
709
+ assert not out.exists() and not os.path.exists(fw.DEFAULT_CONFIG_PATH)
710
+
711
+
712
+ def test_install_writes_a_quoted_mount_path_to_the_unit(tmp_path):
713
+ unit_dir = tmp_path / 'system'
714
+ parsed = parse_cli(['--install-service', '--no-enable', '-o', str(tmp_path / 'out'),
715
+ '--requires-mounts-for', '/mnt/my logs', str(tmp_path / 'p.d')])
716
+ assert fw.install_service(parsed, systemd_available=True, unit_dir=str(unit_dir),
717
+ systemctl=ok_systemctl, executable='/bin/fw') == 0
718
+ assert 'RequiresMountsFor="/mnt/my logs"\n' in (unit_dir / 'firewatcher.service').read_text()
File without changes
File without changes
File without changes
File without changes
File without changes