GRM-13: feat: replace print() with stdlib logging module

- Create logging_config.py with get_logger() providing dual handlers:
  - Console handler (stderr) controlled by GRM_LOG_LEVEL env var (default INFO)
  - File handler (~/.local/state/grm/logs/grm.log) capturing everything at DEBUG
- Replace all print() calls in executor.py and report.py with logger.info()/error()
- Add error logging before raising AnsibleError in executor.run()
- Add GRM_LOG_LEVEL to README configuration table and logging documentation
- Update all unit tests to mock logger instead of using capsys
- 114 tests, 100% coverage, pyright clean, ruff clean
This commit is contained in:
Emil Simeonov
2026-06-19 01:24:35 +02:00
parent 815b59c537
commit 3b3e002f7f
7 changed files with 287 additions and 103 deletions
+22
View File
@@ -152,6 +152,18 @@ Optional: If `GITEA_ADMIN_TOKEN` is set, the installer will also query the Gitea
### View Logs
**GRM application logs** (Python CLI output):
```bash
# Application log file (all messages including DEBUG)
cat ~/.local/state/grm/logs/grm.log
# Enable debug logging in the current session
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
**Runner logs** (on the remote host):
```bash
# Binary mode logs (via systemd template unit)
sudo journalctl -u gitea-runner@<name> -f
@@ -160,6 +172,15 @@ sudo journalctl -u gitea-runner@<name> -f
docker logs gitea-runner-<name> -f
```
The GRM application writes two parallel log streams:
| Destination | Level | Content |
|-------------|-------|---------|
| Console (stderr) | `GRM_LOG_LEVEL` (default: INFO) | User-facing messages and operation reports |
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level.
## Architecture
GRM consists of two layers:
@@ -212,6 +233,7 @@ All tunable values are exposed as Ansible variables in `ansible/roles/gitea-runn
| `gitea_runner_container_label` | `gitea-runner=true` | Container label |
| `docker_gpg_key_path` | `/etc/apt/keyrings/docker.asc` | Docker GPG key path |
| `GRM_LANG` | `en` | CLI language: `en`, `bg`, `de`, `ru`, `zh` |
| `GRM_LOG_LEVEL` | `INFO` | Console verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
Override any variable by passing it to the CLI with `--extra-vars` or by setting it in your Ansible inventory.
+12 -10
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from .exceptions import AnsibleError
from .i18n import _
from .logging_config import get_logger
class AnsibleExecutor:
@@ -17,31 +18,32 @@ class AnsibleExecutor:
def __init__(self, log_dir: Path | None = None) -> None:
self._log_dir = log_dir or Path.home() / ".local" / "state" / "grm" / "logs"
self._logger = get_logger()
def run(self, cmd: list[str], description: str = "") -> None:
"""Run an Ansible command, redirecting output to a log file."""
log_file = self._prepare_log(cmd)
desc = description or _("Running Ansible playbook")
print(f"[GRM] {desc}")
print(f"[GRM] {_('Full log: {log_file}', log_file=log_file)}")
self._logger.info(desc)
self._logger.info(_("Full log: {log_file}", log_file=log_file))
returncode = self._stream(cmd, log_file)
if returncode != 0:
raise AnsibleError(
_(
"Ansible failed with exit code {code}. See full log: {log_file}",
code=returncode,
log_file=log_file,
)
msg = _(
"Ansible failed with exit code {code}. See full log: {log_file}",
code=returncode,
log_file=log_file,
)
self._logger.error(msg)
raise AnsibleError(msg)
status = self._extract_status(log_file)
if status:
print(f"[GRM] {status}")
self._logger.info(status)
print(f"[GRM] {_('Done. See full log: {log_file}', log_file=log_file)}")
self._logger.info(_("Done. See full log: {log_file}", log_file=log_file))
def _prepare_log(self, cmd: list[str]) -> Path:
"""Create a log file with header."""
@@ -0,0 +1,46 @@
"""GRM logging configuration using the standard library."""
from __future__ import annotations
import logging
import os
from pathlib import Path
def get_logger(name: str = "grm") -> logging.Logger:
"""Return a configured logger for GRM.
Console output respects ``GRM_LOG_LEVEL`` (default: INFO).
All messages are also written to ``~/.local/state/grm/logs/grm.log``.
"""
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(logging.DEBUG)
# Console handler — user-controlled level
console = logging.StreamHandler()
console.setLevel(_level_from_env(os.getenv("GRM_LOG_LEVEL", "INFO")))
console.setFormatter(logging.Formatter("[GRM] %(message)s"))
logger.addHandler(console)
# File handler — captures everything including DEBUG
log_dir = Path.home() / ".local" / "state" / "grm" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_dir / "grm.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logger.addHandler(file_handler)
return logger
def _level_from_env(value: str | None) -> int:
"""Map a level name to a logging level constant."""
if not value:
return logging.INFO
try:
return getattr(logging, value.upper())
except AttributeError:
return logging.INFO
+4 -2
View File
@@ -6,6 +6,7 @@ from collections.abc import Generator
from contextlib import contextmanager
from .i18n import _
from .logging_config import get_logger
class Step:
@@ -56,14 +57,15 @@ def track_steps() -> Generator[StepTracker, None, None]:
def _print_report(steps: list[Step]) -> None:
"""Print a translated operation report to stdout."""
logger = get_logger()
icons = {
"completed": "",
"failed": "",
"pending": "",
"in_progress": "",
}
print(f"[GRM] {_('=== Operation Report ===')}")
logger.info(_("=== Operation Report ==="))
for step in steps:
icon = icons.get(step.status, "?")
status_label = _(step.status)
print(f"[GRM] {icon} {step.name} ({status_label})")
logger.info(f" {icon} {step.name} ({status_label})")
+57 -44
View File
@@ -20,31 +20,40 @@ def _mock_popen_process(returncode: int = 0) -> MagicMock:
class TestAnsibleExecutorRun:
def test_run_success(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"], description="Test run")
def test_run_success(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"], description="Test run")
captured = capsys.readouterr()
assert "[GRM] Test run" in captured.out
assert "[GRM] Done" in captured.out
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Test run" in msg for msg in messages)
assert any("Done" in msg for msg in messages)
def test_run_failure(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=1)
with pytest.raises(AnsibleError, match="exit code 1"):
executor.run(["false"], description="Test run")
mock_logger = MagicMock()
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=1)
with pytest.raises(AnsibleError, match="exit code 1"):
executor.run(["false"], description="Test run")
def test_run_default_description(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"])
assert mock_logger.error.called
assert "exit code 1" in mock_logger.error.call_args[0][0]
captured = capsys.readouterr()
assert "[GRM] Running Ansible playbook" in captured.out
def test_run_default_description(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"])
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Running Ansible playbook" in msg for msg in messages)
def test_run_with_stdout_lines(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
@@ -60,20 +69,22 @@ class TestAnsibleExecutorRun:
handle.write.assert_any_call("line1\n")
handle.write.assert_any_call("line2\n")
def test_run_extracts_status(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
def test_run_extracts_status(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
log_content = (
"TASK [gitea-runner : Report runner status]\n"
"ok: [127.0.0.1] => {\n"
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n"
)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open(read_data=log_content)):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"], description="Test run")
with patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger):
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open(read_data=log_content)):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"], description="Test run")
captured = capsys.readouterr()
assert "Runner '127.0.0.1' is installed and running" in captured.out
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Runner '127.0.0.1' is installed and running" in msg for msg in messages)
class TestAnsibleExecutorExtractStatus:
@@ -129,32 +140,34 @@ class TestAnsibleExecutorPrepareLog:
class TestAnsibleExecutorTranslation:
def test_run_translated(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
def test_run_translated(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with (
patch.dict(os.environ, {"GRM_LANG": "de"}),
patch("subprocess.Popen") as mock_popen,
patch("builtins.open", mock_open()),
patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger),
):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"]) # use default description to test translation
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"]) # use default description to test translation
captured = capsys.readouterr()
assert "Ansible-Playbook wird ausgeführt" in captured.out
assert "Fertig" in captured.out
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Ansible-Playbook wird ausgeführt" in msg for msg in messages)
assert any("Fertig" in msg for msg in messages)
def test_unsupported_lang_fallback(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
def test_unsupported_lang_fallback(self, tmp_path: Path) -> None:
mock_logger = MagicMock()
with (
patch.dict(os.environ, {"GRM_LANG": "xx"}),
patch("subprocess.Popen") as mock_popen,
patch("builtins.open", mock_open()),
patch("gitea_runner_manager.executor.get_logger", return_value=mock_logger),
):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"])
executor = AnsibleExecutor(log_dir=tmp_path)
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
mock_popen.return_value = _mock_popen_process(returncode=0)
executor.run(["echo", "hello"])
captured = capsys.readouterr()
assert "[GRM] Running Ansible playbook" in captured.out # English fallback
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Running Ansible playbook" in msg for msg in messages) # English fallback
class TestAnsibleExecutorAdHoc:
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for logging configuration."""
from __future__ import annotations
import logging
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch
import pytest
from gitea_runner_manager.logging_config import _level_from_env, get_logger
@pytest.fixture(autouse=True)
def _cleanup_loggers() -> Generator[None, None, None]:
"""Remove test loggers after each test to prevent state leakage."""
yield
for name in list(logging.Logger.manager.loggerDict.keys()):
if name.startswith("test_"):
logger = logging.getLogger(name)
logger.handlers.clear()
del logging.Logger.manager.loggerDict[name]
class TestLevelFromEnv:
def test_none_defaults_to_info(self) -> None:
assert _level_from_env(None) == logging.INFO
def test_empty_string_defaults_to_info(self) -> None:
assert _level_from_env("") == logging.INFO
def test_valid_levels(self) -> None:
assert _level_from_env("debug") == logging.DEBUG
assert _level_from_env("DEBUG") == logging.DEBUG
assert _level_from_env("warning") == logging.WARNING
assert _level_from_env("error") == logging.ERROR
assert _level_from_env("critical") == logging.CRITICAL
def test_invalid_fallback_to_info(self) -> None:
assert _level_from_env("foo") == logging.INFO
assert _level_from_env("VERBOSE") == logging.INFO
class TestGetLogger:
def test_returns_logger(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_returns_logger")
assert isinstance(logger, logging.Logger)
assert logger.name == "test_returns_logger"
assert len(logger.handlers) == 2 # console + file
def test_caches_same_instance(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path):
logger1 = get_logger("test_caches_same")
logger2 = get_logger("test_caches_same")
assert logger1 is logger2
def test_file_handler_writes(self, tmp_path: Path) -> None:
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_file_writes")
logger.info("hello from test")
log_file = tmp_path / ".local" / "state" / "grm" / "logs" / "grm.log"
assert log_file.exists()
content = log_file.read_text()
assert "hello from test" in content
assert "INFO" in content
def test_respects_grm_log_level(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("GRM_LOG_LEVEL", "DEBUG")
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_env_level")
console = [h for h in logger.handlers if type(h) is logging.StreamHandler]
assert len(console) == 1
assert console[0].level == logging.DEBUG
def test_grm_log_level_warning(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("GRM_LOG_LEVEL", "WARNING")
with patch("gitea_runner_manager.logging_config.Path.home", return_value=tmp_path):
logger = get_logger("test_env_warning")
console = [h for h in logger.handlers if type(h) is logging.StreamHandler]
assert len(console) == 1
assert console[0].level == logging.WARNING
+58 -47
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from gitea_runner_manager.exceptions import AnsibleError
@@ -43,60 +45,69 @@ class TestStepTracker:
class TestTrackSteps:
def test_success(self, capsys: pytest.CaptureFixture[str]) -> None:
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
tracker.done()
captured = capsys.readouterr()
assert "Operation Report" in captured.out
assert "step1" in captured.out
assert "step2" in captured.out
assert "" in captured.out
assert "completed" in captured.out
def test_failure_marks_step(self, capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(AnsibleError, match="fail"):
def test_success(self) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.report.get_logger", return_value=mock_logger):
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
raise AnsibleError("fail")
tracker.done()
captured = capsys.readouterr()
assert "Operation Report" in captured.out
assert "step1" in captured.out
assert "step2" in captured.out
assert "" in captured.out
assert "" in captured.out
assert "completed" in captured.out
assert "failed" in captured.out
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Operation Report" in msg for msg in messages)
assert any("step1" in msg for msg in messages)
assert any("step2" in msg for msg in messages)
assert any("" in msg for msg in messages)
assert any("completed" in msg for msg in messages)
def test_pending_shown_on_failure(self, capsys: pytest.CaptureFixture[str]) -> None:
def test_failure_marks_step(self) -> None:
mock_logger = MagicMock()
with pytest.raises(AnsibleError, match="fail"):
with patch("gitea_runner_manager.report.get_logger", return_value=mock_logger):
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
tracker.begin("step2")
raise AnsibleError("fail")
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("step1" in msg for msg in messages)
assert any("step2" in msg for msg in messages)
assert any("" in msg for msg in messages)
assert any("" in msg for msg in messages)
assert any("completed" in msg for msg in messages)
assert any("failed" in msg for msg in messages)
def test_pending_shown_on_failure(self) -> None:
mock_logger = MagicMock()
with pytest.raises(AnsibleError, match="fail"):
with patch("gitea_runner_manager.report.get_logger", return_value=mock_logger):
with track_steps() as tracker:
tracker.begin("step1")
raise AnsibleError("fail")
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("" in msg for msg in messages)
assert any("failed" in msg for msg in messages)
def test_empty_report(self) -> None:
mock_logger = MagicMock()
with patch("gitea_runner_manager.report.get_logger", return_value=mock_logger):
with track_steps():
pass
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Operation Report" in msg for msg in messages)
def test_translated_report(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GRM_LANG", "bg")
mock_logger = MagicMock()
with patch("gitea_runner_manager.report.get_logger", return_value=mock_logger):
with track_steps() as tracker:
tracker.begin("step1")
raise AnsibleError("fail")
tracker.done()
captured = capsys.readouterr()
assert "" in captured.out
assert "failed" in captured.out
def test_empty_report(self, capsys: pytest.CaptureFixture[str]) -> None:
with track_steps():
pass
captured = capsys.readouterr()
assert "Operation Report" in captured.out
def test_translated_report(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.setenv("GRM_LANG", "bg")
with track_steps() as tracker:
tracker.begin("step1")
tracker.done()
captured = capsys.readouterr()
assert "Отчет за операцията" in captured.out
assert "завършено" in captured.out
messages = [call.args[0] for call in mock_logger.info.call_args_list]
assert any("Отчет за операцията" in msg for msg in messages)
assert any("завършено" in msg for msg in messages)