From ad76e975e6017febe5ccef5e74775f47814331b1 Mon Sep 17 00:00:00 2001 From: Roger Luethi Date: Sun, 27 Sep 2026 17:44:52 +0200 Subject: [PATCH] Add osism get mariadb-backup-host "osism apply mariadb-backup" writes its archives to one MariaDB host, and nothing in osism can say which one before a backup is taken. The mariadb role resolves it from a role default over a shard group built during the play, and the usual override is an extra var from environments/kolla/configuration.yml. None of that is in the inventory, so "osism get hostvars" reports the variable as not found, and "osism get hosts" sorts alphabetically and cannot show the inventory order the default depends on. Operators are left to probe each MariaDB host for /etc/kolla/mariabackup. Add "osism get mariadb-backup-host". It runs the read-only kolla-mariadb-backup-host.yml play on the kolla-ansible worker, which asks the mariadb role itself. The play evaluates the backup task's own condition on every MariaDB host, across all shards, and reports once: mariadb_backup_host= resolved= The first field is the host the backup runs on, empty if there is none; the second lists what each shard resolved. The command dispatches the play with kolla.run and publish=False and takes the play output from the task result instead of streaming it, so only the answer is printed. A table is printed by default; --format script prints the bare host name, for use in shell commands. The command exits non-zero if the play reports no backup host, naming the resolved candidates: kolla backs up only the default shard and would skip the backup without a word, for example when mariadb_backup_host is overridden to a host outside it. It also exits non-zero if the play reports nothing, if the play fails, and on timeout. Since the play output is not streamed, the error for a failed play points at "osism apply -e kolla mariadb-backup-host" for the full output. On timeout the pending result is forgotten explicitly; left to interpreter shutdown, Celery prints a traceback. This depends on kolla-mariadb-backup-host.yml in osism/container-image-kolla-ansible, which has to be merged first. Tested on a 2026.1 cluster with the playbook placed in the kolla-ansible container: the command printed the host holding /etc/kolla/mariabackup in both formats; with a failing play (the playbook removed) and with a 1 s timeout it exited 1 with the respective error. The sharded and no-backup-host reports were verified at the play level, with a second shard simulated through an extra inventory source. DocImpact Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Roger Luethi --- osism/commands/get.py | 78 ++++++++++++++++++++++++ setup.cfg | 1 + tests/unit/commands/test_get.py | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) 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()