blob: 4e298f1b0ed783115b074884d9aa8eb049de8dee [file]
############################################################################
# SPDX-License-Identifier: Apache-2.0
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership. The
# ASF licenses this file to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the
# License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
############################################################################
"""Tests for NTFC log manager module."""
import os
import re
import tempfile
from datetime import datetime, timezone
import yaml # type: ignore
from ntfc.log.manager import LogManager
###############################################################################
# Helpers
###############################################################################
def _write_log_yaml(tmpdir: str, **log_fields: object) -> str:
"""Write a log.yaml config file and return its path.
:param tmpdir: Destination directory for the config file.
:param log_fields: Key-value pairs placed under the ``log`` key.
:return: Absolute path to the written YAML file.
"""
path = os.path.join(tmpdir, "log.yaml")
with open(path, "w", encoding="utf-8") as f:
yaml.dump({"log": dict(log_fields)}, f)
return path
def _make_session(results_dir: str, name: str, mtime: float = 0) -> str:
"""Create a timestamped session directory.
:param results_dir: Parent results directory.
:param name: Session directory name (``YYYY-MM-DD_HH-MM-SS``).
:param mtime: If non-zero, override mtime with this POSIX timestamp.
:return: Absolute path to the created session directory.
"""
path = os.path.join(results_dir, name)
os.makedirs(path, exist_ok=True)
if mtime:
os.utime(path, (mtime, mtime))
return path
def _write_data(path: str, size_bytes: int) -> None:
"""Write a binary file of the given size into *path*.
:param path: Directory in which to create the file.
:param size_bytes: Number of bytes to write.
"""
with open(os.path.join(path, "data.bin"), "wb") as f:
f.write(b"x" * size_bytes)
###############################################################################
# Tests: config loading and properties
###############################################################################
def test_results_dir_default_when_config_absent() -> None:
"""Default ``./result`` returned when config file does not exist."""
lm = LogManager("/nonexistent/path/log.yaml")
assert lm.results_dir == "./result"
def test_results_dir_from_config() -> None:
"""``results_dir`` returns value specified in YAML config."""
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir="/tmp/myresults")
lm = LogManager(path)
assert lm.results_dir == "/tmp/myresults"
def test_max_age_days_none_when_absent() -> None:
"""``max_age_days`` is *None* when not present in config."""
lm = LogManager("/nonexistent/path/log.yaml")
assert lm.max_age_days is None
def test_max_age_days_from_config() -> None:
"""``max_age_days`` returns integer value from YAML config."""
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, max_age_days=7)
lm = LogManager(path)
assert lm.max_age_days == 7
def test_max_count_none_when_absent() -> None:
"""``max_count`` is *None* when not present in config."""
lm = LogManager("/nonexistent/path/log.yaml")
assert lm.max_count is None
def test_max_count_from_config() -> None:
"""``max_count`` returns integer value from YAML config."""
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, max_count=50)
lm = LogManager(path)
assert lm.max_count == 50
def test_max_size_mb_none_when_absent() -> None:
"""``max_size_mb`` is *None* when not present in config."""
lm = LogManager("/nonexistent/path/log.yaml")
assert lm.max_size_mb is None
def test_max_size_mb_from_config() -> None:
"""``max_size_mb`` returns numeric value from YAML config."""
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, max_size_mb=500.0)
lm = LogManager(path)
assert lm.max_size_mb == 500.0
def test_load_config_empty_yaml() -> None:
"""Empty YAML file yields default ``results_dir``."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as f:
f.write("")
config_path = f.name
try:
lm = LogManager(config_path)
assert lm.results_dir == "./result"
finally:
os.unlink(config_path)
def test_default_config_path_when_none() -> None:
"""``LogManager()`` with no argument uses ``DEFAULT_CONFIG_PATH``."""
# log.yaml ships inside the ntfc package directory
lm = LogManager()
assert lm.results_dir == "./result"
assert lm.max_age_days == 30
assert lm.max_count == 100
assert lm.max_size_mb == 50.0
###############################################################################
# Tests: _session_dirs
###############################################################################
def test_session_dirs_empty_when_no_sessions() -> None:
"""``_session_dirs`` returns empty list when no matching entries exist."""
with tempfile.TemporaryDirectory() as results_dir:
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=results_dir)
lm = LogManager(path)
assert lm._session_dirs() == []
def test_session_dirs_ignores_non_matching_entries() -> None:
"""``_session_dirs`` skips dirs and files that don't match the pattern."""
with tempfile.TemporaryDirectory() as results_dir:
# non-matching directory and regular file
os.makedirs(os.path.join(results_dir, "logs"))
open(os.path.join(results_dir, "readme.txt"), "w").close()
# wrong format dir
os.makedirs(os.path.join(results_dir, "2024_01_01_10_00_00"))
# session-pattern name that is a *file*, not a directory (covers
# the os.path.isdir guard inside _session_dirs)
open(os.path.join(results_dir, "2024-01-03_10-00-00"), "w").close()
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=results_dir)
lm = LogManager(path)
assert lm._session_dirs() == []
def test_session_dirs_sorted_oldest_first() -> None:
"""``_session_dirs`` returns entries sorted ascending by mtime."""
with tempfile.TemporaryDirectory() as results_dir:
d1 = _make_session(results_dir, "2024-01-01_10-00-00", mtime=1000.0)
d2 = _make_session(results_dir, "2024-01-02_10-00-00", mtime=2000.0)
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=results_dir)
lm = LogManager(path)
dirs = lm._session_dirs()
assert len(dirs) == 2
assert dirs[0][1] == d1
assert dirs[1][1] == d2
###############################################################################
# Tests: _dir_size_mb
###############################################################################
def test_dir_size_mb() -> None:
"""``_dir_size_mb`` returns the correct size in megabytes."""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "file.bin")
with open(filepath, "wb") as f:
f.write(b"x" * 1024 * 1024) # exactly 1 MB
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=tmpdir)
lm = LogManager(path)
assert abs(lm._dir_size_mb(tmpdir) - 1.0) < 0.01
###############################################################################
# Tests: _compute_removals
###############################################################################
def test_compute_removals_all_rules_none() -> None:
"""``_compute_removals`` returns empty set when no rules are configured."""
sessions = [(1000.0, "/r/2024-01-01_10-00-00")]
lm = LogManager("/nonexistent/log.yaml")
assert lm._compute_removals(sessions) == set()
def test_compute_removals_age_marks_old_preserves_recent() -> None:
"""Age rule marks old sessions and preserves recent ones."""
old_mtime = datetime.now(tz=timezone.utc).timestamp() - (40 * 86400)
recent_mtime = datetime.now(tz=timezone.utc).timestamp() - (5 * 86400)
old_path = "/results/2024-01-01_00-00-00"
recent_path = "/results/2024-06-01_00-00-00"
sessions = [(old_mtime, old_path), (recent_mtime, recent_path)]
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, max_age_days=30)
lm = LogManager(path)
to_remove = lm._compute_removals(sessions)
assert old_path in to_remove
assert recent_path not in to_remove
def test_compute_removals_count_marks_excess() -> None:
"""Count rule marks oldest sessions beyond the configured limit."""
sessions = [
(1000.0, "/r/2024-01-01_10-00-00"),
(2000.0, "/r/2024-01-02_10-00-00"),
(3000.0, "/r/2024-01-03_10-00-00"),
]
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, max_count=2)
lm = LogManager(path)
to_remove = lm._compute_removals(sessions)
assert "/r/2024-01-01_10-00-00" in to_remove
assert "/r/2024-01-02_10-00-00" not in to_remove
assert "/r/2024-01-03_10-00-00" not in to_remove
def test_compute_removals_count_within_limit() -> None:
"""Count rule is skipped when session count is at or below the limit."""
sessions = [
(1000.0, "/r/2024-01-01_10-00-00"),
(2000.0, "/r/2024-01-02_10-00-00"),
]
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, max_count=3)
lm = LogManager(path)
assert lm._compute_removals(sessions) == set()
def test_compute_removals_size_marks_oldest() -> None:
"""Size rule marks oldest sessions until total fits within the limit."""
with tempfile.TemporaryDirectory() as results_dir:
d1 = _make_session(results_dir, "2024-01-01_10-00-00", mtime=1000.0)
d2 = _make_session(results_dir, "2024-01-02_10-00-00", mtime=2000.0)
_write_data(d1, 1024 * 1024) # 1 MB
_write_data(d2, 1024 * 1024) # 1 MB (total 2 MB > 1.5 MB limit)
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(
cfgdir, results_dir=results_dir, max_size_mb=1.5
)
lm = LogManager(path)
sessions = [(1000.0, d1), (2000.0, d2)]
to_remove = lm._compute_removals(sessions)
assert d1 in to_remove
assert d2 not in to_remove
def test_compute_removals_size_marks_all_sessions() -> None:
"""Size rule marks every session when all together still exceed limit."""
with tempfile.TemporaryDirectory() as results_dir:
d1 = _make_session(results_dir, "2024-01-01_10-00-00", mtime=1000.0)
d2 = _make_session(results_dir, "2024-01-02_10-00-00", mtime=2000.0)
_write_data(d1, 1024 * 1024) # 1 MB
_write_data(d2, 1024 * 1024) # 1 MB (2 MB total > 0.5 MB limit)
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(
cfgdir, results_dir=results_dir, max_size_mb=0.5
)
lm = LogManager(path)
sessions = [(1000.0, d1), (2000.0, d2)]
to_remove = lm._compute_removals(sessions)
assert d1 in to_remove
assert d2 in to_remove
def test_compute_removals_size_within_limit() -> None:
"""Size rule marks nothing when total size is within the limit."""
with tempfile.TemporaryDirectory() as results_dir:
d1 = _make_session(results_dir, "2024-01-01_10-00-00")
_write_data(d1, 512 * 1024) # 0.5 MB (under 10 MB limit)
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(
cfgdir, results_dir=results_dir, max_size_mb=10.0
)
lm = LogManager(path)
sessions = [(1000.0, d1)]
assert lm._compute_removals(sessions) == set()
def test_compute_removals_all_rules_combined() -> None:
"""All three rules contribute independently to the removal set."""
with tempfile.TemporaryDirectory() as results_dir:
old_mtime = datetime.now(tz=timezone.utc).timestamp() - (40 * 86400)
# d1: old (age rule marks it) and oldest (count + size mark it too)
d1 = _make_session(results_dir, "2024-01-01_10-00-00", mtime=old_mtime)
# d2: recent but marked by size rule only
d2 = _make_session(results_dir, "2024-01-02_10-00-00", mtime=2000.0)
# d3: recent, preserved by all rules
d3 = _make_session(results_dir, "2024-01-03_10-00-00")
for d in (d1, d2, d3):
_write_data(d, 1024 * 1024) # 1 MB each (3 MB total)
with tempfile.TemporaryDirectory() as cfgdir:
# age=30d marks d1; count=2 marks d1; size=1.5MB marks d1 + d2
path = _write_log_yaml(
cfgdir,
results_dir=results_dir,
max_age_days=30,
max_count=2,
max_size_mb=1.5,
)
lm = LogManager(path)
sessions = lm._session_dirs()
to_remove = lm._compute_removals(sessions)
assert d1 in to_remove
assert d2 in to_remove # size rule alone marks this one
assert d3 not in to_remove
###############################################################################
# Tests: cleanup
###############################################################################
def test_cleanup_noop_when_results_dir_absent() -> None:
"""``cleanup()`` does nothing when ``results_dir`` does not exist."""
with tempfile.TemporaryDirectory() as cfgdir:
nonexistent = os.path.join(cfgdir, "no_such_results_dir")
path = _write_log_yaml(cfgdir, results_dir=nonexistent)
lm = LogManager(path)
lm.cleanup() # must not raise
def test_cleanup_removes_and_keeps_sessions() -> None:
"""``cleanup()`` removes marked sessions and preserves the rest."""
with tempfile.TemporaryDirectory() as results_dir:
# 3 sessions: oldest will be removed by count rule, others kept
d1 = _make_session(results_dir, "2024-01-01_10-00-00", mtime=1000.0)
d2 = _make_session(results_dir, "2024-01-02_10-00-00", mtime=2000.0)
d3 = _make_session(results_dir, "2024-01-03_10-00-00")
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(
cfgdir, results_dir=results_dir, max_count=2
)
lm = LogManager(path)
lm.cleanup()
assert not os.path.exists(d1) # oldest removed
assert os.path.exists(d2)
assert os.path.exists(d3)
###############################################################################
# Tests: new_session_dir
###############################################################################
_SESSION_RE = re.compile(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$")
def test_new_session_dir_creates_directory() -> None:
"""``new_session_dir`` creates a directory under ``results_dir``."""
with tempfile.TemporaryDirectory() as results_dir:
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=results_dir)
lm = LogManager(path)
session_path = lm.new_session_dir()
assert os.path.isdir(session_path)
def test_new_session_dir_name_matches_format() -> None:
"""Directory name created by ``new_session_dir`` matches SESSION_FORMAT."""
with tempfile.TemporaryDirectory() as results_dir:
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=results_dir)
lm = LogManager(path)
session_path = lm.new_session_dir()
name = os.path.basename(session_path)
assert _SESSION_RE.match(name) is not None
def test_new_session_dir_under_results_dir() -> None:
"""``new_session_dir`` creates the directory under ``results_dir``."""
with tempfile.TemporaryDirectory() as results_dir:
with tempfile.TemporaryDirectory() as cfgdir:
path = _write_log_yaml(cfgdir, results_dir=results_dir)
lm = LogManager(path)
session_path = lm.new_session_dir()
assert os.path.dirname(session_path) == results_dir