- {unifiedArchives.map((u) => {
+ {hiddenArchives > 0 && (
+
+ {t("backup.archives.hiddenOtherNodes", { count: hiddenArchives })}{" "}
+
+
+ )}
+ {visibleArchives.map((u) => {
const localKind = u.local?.kind
const localJobId = u.local?.job_id
const localHost = u.local?.source_hostname
@@ -1366,6 +1419,14 @@ export function HostBackup() {
{t("backup.status.manual")}
) : null}
+ {u.source === "local" && u.local?.former_job && (
+
+ {t("backup.archives.formerJob")}
+
+ )}
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: {u.remote.backup_id}
)}
@@ -2035,7 +2096,19 @@ function InspectModal({
{t("backup.fields.createdLabel")} {formatMtime(localArc.mtime)}
{t("backup.fields.sizeLabel")} {formatBytes(localArc.size_bytes)}
{t("backup.fields.pathLabel")} {localArc.path}
- {localArc.job_id &&
{t("backup.fields.jobIdLabel")} {localArc.job_id}
}
+ {localArc.job_id && (
+
+ {t("backup.fields.jobIdLabel")} {localArc.job_id}
+ {localArc.former_job && (
+
+ {t("backup.archives.formerJob")}
+
+ )}
+
+ )}
{localArc.profile &&
{t("backup.fields.profileLabel")} {localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}
}
{localArc.source_hostname &&
{t("backup.fields.sourceHostLabel")} {localArc.source_hostname}
}
{t("backup.fields.detectedViaLabel")} {localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index d2a012a0..9b590728 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -3953,8 +3953,15 @@
"emptyAfter": "backups yet.",
"emptyBefore": "No",
"emptyMessage": "No backup archives found.",
+ "hiddenOtherNodes": "Backups of other nodes not shown: {count}.",
+ "scopeAllNodes": "All nodes",
+ "scopeAriaLabel": "Backups to show",
+ "scopeShowAll": "Show all",
+ "scopeThisNode": "This node",
"encrypted": "Encrypted",
"encryptedTitle": "This backup is encrypted",
+ "formerJob": "Former job",
+ "formerJobTitle": "The job that made this backup no longer exists on this node. The backup is kept until it is deleted.",
"filesInBackup": "Files in backup",
"group": "Group",
"host": "Host",
diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json
index 283c0401..205176d4 100644
--- a/AppImage/messages/es/common.json
+++ b/AppImage/messages/es/common.json
@@ -3953,8 +3953,15 @@
"downloadTitle": "Descargar este backup",
"emptyAfter": "backups todavía.",
"emptyBefore": "No",
+ "hiddenOtherNodes": "Backups de otros nodos no mostrados: {count}.",
+ "scopeAllNodes": "Todos los nodos",
+ "scopeAriaLabel": "Backups que se muestran",
+ "scopeShowAll": "Mostrar todos",
+ "scopeThisNode": "Este nodo",
"encrypted": "cifrado",
"encryptedTitle": "Este backup está cifrado",
+ "formerJob": "Trabajo anterior",
+ "formerJobTitle": "El trabajo que hizo este backup ya no existe en este nodo. El backup se conserva hasta que se elimine.",
"filesInBackup": "Archivos del backup",
"group": "Grupo",
"host": "Host",
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 284f7f3f..6c0d1e86 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -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-
`, 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})
# ──────────────────────────────────────────────────────────────
diff --git a/AppImage/scripts/tests/test_cluster_node_membership_notifications.py b/AppImage/scripts/tests/test_cluster_node_membership_notifications.py
new file mode 100644
index 00000000..a831cfa3
--- /dev/null
+++ b/AppImage/scripts/tests/test_cluster_node_membership_notifications.py
@@ -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()
diff --git a/AppImage/scripts/tests/test_host_backup_node.py b/AppImage/scripts/tests/test_host_backup_node.py
new file mode 100644
index 00000000..311146e9
--- /dev/null
+++ b/AppImage/scripts/tests/test_host_backup_node.py
@@ -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()