diff --git a/osism/commands/get.py b/osism/commands/get.py index 23f8b4ec5..31d756277 100644 --- a/osism/commands/get.py +++ b/osism/commands/get.py @@ -2,6 +2,7 @@ from datetime import datetime import pprint +import re import subprocess import json @@ -263,6 +264,83 @@ def take_action(self, parsed_args): return +# Printed once by kolla-mariadb-backup-host.yml: the host the backup runs on +# (empty if none) and the host each shard resolved. +BACKUP_HOST_PATTERN = re.compile(r"mariadb_backup_host=([^\s\"]*) resolved=([^\s\"]*)") + + +class MariadbBackupHost(Command): + """Show the host that 'osism apply mariadb-backup' writes archives to. + + The value is resolved by the mariadb role itself, in a read-only play on + the kolla-ansible worker, because its default, the shard grouping it + depends on and the usual override are all outside the inventory. + """ + + def get_parser(self, prog_name): + parser = super(MariadbBackupHost, self).get_parser(prog_name) + parser.add_argument( + "--format", + default="table", + choices=["table", "script"], + help="Output type; script prints the bare host name", + ) + parser.add_argument( + "--timeout", + default=300, + type=int, + help="Seconds to wait for the play", + ) + return parser + + def take_action(self, parsed_args): + from celery.exceptions import TimeoutError as CeleryTimeoutError + + from osism.tasks import AnsibleFailure, kolla + + utils.check_task_lock_and_exit() + + t = kolla.run.delay("kolla", "mariadb-backup-host", [], publish=False) + try: + output = t.get(timeout=parsed_args.timeout) + except AnsibleFailure as exc: + logger.error( + f"{exc}. 'osism apply -e kolla mariadb-backup-host' shows " + "the full output." + ) + return 1 + except CeleryTimeoutError: + logger.error(f"Timeout while waiting for task {t.task_id}.") + # Unsubscribe now; left pending, the result is released during + # interpreter shutdown and Celery prints a traceback. + t.forget() + return 1 + + match = BACKUP_HOST_PATTERN.search(output) + if not match: + logger.error("The play did not report a backup host.") + return 1 + + hosts = [host for host in match.group(1).split(",") if host] + if not hosts: + # kolla skips the backup silently in this case. + resolved = match.group(2).replace(",", ", ") + logger.error( + f"No backup would be taken: no resolved backup host " + f"({resolved}) is in the default MariaDB shard." + ) + return 1 + + if parsed_args.format == "script": + print("\n".join(hosts)) + else: + print( + tabulate([[host] for host in hosts], headers=["Host"], tablefmt="psql") + ) + + return + + class States(Command): def get_parser(self, prog_name): parser = super(States, self).get_parser(prog_name) diff --git a/setup.cfg b/setup.cfg index 0c4b10d3f..bafd77e5e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,7 @@ osism.commands: get facts = osism.commands.get:Facts get hosts = osism.commands.get:Hosts get hostvars = osism.commands.get:Hostvars + get mariadb-backup-host = osism.commands.get:MariadbBackupHost get logs ansible = osism.commands.log:Ansible get logs container = osism.commands.log:Container get logs file = osism.commands.log:File diff --git a/tests/unit/commands/test_get.py b/tests/unit/commands/test_get.py index 76542ebff..70fcc1d0c 100644 --- a/tests/unit/commands/test_get.py +++ b/tests/unit/commands/test_get.py @@ -420,3 +420,105 @@ def test_states_prints_nothing_without_cache_entry(monkeypatch, capsys): cmd.take_action(parsed_args) assert capsys.readouterr().out == "" + + +# --- MariadbBackupHost.take_action --- + + +def _backup_line(selected, resolved): + return ( + "ok: [testbed-node-0] => {\n" + f' "msg": "mariadb_backup_host={selected} resolved={resolved}"\n' + "}\n" + ) + + +def _run_backup_host(args, output=None, side_effect=None): + cmd = _make(get.MariadbBackupHost) + parsed_args = cmd.get_parser("test").parse_args(args) + + task = MagicMock() + if side_effect is not None: + task.get.side_effect = side_effect + else: + task.get.return_value = output + + with patch("osism.commands.get.utils.check_task_lock_and_exit"), patch( + "osism.tasks.kolla.run" + ) as mock_run: + mock_run.delay.return_value = task + result = cmd.take_action(parsed_args) + + return result, mock_run + + +def test_mariadb_backup_host_runs_the_play_without_publishing(capsys): + result, mock_run = _run_backup_host( + [], output=_backup_line("testbed-node-0", "testbed-node-0") + ) + + assert not result + mock_run.delay.assert_called_once_with( + "kolla", "mariadb-backup-host", [], publish=False + ) + assert "testbed-node-0" in capsys.readouterr().out + + +def test_mariadb_backup_host_script_format_prints_bare_host(capsys): + # Colour codes as the worker emits them must not leak into the host name. + output = "\x1b[0;32m" + _backup_line("testbed-node-1", "testbed-node-1") + "\x1b[0m" + + result, _ = _run_backup_host(["--format", "script"], output=output) + + assert not result + assert capsys.readouterr().out == "testbed-node-1\n" + + +def test_mariadb_backup_host_ignores_other_shards(capsys): + # Every shard resolves a host; only the default shard's is backed up. + output = _backup_line("db-0", "db-0,db-shard1-0") + + result, _ = _run_backup_host(["--format", "script"], output=output) + + assert not result + assert capsys.readouterr().out == "db-0\n" + + +def test_mariadb_backup_host_fails_when_no_backup_would_be_taken(loguru_logs, capsys): + result, _ = _run_backup_host([], output=_backup_line("", "db-shard1-0")) + + assert result == 1 + assert capsys.readouterr().out == "" + assert any( + r["level"] == "ERROR" and "db-shard1-0" in r["message"] for r in loguru_logs + ) + + +def test_mariadb_backup_host_fails_without_a_report(loguru_logs): + result, _ = _run_backup_host([], output="PLAY RECAP\n") + + assert result == 1 + assert any("did not report a backup host" in r["message"] for r in loguru_logs) + + +def test_mariadb_backup_host_fails_when_the_play_fails(loguru_logs): + from osism.tasks import AnsibleFailure + + result, _ = _run_backup_host( + [], side_effect=AnsibleFailure("kolla-ansible play failed with rc 1") + ) + + assert result == 1 + assert any("osism apply -e kolla" in r["message"] for r in loguru_logs) + + +def test_mariadb_backup_host_fails_on_timeout(loguru_logs): + from celery.exceptions import TimeoutError as CeleryTimeoutError + + result, mock_run = _run_backup_host( + ["--timeout", "5"], side_effect=CeleryTimeoutError() + ) + + assert result == 1 + assert any("Timeout" in r["message"] for r in loguru_logs) + mock_run.delay.return_value.forget.assert_called_once_with()