mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 02:42:08 +00:00
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:
@@ -99,6 +99,13 @@ class CommandDescriptionsTests(unittest.TestCase):
|
|||||||
temporary = json.loads(path.read_text())
|
temporary = json.loads(path.read_text())
|
||||||
for section, key in (
|
for section, key in (
|
||||||
("archives", "emptyMessage"),
|
("archives", "emptyMessage"),
|
||||||
|
("archives", "formerJob"),
|
||||||
|
("archives", "formerJobTitle"),
|
||||||
|
("archives", "hiddenOtherNodes"),
|
||||||
|
("archives", "scopeAllNodes"),
|
||||||
|
("archives", "scopeAriaLabel"),
|
||||||
|
("archives", "scopeShowAll"),
|
||||||
|
("archives", "scopeThisNode"),
|
||||||
("destinations", "backupsKeptMessage"),
|
("destinations", "backupsKeptMessage"),
|
||||||
("destinations", "localAttachHelpMessage"),
|
("destinations", "localAttachHelpMessage"),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -100,11 +100,15 @@ interface RemoteSnapshot {
|
|||||||
encrypted?: boolean
|
encrypted?: boolean
|
||||||
borg_id?: string // Borg-only fields when available
|
borg_id?: string // Borg-only fields when available
|
||||||
borg_start_iso?: string
|
borg_start_iso?: string
|
||||||
|
// The node the backup belongs to, read from its name; null when the name
|
||||||
|
// does not say (another node's backup under a name of its own choosing).
|
||||||
|
node?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteArchivesResp {
|
interface RemoteArchivesResp {
|
||||||
snapshots: RemoteSnapshot[]
|
snapshots: RemoteSnapshot[]
|
||||||
errors: Array<{ backend: string; repo_name: string; error: string }>
|
errors: Array<{ backend: string; repo_name: string; error: string }>
|
||||||
|
hostname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportTask {
|
interface ExportTask {
|
||||||
@@ -132,6 +136,7 @@ interface UnifiedArchive {
|
|||||||
// One of the two is populated depending on `source`.
|
// One of the two is populated depending on `source`.
|
||||||
local?: BackupArchive
|
local?: BackupArchive
|
||||||
remote?: RemoteSnapshot // for both pbs and borg
|
remote?: RemoteSnapshot // for both pbs and borg
|
||||||
|
node: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackupArchive {
|
interface BackupArchive {
|
||||||
@@ -146,6 +151,8 @@ interface BackupArchive {
|
|||||||
kind: "manual" | "scheduled" | "legacy"
|
kind: "manual" | "scheduled" | "legacy"
|
||||||
profile: string | null
|
profile: string | null
|
||||||
source_hostname: string | null
|
source_hostname: string | null
|
||||||
|
node?: string | null // the node the backup belongs to
|
||||||
|
former_job?: boolean // made by a job of this node that no longer exists
|
||||||
// Which detection path identified this archive. Surfaced as a small tooltip
|
// Which detection path identified this archive. Surfaced as a small tooltip
|
||||||
// hint so the operator knows whether the metadata is authoritative
|
// hint so the operator knows whether the metadata is authoritative
|
||||||
// (sidecar) or inferred (filename / tar-peek).
|
// (sidecar) or inferred (filename / tar-peek).
|
||||||
@@ -964,11 +971,14 @@ export function HostBackup() {
|
|||||||
fetcher,
|
fetcher,
|
||||||
{ refreshInterval: 60000 },
|
{ refreshInterval: 60000 },
|
||||||
)
|
)
|
||||||
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[] }>(
|
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[]; hostname?: string }>(
|
||||||
"/api/host-backups/archives",
|
"/api/host-backups/archives",
|
||||||
fetcher,
|
fetcher,
|
||||||
{ refreshInterval: 30000 },
|
{ refreshInterval: 30000 },
|
||||||
)
|
)
|
||||||
|
// On a cluster every PBS holds the backups of all nodes: the list shows
|
||||||
|
// this node's by default, and all of them on request.
|
||||||
|
const [archiveScope, setArchiveScope] = useState<"node" | "all">("node")
|
||||||
|
|
||||||
// Background poll for the pending manual backup, independent of the
|
// Background poll for the pending manual backup, independent of the
|
||||||
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
|
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
|
||||||
@@ -1005,6 +1015,7 @@ export function HostBackup() {
|
|||||||
created_at: a.mtime,
|
created_at: a.mtime,
|
||||||
source_label: a.path.replace(/\/[^/]+$/, "") || "/",
|
source_label: a.path.replace(/\/[^/]+$/, "") || "/",
|
||||||
local: a,
|
local: a,
|
||||||
|
node: a.node ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1017,12 +1028,21 @@ export function HostBackup() {
|
|||||||
created_at: s.backup_time,
|
created_at: s.backup_time,
|
||||||
source_label: `${s.backend.toUpperCase()} ${s.repo_name}`,
|
source_label: `${s.backend.toUpperCase()} ${s.repo_name}`,
|
||||||
remote: s,
|
remote: s,
|
||||||
|
node: s.node ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort((a, b) => b.created_at - a.created_at)
|
out.sort((a, b) => b.created_at - a.created_at)
|
||||||
return out
|
return out
|
||||||
})()
|
})()
|
||||||
|
const thisNode = archivesResp?.hostname || remoteArchivesResp?.hostname || null
|
||||||
|
// The choice is offered only when some backup belongs to another node or
|
||||||
|
// its node is not known; a single node sees its list as before.
|
||||||
|
const otherNodeArchives = thisNode ? unifiedArchives.filter((u) => u.node !== thisNode).length : 0
|
||||||
|
const visibleArchives = archiveScope === "all" || otherNodeArchives === 0
|
||||||
|
? unifiedArchives
|
||||||
|
: unifiedArchives.filter((u) => u.node === thisNode)
|
||||||
|
const hiddenArchives = unifiedArchives.length - visibleArchives.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
@@ -1278,7 +1298,32 @@ export function HostBackup() {
|
|||||||
<Archive className="h-5 w-5 text-blue-500" />
|
<Archive className="h-5 w-5 text-blue-500" />
|
||||||
<CardTitle className="text-base font-semibold">{t("backup.archives.title")}</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("backup.archives.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{unifiedArchives.length}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
{otherNodeArchives > 0 && (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("backup.archives.scopeAriaLabel")}
|
||||||
|
className="inline-flex rounded-lg border border-border bg-muted/40 p-1 gap-1"
|
||||||
|
>
|
||||||
|
{(["node", "all"] as const).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={archiveScope === key}
|
||||||
|
onClick={() => setArchiveScope(key)}
|
||||||
|
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
archiveScope === key
|
||||||
|
? "bg-blue-500 text-white shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{key === "node" ? t("backup.archives.scopeThisNode") : t("backup.archives.scopeAllNodes")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">{visibleArchives.length}</Badge>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-[11px] text-muted-foreground mb-3">
|
<p className="text-[11px] text-muted-foreground mb-3">
|
||||||
@@ -1301,13 +1346,21 @@ export function HostBackup() {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t("backup.common.loading")}
|
{t("backup.common.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : unifiedArchives.length === 0 ? (
|
) : visibleArchives.length === 0 && hiddenArchives === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="text-sm text-muted-foreground py-4">
|
||||||
{t("backup.archives.emptyMessage")}
|
{t("backup.archives.emptyMessage")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{unifiedArchives.map((u) => {
|
{hiddenArchives > 0 && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t("backup.archives.hiddenOtherNodes", { count: hiddenArchives })}{" "}
|
||||||
|
<button type="button" className="text-blue-400 hover:underline" onClick={() => setArchiveScope("all")}>
|
||||||
|
{t("backup.archives.scopeShowAll")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visibleArchives.map((u) => {
|
||||||
const localKind = u.local?.kind
|
const localKind = u.local?.kind
|
||||||
const localJobId = u.local?.job_id
|
const localJobId = u.local?.job_id
|
||||||
const localHost = u.local?.source_hostname
|
const localHost = u.local?.source_hostname
|
||||||
@@ -1366,6 +1419,14 @@ export function HostBackup() {
|
|||||||
{t("backup.status.manual")}
|
{t("backup.status.manual")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{u.source === "local" && u.local?.former_job && (
|
||||||
|
<span
|
||||||
|
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||||
|
title={t("backup.archives.formerJobTitle")}
|
||||||
|
>
|
||||||
|
{t("backup.archives.formerJob")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
|
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
|
||||||
<span>{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: <code className="font-mono">{u.remote.backup_id}</code></span>
|
<span>{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: <code className="font-mono">{u.remote.backup_id}</code></span>
|
||||||
)}
|
)}
|
||||||
@@ -2035,7 +2096,19 @@ function InspectModal({
|
|||||||
<div><span className="text-muted-foreground">{t("backup.fields.createdLabel")}</span> {formatMtime(localArc.mtime)}</div>
|
<div><span className="text-muted-foreground">{t("backup.fields.createdLabel")}</span> {formatMtime(localArc.mtime)}</div>
|
||||||
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
|
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
|
||||||
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
|
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
|
||||||
{localArc.job_id && <div><span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code></div>}
|
{localArc.job_id && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code>
|
||||||
|
{localArc.former_job && (
|
||||||
|
<span
|
||||||
|
className="ml-2 uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||||
|
title={t("backup.archives.formerJobTitle")}
|
||||||
|
>
|
||||||
|
{t("backup.archives.formerJob")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
|
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
|
||||||
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
|
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
|
||||||
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
|
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
|
||||||
|
|||||||
@@ -3953,8 +3953,15 @@
|
|||||||
"emptyAfter": "backups yet.",
|
"emptyAfter": "backups yet.",
|
||||||
"emptyBefore": "No",
|
"emptyBefore": "No",
|
||||||
"emptyMessage": "No backup archives found.",
|
"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",
|
"encrypted": "Encrypted",
|
||||||
"encryptedTitle": "This backup is 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",
|
"filesInBackup": "Files in backup",
|
||||||
"group": "Group",
|
"group": "Group",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
|||||||
@@ -3953,8 +3953,15 @@
|
|||||||
"downloadTitle": "Descargar este backup",
|
"downloadTitle": "Descargar este backup",
|
||||||
"emptyAfter": "backups todavía.",
|
"emptyAfter": "backups todavía.",
|
||||||
"emptyBefore": "No",
|
"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",
|
"encrypted": "cifrado",
|
||||||
"encryptedTitle": "Este backup está 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",
|
"filesInBackup": "Archivos del backup",
|
||||||
"group": "Grupo",
|
"group": "Grupo",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
|||||||
@@ -16151,6 +16151,56 @@ def _known_job_ids():
|
|||||||
return {os.path.basename(p)[:-len('.env')] for p in env_files}
|
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
|
# In-process cache for the tar-peek fallback so we don't re-decompress
|
||||||
# every archive on every Monitor refresh. Keyed by absolute archive
|
# every archive on every Monitor refresh. Keyed by absolute archive
|
||||||
# path; the cached tuple is (size, mtime, is_proxmenux_backup_bool).
|
# 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})
|
errors.append({'backend': 'borg', 'repo_name': target['name'], 'error': err})
|
||||||
snapshots.extend(items)
|
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'])
|
@app.route('/api/host-backups/remote-archives', methods=['DELETE'])
|
||||||
@@ -20962,6 +21016,7 @@ def api_host_backups_archives():
|
|||||||
seen: set = set()
|
seen: set = set()
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
job_ids = _known_job_ids()
|
job_ids = _known_job_ids()
|
||||||
|
own_names = _host_backup_own_names(hostname)
|
||||||
|
|
||||||
for d in _collect_backup_scan_dirs():
|
for d in _collect_backup_scan_dirs():
|
||||||
try:
|
try:
|
||||||
@@ -20988,10 +21043,15 @@ def api_host_backups_archives():
|
|||||||
'size_bytes': st.st_size,
|
'size_bytes': st.st_size,
|
||||||
'mtime': int(st.st_mtime),
|
'mtime': int(st.st_mtime),
|
||||||
**info,
|
**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)
|
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()
|
||||||
Reference in New Issue
Block a user