GRM-12: feat: add translated operation report for success and failure cases
- Create StepTracker context manager in new report.py module - Track each step of lifecycle operations (install, update, start, stop, enable, disable, status, remove) with begin/done markers - On success: report lists all completed steps with ✓ icons - On failure: report shows failed step (✗), completed steps before failure (✓), and pending steps that never started (○) - Add translations for report header, status labels, and registry step names in all 5 supported languages (EN/BG/DE/RU/ZH) - 105 tests, 100% coverage, pyright clean, ruff clean
This commit is contained in:
@@ -425,6 +425,48 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"ru": "неизвестно",
|
"ru": "неизвестно",
|
||||||
"zh": "未知",
|
"zh": "未知",
|
||||||
},
|
},
|
||||||
|
"=== Operation Report ===": {
|
||||||
|
"en": "=== Operation Report ===",
|
||||||
|
"bg": "=== Отчет за операцията ===",
|
||||||
|
"de": "=== Operationsbericht ===",
|
||||||
|
"ru": "=== Отчет об операции ===",
|
||||||
|
"zh": "=== 操作报告 ===",
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"en": "completed",
|
||||||
|
"bg": "завършено",
|
||||||
|
"de": "abgeschlossen",
|
||||||
|
"ru": "завершено",
|
||||||
|
"zh": "已完成",
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"en": "pending",
|
||||||
|
"bg": "чакащо",
|
||||||
|
"de": "ausstehend",
|
||||||
|
"ru": "в ожидании",
|
||||||
|
"zh": "待处理",
|
||||||
|
},
|
||||||
|
"in_progress": {
|
||||||
|
"en": "in progress",
|
||||||
|
"bg": "в процес",
|
||||||
|
"de": "in Bearbeitung",
|
||||||
|
"ru": "в процессе",
|
||||||
|
"zh": "进行中",
|
||||||
|
},
|
||||||
|
"Save runner '{name}' to local registry": {
|
||||||
|
"en": "Save runner '{name}' to local registry",
|
||||||
|
"bg": "Запазване на runner '{name}' в локалния регистър",
|
||||||
|
"de": "Runner '{name}' in lokaler Registrierung speichern",
|
||||||
|
"ru": "Сохранить runner '{name}' в локальном реестре",
|
||||||
|
"zh": "将 runner '{name}' 保存到本地注册表",
|
||||||
|
},
|
||||||
|
"Remove runner '{name}' from local registry": {
|
||||||
|
"en": "Remove runner '{name}' from local registry",
|
||||||
|
"bg": "Премахване на runner '{name}' от локалния регистър",
|
||||||
|
"de": "Runner '{name}' aus lokaler Registrierung entfernen",
|
||||||
|
"ru": "Удалить runner '{name}' из локального реестра",
|
||||||
|
"zh": "从本地注册表移除 runner '{name}'",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Operation report tracking for GRM lifecycle commands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from .i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
class Step:
|
||||||
|
"""A single tracked step in an operation."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.status = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class StepTracker:
|
||||||
|
"""Tracks steps of an operation and prints a translated report on exit."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.steps: list[Step] = []
|
||||||
|
|
||||||
|
def begin(self, name: str) -> None:
|
||||||
|
"""Start a new step."""
|
||||||
|
step = Step(name)
|
||||||
|
self.steps.append(step)
|
||||||
|
step.status = "in_progress"
|
||||||
|
|
||||||
|
def done(self) -> None:
|
||||||
|
"""Mark the most recent in-progress step as completed."""
|
||||||
|
if self.steps and self.steps[-1].status == "in_progress":
|
||||||
|
self.steps[-1].status = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def track_steps() -> Generator[StepTracker, None, None]:
|
||||||
|
"""Context manager that tracks steps and prints a report on exit.
|
||||||
|
|
||||||
|
On exception the last in-progress step is marked as failed.
|
||||||
|
The report is printed in the ``finally`` block so it always appears.
|
||||||
|
"""
|
||||||
|
tracker = StepTracker()
|
||||||
|
try:
|
||||||
|
yield tracker
|
||||||
|
except Exception:
|
||||||
|
for step in reversed(tracker.steps):
|
||||||
|
if step.status == "in_progress":
|
||||||
|
step.status = "failed"
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_print_report(tracker.steps)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_report(steps: list[Step]) -> None:
|
||||||
|
"""Print a translated operation report to stdout."""
|
||||||
|
icons = {
|
||||||
|
"completed": "✓",
|
||||||
|
"failed": "✗",
|
||||||
|
"pending": "○",
|
||||||
|
"in_progress": "◌",
|
||||||
|
}
|
||||||
|
print(f"[GRM] {_('=== Operation Report ===')}")
|
||||||
|
for step in steps:
|
||||||
|
icon = icons.get(step.status, "?")
|
||||||
|
status_label = _(step.status)
|
||||||
|
print(f"[GRM] {icon} {step.name} ({status_label})")
|
||||||
@@ -8,6 +8,7 @@ from .exceptions import AnsibleError
|
|||||||
from .executor import AnsibleExecutor
|
from .executor import AnsibleExecutor
|
||||||
from .i18n import _
|
from .i18n import _
|
||||||
from .registry import RunnerRegistry
|
from .registry import RunnerRegistry
|
||||||
|
from .report import track_steps
|
||||||
|
|
||||||
|
|
||||||
class RunnerManager:
|
class RunnerManager:
|
||||||
@@ -50,17 +51,22 @@ class RunnerManager:
|
|||||||
if admin_token:
|
if admin_token:
|
||||||
extra_vars += f" gitea_admin_token={admin_token}"
|
extra_vars += f" gitea_admin_token={admin_token}"
|
||||||
|
|
||||||
cmd = self._build_cmd("install-runner.yml", host, user, extra_vars, key, ask_become_pass)
|
with track_steps() as tracker:
|
||||||
self._executor.run(cmd, description=_("Installing Gitea Runner on {host}", host=host))
|
tracker.begin(_("Installing Gitea Runner on {host}", host=host))
|
||||||
|
cmd = self._build_cmd("install-runner.yml", host, user, extra_vars, key, ask_become_pass)
|
||||||
|
self._executor.run(cmd, description=_("Installing Gitea Runner on {host}", host=host))
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
self._registry.add(
|
tracker.begin(_("Save runner '{name}' to local registry", name=name))
|
||||||
name=name,
|
self._registry.add(
|
||||||
host=host,
|
name=name,
|
||||||
user=user,
|
host=host,
|
||||||
key=key,
|
user=user,
|
||||||
mode=mode,
|
key=key,
|
||||||
gitea_url=gitea_url,
|
mode=mode,
|
||||||
)
|
gitea_url=gitea_url,
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
@@ -76,8 +82,11 @@ class RunnerManager:
|
|||||||
if version:
|
if version:
|
||||||
extra_vars += f" gitea_runner_version={version}"
|
extra_vars += f" gitea_runner_version={version}"
|
||||||
|
|
||||||
cmd = self._build_cmd("update-runner.yml", host, user, extra_vars, key, ask_become_pass)
|
with track_steps() as tracker:
|
||||||
self._executor.run(cmd, description=_("Updating Gitea Runner on {host}", host=host))
|
tracker.begin(_("Updating Gitea Runner on {host}", host=host))
|
||||||
|
cmd = self._build_cmd("update-runner.yml", host, user, extra_vars, key, ask_become_pass)
|
||||||
|
self._executor.run(cmd, description=_("Updating Gitea Runner on {host}", host=host))
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def _resolve_runner(
|
def _resolve_runner(
|
||||||
self,
|
self,
|
||||||
@@ -120,9 +129,14 @@ class RunnerManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Start a runner instance on a remote host."""
|
"""Start a runner instance on a remote host."""
|
||||||
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
||||||
extra_vars = f"runner_name={name} runner_mode={actual_mode}"
|
with track_steps() as tracker:
|
||||||
cmd = self._build_cmd("start-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
tracker.begin(_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host))
|
||||||
self._executor.run(cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host))
|
extra_vars = f"runner_name={name} runner_mode={actual_mode}"
|
||||||
|
cmd = self._build_cmd("start-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
||||||
|
self._executor.run(
|
||||||
|
cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host)
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def stop(
|
def stop(
|
||||||
self,
|
self,
|
||||||
@@ -134,9 +148,14 @@ class RunnerManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Stop a runner instance on a remote host."""
|
"""Stop a runner instance on a remote host."""
|
||||||
actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
|
actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
|
||||||
extra_vars = f"runner_name={name}"
|
with track_steps() as tracker:
|
||||||
cmd = self._build_cmd("stop-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
tracker.begin(_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host))
|
||||||
self._executor.run(cmd, description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host))
|
extra_vars = f"runner_name={name}"
|
||||||
|
cmd = self._build_cmd("stop-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
||||||
|
self._executor.run(
|
||||||
|
cmd, description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host)
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def enable(
|
def enable(
|
||||||
self,
|
self,
|
||||||
@@ -148,9 +167,16 @@ class RunnerManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Enable a runner instance to start on boot."""
|
"""Enable a runner instance to start on boot."""
|
||||||
actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
|
actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
|
||||||
extra_vars = f"runner_name={name}"
|
with track_steps() as tracker:
|
||||||
cmd = self._build_cmd("enable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
tracker.begin(_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host))
|
||||||
self._executor.run(cmd, description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host))
|
extra_vars = f"runner_name={name}"
|
||||||
|
cmd = self._build_cmd(
|
||||||
|
"enable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
|
||||||
|
)
|
||||||
|
self._executor.run(
|
||||||
|
cmd, description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host)
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def disable(
|
def disable(
|
||||||
self,
|
self,
|
||||||
@@ -169,9 +195,18 @@ class RunnerManager:
|
|||||||
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
||||||
if not token:
|
if not token:
|
||||||
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
|
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
|
||||||
extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}"
|
with track_steps() as tracker:
|
||||||
cmd = self._build_cmd("disable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
tracker.begin(_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host))
|
||||||
self._executor.run(cmd, description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host))
|
extra_vars = (
|
||||||
|
f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}"
|
||||||
|
)
|
||||||
|
cmd = self._build_cmd(
|
||||||
|
"disable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
|
||||||
|
)
|
||||||
|
self._executor.run(
|
||||||
|
cmd, description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host)
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def status(
|
def status(
|
||||||
self,
|
self,
|
||||||
@@ -184,11 +219,16 @@ class RunnerManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Check the status of a runner instance."""
|
"""Check the status of a runner instance."""
|
||||||
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
||||||
extra_vars = f"runner_name={name} runner_mode={actual_mode}"
|
with track_steps() as tracker:
|
||||||
cmd = self._build_cmd("status-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
tracker.begin(_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host))
|
||||||
self._executor.run(
|
extra_vars = f"runner_name={name} runner_mode={actual_mode}"
|
||||||
cmd, description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host)
|
cmd = self._build_cmd(
|
||||||
)
|
"status-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
|
||||||
|
)
|
||||||
|
self._executor.run(
|
||||||
|
cmd, description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host)
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def remove(
|
def remove(
|
||||||
self,
|
self,
|
||||||
@@ -207,11 +247,22 @@ class RunnerManager:
|
|||||||
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
|
||||||
if not token:
|
if not token:
|
||||||
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
|
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
|
||||||
extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}"
|
with track_steps() as tracker:
|
||||||
cmd = self._build_cmd("remove-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
|
tracker.begin(_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host))
|
||||||
self._executor.run(cmd, description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host))
|
extra_vars = (
|
||||||
|
f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}"
|
||||||
|
)
|
||||||
|
cmd = self._build_cmd(
|
||||||
|
"remove-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
|
||||||
|
)
|
||||||
|
self._executor.run(
|
||||||
|
cmd, description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host)
|
||||||
|
)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
self._registry.remove(name)
|
tracker.begin(_("Remove runner '{name}' from local registry", name=name))
|
||||||
|
self._registry.remove(name)
|
||||||
|
tracker.done()
|
||||||
|
|
||||||
def list_runners(self) -> list[dict[str, str]]:
|
def list_runners(self) -> list[dict[str, str]]:
|
||||||
"""Return a list of registered runners with live service status."""
|
"""Return a list of registered runners with live service status."""
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Unit tests for the operation report tracker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gitea_runner_manager.exceptions import AnsibleError
|
||||||
|
from gitea_runner_manager.report import Step, StepTracker, track_steps
|
||||||
|
|
||||||
|
|
||||||
|
class TestStep:
|
||||||
|
def test_init(self) -> None:
|
||||||
|
step = Step("test")
|
||||||
|
assert step.name == "test"
|
||||||
|
assert step.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStepTracker:
|
||||||
|
def test_begin(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("step1")
|
||||||
|
assert len(tracker.steps) == 1
|
||||||
|
assert tracker.steps[0].name == "step1"
|
||||||
|
assert tracker.steps[0].status == "in_progress"
|
||||||
|
|
||||||
|
def test_done(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
assert tracker.steps[0].status == "completed"
|
||||||
|
|
||||||
|
def test_done_no_steps(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.done() # should not raise
|
||||||
|
assert tracker.steps == []
|
||||||
|
|
||||||
|
def test_done_last_not_in_progress(self) -> None:
|
||||||
|
tracker = StepTracker()
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.done() # second done is a no-op
|
||||||
|
assert tracker.steps[0].status == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
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"):
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
tracker.done()
|
||||||
|
tracker.begin("step2")
|
||||||
|
raise AnsibleError("fail")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_pending_shown_on_failure(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(AnsibleError, match="fail"):
|
||||||
|
with track_steps() as tracker:
|
||||||
|
tracker.begin("step1")
|
||||||
|
raise AnsibleError("fail")
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user