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
+62 -2
View File
@@ -16151,6 +16151,56 @@ def _known_job_ids():
return {os.path.basename(p)[:-len('.env')] for p in env_files}
# The date a host backup carries at the end of its name.
_HOST_BACKUP_STAMP_RE = re.compile(r'-\d{8}_\d{6}$')
def _host_backup_own_names(hostname):
"""Names this node keeps its host backups under: `hostcfg-<node>`, the
id of each of its jobs, which names its archives, and the PBS backup ID
each job uses."""
import glob
names = {f'hostcfg-{hostname}'}
try:
env_files = glob.glob(f'{_BACKUP_JOBS_DIR}/*.env')
except OSError:
env_files = []
for path in env_files:
names.add(os.path.basename(path)[:-len('.env')])
backup_id = (_parse_job_env(path).get('PBS_BACKUP_ID') or '').strip()
if backup_id:
names.add(backup_id)
return names
def _host_backup_from_former_job(info, hostname, job_ids):
"""True when the job that made this backup no longer exists on this
node. Only the sidecar says which job made it, and only a backup made
on this node can be measured against this node's jobs; anything else
is left unknown."""
if info.get('detected_via') != 'sidecar':
return False
job_id = info.get('job_id')
return bool(job_id) and info.get('source_hostname') == hostname and job_id not in job_ids
def _host_backup_node(name, hostname, own_names):
"""The node a host backup belongs to, read from its name, or None when
the name does not say: another node's backup stored under a name of its
own choosing."""
stem = name or ''
for suffix in _BACKUP_TAR_SUFFIXES:
if stem.endswith(suffix):
stem = stem[:-len(suffix)]
break
stem = _HOST_BACKUP_STAMP_RE.sub('', stem)
if stem in own_names:
return hostname
if stem.startswith('hostcfg-') and len(stem) > len('hostcfg-'):
return stem[len('hostcfg-'):]
return None
# In-process cache for the tar-peek fallback so we don't re-decompress
# every archive on every Monitor refresh. Keyed by absolute archive
# path; the cached tuple is (size, mtime, is_proxmenux_backup_bool).
@@ -20398,7 +20448,11 @@ def api_host_backups_remote_archives():
errors.append({'backend': 'borg', 'repo_name': target['name'], 'error': err})
snapshots.extend(items)
return jsonify({'snapshots': snapshots, 'errors': errors})
hostname = socket.gethostname()
own_names = _host_backup_own_names(hostname)
for snapshot in snapshots:
snapshot['node'] = _host_backup_node(snapshot.get('backup_id'), hostname, own_names)
return jsonify({'snapshots': snapshots, 'errors': errors, 'hostname': hostname})
@app.route('/api/host-backups/remote-archives', methods=['DELETE'])
@@ -20962,6 +21016,7 @@ def api_host_backups_archives():
seen: set = set()
hostname = socket.gethostname()
job_ids = _known_job_ids()
own_names = _host_backup_own_names(hostname)
for d in _collect_backup_scan_dirs():
try:
@@ -20988,10 +21043,15 @@ def api_host_backups_archives():
'size_bytes': st.st_size,
'mtime': int(st.st_mtime),
**info,
# An archive on this node's disk whose origin is not known
# is taken as this node's.
'node': (info.get('source_hostname')
or _host_backup_node(name, hostname, own_names) or hostname),
'former_job': _host_backup_from_former_job(info, hostname, job_ids),
})
archives.sort(key=lambda a: a['mtime'], reverse=True)
return jsonify({'archives': archives})
return jsonify({'archives': archives, 'hostname': hostname})
# ──────────────────────────────────────────────────────────────
@@ -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()