mirror of
https://gitlab.isc.org/isc-projects/bind9
synced 2025-08-22 18:19:42 +00:00
Add helper Python classes for watching log files
Waiting for a specific log line to appear in a named.run file is a common action in BIND 9 system tests. Implement a set of Python classes which intend to simplify and standardize this task in Python-based system tests. Co-authored-by: Štěpán Balážik <stepan@isc.org>
This commit is contained in:
parent
00003e497c
commit
bf92f33f94
@ -13,3 +13,4 @@ from . import check
|
|||||||
from . import instance
|
from . import instance
|
||||||
from . import query
|
from . import query
|
||||||
from . import rndc
|
from . import rndc
|
||||||
|
from . import watchlog
|
||||||
|
@ -18,6 +18,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from .rndc import RNDCBinaryExecutor, RNDCException, RNDCExecutor
|
from .rndc import RNDCBinaryExecutor, RNDCException, RNDCExecutor
|
||||||
|
from .watchlog import WatchLogFromStart, WatchLogFromHere
|
||||||
|
|
||||||
|
|
||||||
class NamedPorts(NamedTuple):
|
class NamedPorts(NamedTuple):
|
||||||
@ -127,6 +128,20 @@ class NamedInstance:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def watch_log_from_start(self) -> WatchLogFromStart:
|
||||||
|
"""
|
||||||
|
Return an instance of the `WatchLogFromStart` context manager for this
|
||||||
|
`named` instance's log file.
|
||||||
|
"""
|
||||||
|
return WatchLogFromStart(self._log_file)
|
||||||
|
|
||||||
|
def watch_log_from_here(self) -> WatchLogFromHere:
|
||||||
|
"""
|
||||||
|
Return an instance of the `WatchLogFromHere` context manager for this
|
||||||
|
`named` instance's log file.
|
||||||
|
"""
|
||||||
|
return WatchLogFromHere(self._log_file)
|
||||||
|
|
||||||
def _rndc_log(self, command: str, response: str) -> None:
|
def _rndc_log(self, command: str, response: str) -> None:
|
||||||
"""
|
"""
|
||||||
Log an `rndc` invocation (and its output) to the `rndc.log` file in the
|
Log an `rndc` invocation (and its output) to the `rndc.log` file in the
|
||||||
|
335
bin/tests/system/isctest/watchlog.py
Normal file
335
bin/tests/system/isctest/watchlog.py
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
#
|
||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
#
|
||||||
|
# See the COPYRIGHT file distributed with this work for additional
|
||||||
|
# information regarding copyright ownership.
|
||||||
|
|
||||||
|
from typing import Optional, TextIO, Dict, Any, overload, List, Union
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class WatchLogException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WatchLog(abc.ABC):
|
||||||
|
|
||||||
|
"""
|
||||||
|
Wait for a log message to appear in a text file.
|
||||||
|
|
||||||
|
This class should not be used directly; instead, its subclasses,
|
||||||
|
`WatchLogFromStart` and `WatchLogFromHere`, should be used. For `named`
|
||||||
|
instances used in system tests, it is recommended to use the
|
||||||
|
`watch_log_from_start()` and `watch_log_from_here()` helper methods exposed
|
||||||
|
by the `NamedInstance` class (see below for recommended usage patterns).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
"""
|
||||||
|
`path` is the path to the log file to watch.
|
||||||
|
|
||||||
|
Every instance of this class must call one of the `wait_for_*()`
|
||||||
|
methods exactly once or else an `Exception` is thrown.
|
||||||
|
|
||||||
|
>>> with WatchLogFromStart("/dev/null") as watcher:
|
||||||
|
... print("Just print something without waiting for a log line")
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
Exception: wait_for_*() was not called
|
||||||
|
|
||||||
|
>>> with WatchLogFromHere("/dev/null") as watcher:
|
||||||
|
... try:
|
||||||
|
... watcher.wait_for_line("foo", timeout=0)
|
||||||
|
... except TimeoutError:
|
||||||
|
... pass
|
||||||
|
... try:
|
||||||
|
... watcher.wait_for_lines({"bar": 42}, timeout=0)
|
||||||
|
... except TimeoutError:
|
||||||
|
... pass
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
Exception: wait_for_*() was already called
|
||||||
|
"""
|
||||||
|
self._fd = None # type: Optional[TextIO]
|
||||||
|
self._path = path
|
||||||
|
self._wait_function_called = False
|
||||||
|
|
||||||
|
def wait_for_line(self, string: str, timeout: int = 10) -> None:
|
||||||
|
"""
|
||||||
|
Block execution until a line containing the provided `string` appears
|
||||||
|
in the log file. Return `None` once the line is found or raise a
|
||||||
|
`TimeoutError` after `timeout` seconds (default: 10) if `string` does
|
||||||
|
not appear in the log file. (Catching this exception is discouraged as
|
||||||
|
it indicates that the test code did not behave as expected.)
|
||||||
|
|
||||||
|
Recommended use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import isctest
|
||||||
|
|
||||||
|
def test_foo(servers):
|
||||||
|
with servers["ns1"].watch_log_from_here() as watcher:
|
||||||
|
# ... do stuff here ...
|
||||||
|
watcher.wait_for_line("foo bar")
|
||||||
|
```
|
||||||
|
|
||||||
|
One of `wait_for_line()` or `wait_for_lines()` must be called exactly
|
||||||
|
once for every `WatchLogFrom*` instance.
|
||||||
|
|
||||||
|
>>> # For `WatchLogFromStart`, `wait_for_line()` returns without
|
||||||
|
>>> # raising an exception as soon as the line being looked for appears
|
||||||
|
>>> # anywhere in the file, no matter whether that happens before of
|
||||||
|
>>> # after the `with` statement is reached.
|
||||||
|
>>> import tempfile
|
||||||
|
>>> with tempfile.NamedTemporaryFile("w") as file:
|
||||||
|
... print("foo", file=file, flush=True)
|
||||||
|
... with WatchLogFromStart(file.name) as watcher:
|
||||||
|
... retval = watcher.wait_for_line("foo", timeout=1)
|
||||||
|
>>> print(retval)
|
||||||
|
None
|
||||||
|
>>> with tempfile.NamedTemporaryFile("w") as file:
|
||||||
|
... with WatchLogFromStart(file.name) as watcher:
|
||||||
|
... print("foo", file=file, flush=True)
|
||||||
|
... retval = watcher.wait_for_line("foo", timeout=1)
|
||||||
|
>>> print(retval)
|
||||||
|
None
|
||||||
|
|
||||||
|
>>> # For `WatchLogFromHere`, `wait_for_line()` only returns without
|
||||||
|
>>> # raising an exception if the string being looked for appears in
|
||||||
|
>>> # the log file after the `with` statement is reached.
|
||||||
|
>>> import tempfile
|
||||||
|
>>> with tempfile.NamedTemporaryFile("w") as file:
|
||||||
|
... print("foo", file=file, flush=True)
|
||||||
|
... with WatchLogFromHere(file.name) as watcher:
|
||||||
|
... watcher.wait_for_line("foo", timeout=1) #doctest: +ELLIPSIS
|
||||||
|
Traceback (most recent call last):
|
||||||
|
...
|
||||||
|
TimeoutError: Timeout reached watching ...
|
||||||
|
>>> with tempfile.NamedTemporaryFile("w") as file:
|
||||||
|
... print("foo", file=file, flush=True)
|
||||||
|
... with WatchLogFromHere(file.name) as watcher:
|
||||||
|
... print("foo", file=file, flush=True)
|
||||||
|
... retval = watcher.wait_for_line("foo", timeout=1)
|
||||||
|
>>> print(retval)
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
return self._wait_for({string: None}, timeout)
|
||||||
|
|
||||||
|
def wait_for_lines(self, strings: Dict[str, Any], timeout: int = 10) -> None:
|
||||||
|
"""
|
||||||
|
Block execution until a line of interest appears in the log file. This
|
||||||
|
function is a "multi-match" variant of `wait_for_line()` which is
|
||||||
|
useful when some action may cause several different (mutually
|
||||||
|
exclusive) messages to appear in the log file.
|
||||||
|
|
||||||
|
`strings` is a `dict` associating each string to look for with the
|
||||||
|
value this function should return when that string is found in the log
|
||||||
|
file. If none of the `strings` being looked for appear in the log file
|
||||||
|
after `timeout` seconds, a `TimeoutError` is raised.
|
||||||
|
(Catching this exception is discouraged as it indicates that the test
|
||||||
|
code did not behave as expected.)
|
||||||
|
|
||||||
|
Since `strings` is a `dict` and preserves key order (in CPython 3.6 as
|
||||||
|
implementation detail, since 3.7 by language design), each line is
|
||||||
|
checked against each key in order until the first match. Values provided
|
||||||
|
in the `strings` dictionary (i.e. values which this function is expected
|
||||||
|
to return upon a successful match) can be of any type.
|
||||||
|
|
||||||
|
Recommended use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import isctest
|
||||||
|
|
||||||
|
def test_foo(servers):
|
||||||
|
triggers = {
|
||||||
|
"message A": "value returned when message A is found",
|
||||||
|
"message B": "value returned when message B is found",
|
||||||
|
}
|
||||||
|
with servers["ns1"].watch_log_from_here() as watcher:
|
||||||
|
# ... do stuff here ...
|
||||||
|
retval = watcher.wait_for_lines(triggers)
|
||||||
|
```
|
||||||
|
|
||||||
|
One of `wait_for_line()` or `wait_for_lines()` must be called exactly
|
||||||
|
once for every `WatchLogFromHere` instance.
|
||||||
|
|
||||||
|
>>> # Different values must be returned depending on which line is
|
||||||
|
>>> # found in the log file.
|
||||||
|
>>> import tempfile
|
||||||
|
>>> triggers = {"foo": 42, "bar": 1337}
|
||||||
|
>>> with tempfile.NamedTemporaryFile("w") as file:
|
||||||
|
... print("foo", file=file, flush=True)
|
||||||
|
... with WatchLogFromStart(file.name) as watcher:
|
||||||
|
... retval1 = watcher.wait_for_lines(triggers, timeout=1)
|
||||||
|
... with WatchLogFromHere(file.name) as watcher:
|
||||||
|
... print("bar", file=file, flush=True)
|
||||||
|
... retval2 = watcher.wait_for_lines(triggers, timeout=1)
|
||||||
|
>>> print(retval1)
|
||||||
|
42
|
||||||
|
>>> print(retval2)
|
||||||
|
1337
|
||||||
|
"""
|
||||||
|
return self._wait_for(strings, timeout)
|
||||||
|
|
||||||
|
def _wait_for(self, strings: Dict[str, Any], timeout: int) -> Any:
|
||||||
|
"""
|
||||||
|
Block execution until one of the `strings` being looked for appears in
|
||||||
|
the log file. Raise a `TimeoutError` if none of the `strings` being
|
||||||
|
looked for are found in the log file for `timeout` seconds.
|
||||||
|
"""
|
||||||
|
if self._wait_function_called:
|
||||||
|
raise WatchLogException("wait_for_*() was already called")
|
||||||
|
self._wait_function_called = True
|
||||||
|
if not self._fd:
|
||||||
|
raise WatchLogException("No file to watch")
|
||||||
|
leftover = ""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
for line in self._fd.readlines():
|
||||||
|
if line[-1] != "\n":
|
||||||
|
# Line is not completely written yet, buffer and keep on waiting
|
||||||
|
leftover += line
|
||||||
|
else:
|
||||||
|
line = leftover + line
|
||||||
|
leftover = ""
|
||||||
|
for string, retval in strings.items():
|
||||||
|
if string in line:
|
||||||
|
return retval
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise TimeoutError(
|
||||||
|
"Timeout reached watching {} for {}".format(
|
||||||
|
self._path, list(strings.keys())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self) -> Any:
|
||||||
|
self._fd = open(self._path, encoding="utf-8")
|
||||||
|
self._seek_on_enter()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _seek_on_enter(self) -> None:
|
||||||
|
"""
|
||||||
|
This method is responsible for setting the file position indicator for
|
||||||
|
the file being watched when execution reaches the __enter__() method.
|
||||||
|
It is expected to be set differently depending on which `WatchLog`
|
||||||
|
subclass is used. Since the base `WatchLog` class should not be used
|
||||||
|
directly, raise an exception upon any attempt of such use.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __exit__(self, *_: Any) -> None:
|
||||||
|
if not self._wait_function_called:
|
||||||
|
raise WatchLogException("wait_for_*() was not called")
|
||||||
|
if self._fd:
|
||||||
|
self._fd.close()
|
||||||
|
|
||||||
|
|
||||||
|
class WatchLogFromStart(WatchLog):
|
||||||
|
"""
|
||||||
|
A `WatchLog` subclass which looks for the provided string(s) in the entire
|
||||||
|
log file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _seek_on_enter(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WatchLogFromHere(WatchLog):
|
||||||
|
"""
|
||||||
|
A `WatchLog` subclass which only looks for the provided string(s) in the
|
||||||
|
portion of the log file which is appended to it after the `with` statement
|
||||||
|
is reached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _seek_on_enter(self) -> None:
|
||||||
|
if self._fd:
|
||||||
|
self._fd.seek(0, os.SEEK_END)
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class RNDCExecutor(abc.ABC):
|
||||||
|
|
||||||
|
"""
|
||||||
|
An interface which RNDC executors have to implement in order for the
|
||||||
|
`NamedInstance` class to be able to use them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def call(self, ip: str, port: int, command: str, timeout: int = 10) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def call(self, ip: str, port: int, command: List[str], timeout: int = 10) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def call(
|
||||||
|
self, ip: str, port: int, command: Union[str, List[str]], timeout: int = 10
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Send RNDC `command` to the `named` instance at `ip:port` and return the
|
||||||
|
server's response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class RNDCException(Exception):
|
||||||
|
"""
|
||||||
|
Raised by classes implementing the `RNDCExecutor` interface when sending an
|
||||||
|
RNDC command fails for any reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class RNDCBinaryExecutor(RNDCExecutor):
|
||||||
|
|
||||||
|
"""
|
||||||
|
An `RNDCExecutor` which sends RNDC commands to servers using the `rndc`
|
||||||
|
binary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
This class needs the `RNDC` environment variable to be set to the path
|
||||||
|
to the `rndc` binary to use.
|
||||||
|
"""
|
||||||
|
rndc_path = os.environ.get("RNDC", "/bin/false")
|
||||||
|
rndc_conf = os.path.join("..", "_common", "rndc.conf")
|
||||||
|
self._base_cmdline = [rndc_path, "-c", rndc_conf]
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def call(self, ip: str, port: int, command: str, timeout: int = 10) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def call(self, ip: str, port: int, command: List[str], timeout: int = 10) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self, ip: str, port: int, command: Union[str, List[str]], timeout: int = 10
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Send RNDC `command` to the `named` instance at `ip:port` and return the
|
||||||
|
server's response.
|
||||||
|
"""
|
||||||
|
cmdline = self._base_cmdline[:]
|
||||||
|
cmdline.extend(["-s", ip])
|
||||||
|
cmdline.extend(["-p", str(port)])
|
||||||
|
cmdline.extend(shlex.split(command) if isinstance(command, str) else command)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(
|
||||||
|
cmdline, stderr=subprocess.STDOUT, timeout=timeout, encoding="utf-8"
|
||||||
|
)
|
||||||
|
except subprocess.SubprocessError as exc:
|
||||||
|
msg = getattr(exc, "output", "RNDC exception occurred")
|
||||||
|
raise RNDCException(msg) from exc
|
Loading…
x
Reference in New Issue
Block a user