set apart host backups of other nodes and of former jobs

- Each host backup carries the node it belongs to, read from its source host or its name (`hostcfg-<node>`, a job of this node or its PBS backup ID); both backup lists also return the node name.
- The backup list offers This node / All nodes when some backup belongs to another node, with This node as the default, a count that follows the choice and a note with the backups not shown.
- Backups whose node cannot be read from their name are listed under All nodes.
- A local backup made on this node by a job that no longer exists is marked as Former job, read from its metadata file; backups without it, manual ones and those of other nodes are not marked.
- New texts in English and Spanish, modelled in the translation steady-state test.
- Tests for the node and the former job read from each backup, and for the cluster node membership notifications.
This commit is contained in:
MacRimi
2026-10-14 18:04:42 +02:00
parent f2c5ab1636
commit c9479c6ac1
7 changed files with 358 additions and 7 deletions
@@ -0,0 +1,106 @@
"""Corosync reports a node that leaves or comes back with its number, in a
membership line that never says "node". The surviving nodes notify it by the
name the node has in corosync.conf."""
import sys
import tempfile
import unittest
from pathlib import Path
from queue import Empty, Queue
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import notification_events # noqa: E402
CONF = """totem {
cluster_name: lab
}
nodelist {
node {
name: pve1
nodeid: 1
ring0_addr: 10.0.0.1
}
node {
name: pve2
nodeid: 2
ring0_addr: 10.0.0.2
}
node {
name: pve3
nodeid: 3
ring0_addr: 10.0.0.3
}
}
"""
class ClusterMembershipTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
conf = Path(folder.name) / "corosync.conf"
conf.write_text(CONF)
real_open = open
def corosync_conf(path, *args, **kwargs):
if str(path) in ("/etc/pve/corosync.conf", "/etc/corosync/corosync.conf"):
path = conf
return real_open(path, *args, **kwargs)
self.booting = False
for item in (patch("builtins.open", corosync_conf),
patch.object(notification_events.socket, "gethostname", return_value="pve1"),
patch.object(notification_events.startup_grace, "is_real_system_boot",
side_effect=lambda: self.booting),
patch.object(notification_events._shared_state, "is_startup_health_grace",
side_effect=lambda: self.booting)):
item.start()
self.addCleanup(item.stop)
self.queue = Queue()
self.watcher = notification_events.JournalWatcher(self.queue)
def notified(self, line):
self.watcher._check_cluster_events(line, "corosync")
events = []
while True:
try:
event = self.queue.get_nowait()
except Empty:
return events
events.append((event.event_type, event.data.get("node_name")))
def test_a_node_that_leaves_is_named(self):
self.assertEqual(self.notified("[TOTEM ] A new membership (1.55) was formed. Members left: 2"),
[("node_disconnect", "pve2")])
def test_a_node_that_comes_back_is_named(self):
self.assertEqual(self.notified("[TOTEM ] A new membership (1.5a) was formed. Members joined: 2"),
[("node_reconnect", "pve2")])
def test_every_node_of_the_line_is_notified(self):
self.assertEqual(self.notified("[TOTEM ] A new membership (1.60) was formed. Members left: 2 3"),
[("node_disconnect", "pve2"), ("node_disconnect", "pve3")])
def test_this_node_is_not_notified(self):
self.assertEqual(self.notified("[TOTEM ] A new membership (1.61) was formed. Members joined: 1"), [])
def test_while_this_host_boots_only_the_joins_are_left_out(self):
self.booting = True
self.assertEqual(self.notified("[TOTEM ] A new membership (1.62) was formed. Members joined: 2"), [])
self.assertEqual(self.notified("[TOTEM ] A new membership (1.63) was formed. Members left: 3"),
[("node_disconnect", "pve3")])
def test_a_node_missing_from_the_configuration_keeps_its_number(self):
self.assertEqual(self.notified("[TOTEM ] A new membership (1.64) was formed. Members left: 7"),
[("node_disconnect", "7")])
def test_other_corosync_lines_notify_nothing(self):
for line in ("[CFG ] Node 2 was shut down by sysadmin", "[QUORUM] Members[2]: 1 3",
"[KNET ] link: host: 2 link: 0 is down"):
with self.subTest(line=line):
self.assertEqual(self.notified(line), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,91 @@
"""Each host backup in the list carries the node it belongs to, read from its
name, so a cluster node can show its own backups apart from the others, and
a local backup says when the job that made it no longer exists."""
import ast
import os
from pathlib import Path
import re
import shlex
import tempfile
import unittest
SERVER = Path(__file__).resolve().parents[1] / 'flask_server.py'
HELPERS = ('_parse_job_env', '_host_backup_own_names', '_host_backup_node',
'_host_backup_from_former_job')
class HostBackupNodeTests(unittest.TestCase):
def setUp(self):
text = SERVER.read_text(encoding='utf-8')
tree = ast.parse(text)
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in HELPERS]
self.assertEqual(sorted(n.name for n in nodes), sorted(HELPERS))
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.jobs = folder.name
self.scope = {'os': os, 're': re, 'shlex': shlex,
'_BACKUP_JOBS_DIR': self.jobs,
'_BACKUP_TAR_SUFFIXES': ('.tar', '.tar.zst', '.tar.gz'),
'_HOST_BACKUP_STAMP_RE': re.compile(r'-\d{8}_\d{6}$')}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope)
self.own_names = self.scope['_host_backup_own_names']
self.node = self.scope['_host_backup_node']
self.former = self.scope['_host_backup_from_former_job']
def job(self, job_id, backup_id=None):
lines = [f'JOB_ID={job_id}']
if backup_id:
lines.append(f'PBS_BACKUP_ID={backup_id}')
Path(self.jobs, f'{job_id}.env').write_text('\n'.join(lines) + '\n')
def test_the_default_name_says_the_node(self):
own = self.own_names('pve1')
self.assertEqual(self.node('hostcfg-pve1', 'pve1', own), 'pve1')
self.assertEqual(self.node('hostcfg-pve2', 'pve1', own), 'pve2')
def test_a_local_archive_name_says_the_node(self):
own = self.own_names('pve1')
self.assertEqual(self.node('hostcfg-pve2-20261014_031500.tar.zst', 'pve1', own), 'pve2')
def test_a_job_of_this_node_is_this_node(self):
self.job('nightly-pbs', backup_id='my-host-config')
own = self.own_names('pve1')
self.assertEqual(self.node('nightly-pbs-20261014_031500.tar.zst', 'pve1', own), 'pve1')
self.assertEqual(self.node('my-host-config', 'pve1', own), 'pve1')
def test_a_name_that_does_not_say_is_unknown(self):
own = self.own_names('pve1')
self.assertIsNone(self.node('custom-backup', 'pve1', own))
self.assertIsNone(self.node('hostcfg-', 'pve1', own))
self.assertIsNone(self.node(None, 'pve1', own))
def test_a_missing_jobs_folder_leaves_the_default_name(self):
self.scope['_BACKUP_JOBS_DIR'] = os.path.join(self.jobs, 'absent')
self.assertEqual(self.own_names('pve1'), {'hostcfg-pve1'})
def sidecar(self, job_id, hostname='pve1'):
return {'kind': 'scheduled', 'job_id': job_id, 'source_hostname': hostname,
'detected_via': 'sidecar'}
def test_a_backup_of_a_job_that_no_longer_exists_is_from_a_former_job(self):
self.assertTrue(self.former(self.sidecar('PVE1-local-backuo'), 'pve1', {'PVE1-local-backup'}))
def test_a_backup_of_an_existing_job_is_not(self):
self.assertFalse(self.former(self.sidecar('PVE1-local-backup'), 'pve1', {'PVE1-local-backup'}))
def test_a_backup_made_on_another_node_is_left_unknown(self):
self.assertFalse(self.former(self.sidecar('pve2-nightly', hostname='pve2'), 'pve1', set()))
def test_a_backup_without_its_sidecar_is_left_unknown(self):
for info in ({'kind': 'legacy', 'job_id': None, 'source_hostname': None, 'detected_via': 'tar_peek'},
{'kind': 'manual', 'job_id': None, 'source_hostname': None, 'detected_via': 'hostcfg_prefix'}):
self.assertFalse(self.former(info, 'pve1', set()))
def test_a_manual_backup_has_no_job(self):
info = self.sidecar(None)
info['kind'] = 'manual'
self.assertFalse(self.former(info, 'pve1', set()))
if __name__ == '__main__':
unittest.main()