GRM-16: feat: add --force flag to grm remove for unreachable runners

- Add force parameter to RunnerManager.remove() — skips remote Ansible
  playbook and only removes the local registry entry
- Add --force/-f CLI flag to grm remove command
- Add translations for --force help text across all 5 languages
- Add unit tests for force skip and CLI flag propagation
- 118 tests, 100% coverage, pyright clean, ruff clean
This commit is contained in:
Emil Simeonov
2026-06-19 02:00:45 +02:00
parent 6ede871054
commit 677745ac99
5 changed files with 64 additions and 10 deletions
+8
View File
@@ -306,6 +306,12 @@ def status(
type=click.Choice(["docker", "binary"]), type=click.Choice(["docker", "binary"]),
help=_("Override deployment mode from registry"), help=_("Override deployment mode from registry"),
) )
@click.option(
"--force",
"-f",
is_flag=True,
help=_("Skip remote cleanup and only remove the local registry entry"),
)
@_handle_errors("Remove failed: {error}") @_handle_errors("Remove failed: {error}")
def remove( def remove(
runner_name: str, runner_name: str,
@@ -314,6 +320,7 @@ def remove(
key: str | None, key: str | None,
token: str | None, token: str | None,
mode: str | None, mode: str | None,
force: bool,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -326,6 +333,7 @@ def remove(
gitea_url=os.getenv("GITEA_URL", ""), gitea_url=os.getenv("GITEA_URL", ""),
mode=mode, mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
force=force,
) )
+7
View File
@@ -467,6 +467,13 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"ru": "Удалить runner '{name}' из локального реестра", "ru": "Удалить runner '{name}' из локального реестра",
"zh": "从本地注册表移除 runner '{name}'", "zh": "从本地注册表移除 runner '{name}'",
}, },
"Skip remote cleanup and only remove the local registry entry": {
"en": "Skip remote cleanup and only remove the local registry entry",
"bg": "Пропуснете отдалеченото почистване и премахнете само локалния запис",
"de": "Remote-Bereinigung überspringen und nur den lokalen Registrierungseintrag entfernen",
"ru": "Пропустить удаленную очистку и удалить только локальную запись реестра",
"zh": "跳过远程清理,仅删除本地注册表条目",
},
} }
+17 -10
View File
@@ -240,8 +240,14 @@ class RunnerManager:
gitea_url: str = "", gitea_url: str = "",
mode: str | None = None, mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
force: bool = False,
) -> None: ) -> None:
"""Remove a runner instance completely.""" """Remove a runner instance completely.
When *force* is ``True``, skip the remote Ansible playbook and
only remove the local registry entry. Use this when the remote
host is already gone or unreachable.
"""
if not gitea_url: if not gitea_url:
raise AnsibleError(_("GITEA_URL must be set (or pass --url)")) raise AnsibleError(_("GITEA_URL must be set (or pass --url)"))
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)
@@ -249,15 +255,16 @@ class RunnerManager:
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)")) raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host)) tracker.begin(_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host))
extra_vars = ( if not force:
f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}" 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 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) self._executor.run(
) cmd, description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host)
)
tracker.done() tracker.done()
tracker.begin(_("Remove runner '{name}' from local registry", name=name)) tracker.begin(_("Remove runner '{name}' from local registry", name=name))
+21
View File
@@ -375,6 +375,27 @@ class TestCLI:
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode=None, mode=None,
ask_become_pass=False, ask_become_pass=False,
force=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_remove_force_flag(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com"})
result = runner.invoke(cli, ["remove", "r1", "--token", "tok", "--force"])
assert result.exit_code == 0
mock_manager.remove.assert_called_once_with(
name="r1",
host=None,
user=None,
key=None,
token="tok",
gitea_url="https://git.example.com",
mode=None,
ask_become_pass=False,
force=True,
) )
@patch("gitea_runner_manager.cli.RunnerManager") @patch("gitea_runner_manager.cli.RunnerManager")
+11
View File
@@ -381,6 +381,17 @@ class TestRunnerManager:
manager.remove("r1", token="tok") manager.remove("r1", token="tok")
mock_registry.remove.assert_not_called() mock_registry.remove.assert_not_called()
def test_remove_force_skips_playbook(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.remove("r1", token="tok", gitea_url="https://git.example.com", force=True)
mock_executor.run.assert_not_called()
mock_registry.remove.assert_called_once_with("r1")
def test_list_runners(self) -> None: def test_list_runners(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {