"""Inspect logs and output for errors and warnings."""
import gzip
import logging
import os
import re
import tarfile
from pathlib import Path, PurePosixPath
from simtools.utils import value_conversion
_logger = logging.getLogger(__name__)
_GZIP_LOG_SUFFIX = ".log.gz"
_GZIP_STDERR_SUFFIX = ".err.gz"
ERROR_PATTERNS = [
re.compile(r"error", re.IGNORECASE),
re.compile(r"exception", re.IGNORECASE),
re.compile(r"traceback", re.IGNORECASE),
re.compile(r"\b(failed to|has failed)\b", re.IGNORECASE),
re.compile(r"runtime\s*warning", re.IGNORECASE),
re.compile(r"segmentation fault", re.IGNORECASE),
]
IGNORE_PATTERNS = [
re.compile(r"Falling back to 'utf-8' with errors='ignore'", re.IGNORECASE),
re.compile(r"Failed to get user name[^\n]*setting it to UNKNOWN_USER", re.IGNORECASE),
re.compile(r"adjust_text::Error", re.IGNORECASE),
re.compile(r"Job output/error streams", re.IGNORECASE),
re.compile(r"^\s*DEBUG(?:::|:)", re.IGNORECASE),
# typically model parameters like 'transit_time_error'
re.compile(r"'[A-Za-z0-9_]+_error'", re.IGNORECASE), # NOSONAR
re.compile(r"DEBUG: Setting environment variables for job execution:", re.IGNORECASE),
re.compile(r"INFO:", re.IGNORECASE),
]
[docs]
def inspect(log_text):
"""
Inspect log text for errors and run-time warnings.
Ignore any lines containing "INFO::" (to avoid false positives
like "INFO:: Job error stream ").
Parameters
----------
log_text: str or list of str
Text of the log to inspect.
Returns
-------
bool
True if no errors or warnings are found, False otherwise.
"""
log_text = log_text if isinstance(log_text, list) else [log_text]
issues = [
(lineno, line)
for txt in log_text
for lineno, line in enumerate(txt.splitlines(), 1)
if "INFO::" not in line
and any(p.search(line) for p in ERROR_PATTERNS)
and not any(p.search(line) for p in IGNORE_PATTERNS)
]
for lineno, line in issues:
_logger.error(f"Error or warning found in log at line {lineno}: {line.strip()}")
return not issues
[docs]
def check_tar_logs(tar_file, file_test):
"""
Check log files in tar package for wanted and forbidden patterns.
Parameters
----------
tar_file : str
Path to the tar file.
file_test : dict
Dictionary with the test configuration.
Returns
-------
bool
True if the logs are correct.
"""
wanted, forbidden = _get_expected_patterns(file_test)
if wanted is None and forbidden is None:
return True
if not tarfile.is_tarfile(tar_file):
raise ValueError(f"{tar_file} is not a tar file")
found_wanted = set()
found_forbidden = set()
with tarfile.open(tar_file, "r:*") as tar:
for member in tar.getmembers():
if not _is_valid_log_member(member):
continue
if not is_safe_tar_member(member.name):
_logger.warning(f"Skipping potentially unsafe tar member: {member.name}")
continue
_logger.info(f"Scanning {member.name}")
text = _read_log(member, tar)
found_wanted |= _find_patterns(text, wanted)
found_forbidden |= _find_patterns(text, forbidden)
return _validate_patterns(found_wanted, found_forbidden, wanted)
def _is_valid_log_member(member):
"""Validate member suffix for tar log scanning."""
return member.name.endswith((_GZIP_LOG_SUFFIX, _GZIP_STDERR_SUFFIX))
def _read_log(member, tar):
"""Read and decode a gzipped tar member, or return None if extraction fails."""
source = tar.extractfile(member)
if source is None:
return None
with source, gzip.open(source, "rb") as f:
return f.read().decode("utf-8", "ignore")
[docs]
def check_plain_logs(log_files, file_test):
"""
Check plain log file(s) for wanted and forbidden patterns.
Log file can be plain or gzipped.
Parameters
----------
log_files : List, str
Path to the log file.
file_test : dict
Dictionary with the test configuration.
Returns
-------
bool
True if the logs are correct.
"""
wanted, forbidden = _get_expected_patterns(file_test)
if wanted is None and forbidden is None:
return True
log_files = _include_stderr_logs(value_conversion.ensure_list(log_files))
def file_open(file):
if file.suffix == ".gz":
return gzip.open(file, "rt", encoding="utf-8", errors="ignore")
return open(file, encoding="utf-8", errors="ignore")
found = set()
bad = set()
for log_file in log_files:
try:
with file_open(log_file) as f:
text = f.read()
except FileNotFoundError:
_logger.error(f"Log file {log_file} not found")
return False
found |= _find_patterns(text, wanted)
bad |= _find_patterns(text, forbidden)
return _validate_patterns(found, bad, wanted)
def _include_stderr_logs(log_files):
"""Include existing stderr siblings without requiring them for older logs."""
paths = []
for log_file in log_files:
path = Path(log_file)
if path not in paths:
paths.append(path)
for stdout_suffix, stderr_suffix in (
(_GZIP_LOG_SUFFIX, _GZIP_STDERR_SUFFIX),
(".log", ".err"),
):
if path.name.endswith(stdout_suffix):
sibling = path.with_name(path.name.removesuffix(stdout_suffix) + stderr_suffix)
if sibling.is_file() and sibling not in paths:
paths.append(sibling)
break
return paths
def _get_expected_patterns(file_test):
"""
Get wanted and forbidden patterns from file test configuration.
Parameters
----------
file_test : dict
Dictionary with expected / forbidden patterns
"""
expected_log = file_test.get("expected_log_output", file_test)
if not isinstance(expected_log, dict):
_logger.debug("No expected log output provided, skipping checks %s", file_test)
return None, None
wanted = expected_log.get("pattern", [])
forbidden = expected_log.get("forbidden_pattern", [])
if not (wanted or forbidden):
_logger.debug("No expected log output provided, skipping checks %s", file_test)
return None, None
return wanted, forbidden
def _validate_patterns(found, bad, wanted):
"""Validate found patterns against wanted and forbidden ones."""
if bad:
_logger.error(f"Forbidden patterns found: {list(bad)}")
return False
missing = [p for p in wanted if p and p not in found]
if missing:
_logger.error(f"Missing expected patterns: {missing}")
return False
_logger.debug(f"All expected patterns found: {wanted}")
return True
def _find_patterns(text, patterns):
"""Find patterns in text (case and space insensitive)."""
def _normalize(s):
return re.sub(r"\s+", "", s.lower())
text_n = _normalize(text)
return {p for p in patterns if p and _normalize(p) in text_n}
[docs]
def collect_final_lines(file, n_lines):
"""
Collect final lines.
Parameters
----------
file: str or Path
File to collect the lines from.
n_lines: int
Number of lines to be collected.
Returns
-------
str
Final lines collected.
"""
list_of_lines = []
if Path(file).suffix == ".gz":
file_open_function = gzip.open
else:
file_open_function = open
with file_open_function(file, "rb") as read_obj:
# Move the cursor to the end of the file
read_obj.seek(0, os.SEEK_END)
# Create a buffer to keep the last read line
buffer = bytearray()
# Get the current position of pointer i.e eof
pointer_location = read_obj.tell()
# Loop till pointer reaches the top of the file
while pointer_location >= 0:
# Move the file pointer to the location pointed by pointer_location
read_obj.seek(pointer_location)
# Shift pointer location by -1
pointer_location = pointer_location - 1
# read that byte / character
new_byte = read_obj.read(1)
# If the read byte is new line character then it means one line is read
if new_byte == b"\n":
# Save the line in list of lines
list_of_lines.append(buffer.decode()[::-1])
# If the size of list reaches n_lines, then return the reversed list
if len(list_of_lines) == n_lines:
return "".join(list(reversed(list_of_lines)))
# Reinitialize the byte array to save next line
buffer = bytearray()
else:
# If last read character is not eol then add it in buffer
buffer.extend(new_byte)
# As file is read completely, if there is still data in buffer, then its first line.
if len(buffer) > 0:
list_of_lines.append(buffer.decode()[::-1])
return "".join(list(reversed(list_of_lines)))
[docs]
def is_safe_tar_member(member_name):
"""
Validate that a tar member path is safe and doesn't contain path traversal sequences.
Parameters
----------
member_name : str
The path of the tar member to validate.
Returns
-------
bool
True if the path is safe, False otherwise.
"""
# Check for null bytes
if "\0" in member_name:
return False
# Check for absolute paths
if Path(member_name).is_absolute():
return False
# Normalize path and validate components
try:
parts = PurePosixPath(member_name).parts
except ValueError:
return False
# Reject if any component is ".." or if path becomes absolute
if ".." in parts:
return False
# Verify the normalized path is still relative
normalized = PurePosixPath(*parts) if parts else PurePosixPath()
if normalized.is_absolute():
return False
return True
[docs]
def get_log_excerpt(log_file, n_last_lines=30):
"""
Get an excerpt from a log file, namely the n_last_lines of the file.
Parameters
----------
log_file: str or Path
Log file to get the excerpt from.
n_last_lines: int
Number of last lines of the file to get.
Returns
-------
str
Excerpt from log file with header/footer
"""
return (
"\n\nRuntime error - See below the relevant part of the log/err file.\n\n"
f"{log_file}\n"
"====================================================================\n\n"
f"{collect_final_lines(log_file, n_last_lines)}\n\n"
"====================================================================\n"
)