mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Reproduce cross-scope Proxmox backup attribution
Cover prefix and reused node names, conflicting PVE instances, PBS namespace ambiguity, source-separated orphan artifacts and task attribution. These controls are synthetic recovery-model evidence, not native backup or release acceptance. Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import type { BackupTask, GuestSnapshot, PBSBackup, StorageBackup } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
export const BACKUP_IDENTITY_NOW = Date.parse('2026-10-04T12:00:00Z');
|
||||
|
||||
export const identityGuest = (
|
||||
id: string,
|
||||
instance: string,
|
||||
node: string,
|
||||
extra: Partial<NonNullable<Resource['proxmox']>> = {},
|
||||
): Resource =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
type: 'vm',
|
||||
platformId: instance,
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'running',
|
||||
lastSeen: BACKUP_IDENTITY_NOW,
|
||||
proxmox: { vmid: 100, instance, nodeName: node, ...extra },
|
||||
}) as Resource;
|
||||
|
||||
export const identityArchive = (extra: Partial<StorageBackup> = {}): StorageBackup => ({
|
||||
id: 'file-100',
|
||||
instance: 'east',
|
||||
node: 'pve-1',
|
||||
type: 'vm',
|
||||
vmid: 100,
|
||||
storage: 'local',
|
||||
time: '2026-10-04T11:00:00Z',
|
||||
ctime: BACKUP_IDENTITY_NOW / 1000 - 3600,
|
||||
size: 1024,
|
||||
format: 'zst',
|
||||
volid: 'local:backup/vzdump-qemu-100.vma.zst',
|
||||
protected: false,
|
||||
isPBS: false,
|
||||
verified: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const identitySnapshot = (extra: Partial<GuestSnapshot> = {}): GuestSnapshot => ({
|
||||
id: 'snapshot-100',
|
||||
instance: 'east',
|
||||
node: 'pve-1',
|
||||
type: 'vm',
|
||||
vmid: 100,
|
||||
name: 'before-update',
|
||||
time: '2026-10-04T11:30:00Z',
|
||||
vmstate: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const identityPBS = (extra: Partial<PBSBackup> = {}): PBSBackup => ({
|
||||
id: 'pbs-100',
|
||||
instance: 'pbs-server',
|
||||
namespace: 'pve-1',
|
||||
datastore: 'main',
|
||||
backupType: 'vm',
|
||||
vmid: '100',
|
||||
backupTime: '2026-10-04T10:00:00Z',
|
||||
size: 2048,
|
||||
files: ['index.json.blob'],
|
||||
protected: false,
|
||||
verified: true,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const identityTask = (extra: Partial<BackupTask> = {}): BackupTask => ({
|
||||
id: 'task-100',
|
||||
instance: 'east',
|
||||
node: 'pve-1',
|
||||
type: 'vzdump',
|
||||
vmid: 100,
|
||||
status: 'OK',
|
||||
startTime: '2026-10-04T11:00:00Z',
|
||||
endTime: '2026-10-04T11:05:00Z',
|
||||
...extra,
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
BACKUP_IDENTITY_NOW,
|
||||
identityArchive,
|
||||
identityGuest,
|
||||
identityPBS,
|
||||
identitySnapshot,
|
||||
identityTask,
|
||||
} from '../__fixtures__/backupIdentity';
|
||||
import { buildProxmoxBackupRecoveryModel } from '../proxmoxBackupRecoveryModel';
|
||||
|
||||
type Input = Parameters<typeof buildProxmoxBackupRecoveryModel>[0];
|
||||
const model = (input: Partial<Input>) =>
|
||||
buildProxmoxBackupRecoveryModel({
|
||||
workloads: [],
|
||||
archives: [],
|
||||
snapshots: [],
|
||||
pbsBackups: [],
|
||||
tasks: [],
|
||||
nowMs: BACKUP_IDENTITY_NOW,
|
||||
...input,
|
||||
});
|
||||
const owners = (result: ReturnType<typeof model>) =>
|
||||
result.recoverableArtifacts.map((artifact) => artifact.workload.resourceId);
|
||||
|
||||
describe('backup-identity: native ownership rather than first label match', () => {
|
||||
it.each([false, true])('is independent of inventory order (reversed: %s)', (reversed) => {
|
||||
const workloads = [identityGuest('west-100', 'west', 'pve-10'), identityGuest('east-100', 'east', 'pve-1')];
|
||||
if (reversed) workloads.reverse();
|
||||
const result = model({ workloads, archives: [identityArchive()], snapshots: [identitySnapshot()], pbsBackups: [identityPBS()], tasks: [identityTask()] });
|
||||
expect(owners(result)).toEqual(['east-100', 'east-100', 'east-100']);
|
||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined();
|
||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100');
|
||||
});
|
||||
|
||||
it('uses the PVE instance before a reused node name', () => {
|
||||
const result = model({
|
||||
workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')],
|
||||
archives: [identityArchive()], snapshots: [identitySnapshot()], tasks: [identityTask()],
|
||||
});
|
||||
expect(owners(result)).toEqual(['east-100', 'east-100']);
|
||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'west-100')?.latestTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps same-instance migrated backups without using the old node to select another cluster', () => {
|
||||
const result = model({
|
||||
workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-2')],
|
||||
archives: [identityArchive()], tasks: [identityTask()],
|
||||
});
|
||||
expect(owners(result)).toEqual(['east-100']);
|
||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100');
|
||||
});
|
||||
|
||||
it('does not let a matching node override a conflicting PVE instance, even with one visible guest', () => {
|
||||
const result = model({
|
||||
workloads: [identityGuest('east-100', 'east', 'pve-1')],
|
||||
archives: [identityArchive({ instance: 'removed-east' })], snapshots: [identitySnapshot({ instance: 'removed-east' })],
|
||||
tasks: [identityTask({ instance: 'removed-east' })],
|
||||
});
|
||||
expect(owners(result)).toEqual([undefined, undefined]);
|
||||
const live = result.coverageRows.find((row) => row.workload.resourceId === 'east-100')!;
|
||||
expect(live.artifacts).toEqual([]);
|
||||
expect(live.latestTask).toBeUndefined();
|
||||
const orphan = result.coverageRows.find((row) => row.isOrphaned)!;
|
||||
expect(orphan.latestTask?.id).toBe('task-100');
|
||||
expect(orphan.posture).toBe('not-evaluated');
|
||||
});
|
||||
|
||||
it('does not accept a partial node match or contradictory singleton when PVE instance is absent', () => {
|
||||
const result = model({
|
||||
workloads: [identityGuest('west-100', '', 'pve-10')],
|
||||
archives: [identityArchive({ instance: '' })], snapshots: [identitySnapshot({ instance: '' })], tasks: [identityTask({ instance: '' })],
|
||||
});
|
||||
expect(owners(result)).toEqual([undefined, undefined]);
|
||||
expect(result.coverageRows.find((row) => row.workload.resourceId)?.latestTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts exact native aliases, not mutable display labels', () => {
|
||||
const workloads = [identityGuest('west-100', 'west', 'pve-10', { nodeDisplayName: 'pve-1' }), identityGuest('east-100', 'east', 'pve-new', { nodeAliases: ['pve-1'] })];
|
||||
const result = model({ workloads, archives: [identityArchive({ instance: '' })], pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] });
|
||||
expect(owners(result)).toEqual(['east-100', 'east-100']);
|
||||
expect(result.coverageRows.find((row) => row.workload.resourceId === 'east-100')?.latestTask?.id).toBe('task-100');
|
||||
});
|
||||
|
||||
it('does not turn a reused namespace/node or alias into a unique PBS owner', () => {
|
||||
const result = model({ workloads: [identityGuest('west-100', 'west', 'shared', { nodeAliases: ['pve-1'] }), identityGuest('east-100', 'east', 'shared', { nodeAliases: ['pve-1'] })], pbsBackups: [identityPBS()] });
|
||||
expect(owners(result)).toEqual([undefined]);
|
||||
expect(result.coverageRows.filter((row) => row.workload.resourceId).every((row) => row.pbsCount === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not use a datastore label as a PVE workload identity', () => {
|
||||
const result = model({ workloads: [identityGuest('west-100', 'west', 'main'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] });
|
||||
expect(owners(result)).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('preserves root-namespace PBS for a unique VMID and exact instance namespace with collisions', () => {
|
||||
expect(owners(model({ workloads: [identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: '' })] }))).toEqual(['east-100']);
|
||||
expect(owners(model({ workloads: [identityGuest('west-100', 'west', 'pve-1'), identityGuest('east-100', 'east', 'pve-1')], pbsBackups: [identityPBS({ namespace: ' EAST ' })] }))).toEqual(['east-100']);
|
||||
});
|
||||
|
||||
it('retains exact-node matching without instance metadata and the unscoped singleton control', () => {
|
||||
const workload = identityGuest('east-100', '', 'pve-1');
|
||||
expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '' })] }))).toEqual(['east-100']);
|
||||
expect(owners(model({ workloads: [workload], archives: [identityArchive({ instance: '', node: '' })] }))).toEqual(['east-100']);
|
||||
});
|
||||
|
||||
it('leaves an ambiguous unknown-type task unattached across VM and LXC identities', () => {
|
||||
const vm = identityGuest('vm-100', 'east', 'pve-1');
|
||||
const ct = { ...identityGuest('ct-100', 'east', 'pve-1'), type: 'system-container' } as typeof vm;
|
||||
const result = model({ workloads: [vm, ct], tasks: [identityTask()] });
|
||||
expect(result.coverageRows.every((row) => row.latestTask === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves unresolved artifacts but does not pool identically named PBS sources', () => {
|
||||
const result = model({ pbsBackups: [identityPBS({ id: 'pbs-a', instance: 'pbs-a' }), identityPBS({ id: 'pbs-b', instance: 'pbs-b' })] });
|
||||
expect(result.recoverableArtifacts).toHaveLength(2);
|
||||
expect(result.coverageRows).toHaveLength(2);
|
||||
expect(new Set(result.coverageRows.map((row) => row.key)).size).toBe(2);
|
||||
expect(result.coverageRows.every((row) => row.pbsCount === 1 && row.posture === 'not-evaluated')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not attach a PVE task to a PBS-only orphan with a similar location label', () => {
|
||||
const result = model({ pbsBackups: [identityPBS()], tasks: [identityTask({ instance: '' })] });
|
||||
expect(result.coverageRows[0].latestTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps unresolved PVE instances separate while associating their own typed and untyped tasks', () => {
|
||||
const result = model({ archives: [identityArchive({ id: 'file-east' }), identityArchive({ id: 'file-west', instance: 'west' })], tasks: [identityTask(), identityTask({ id: 'task-west', instance: 'west', type: 'vm' })] });
|
||||
expect(result.coverageRows).toHaveLength(2);
|
||||
expect(result.coverageRows.map((row) => [row.workload.instance, row.latestTask?.id]).sort()).toEqual([['east', 'task-100'], ['west', 'task-west']]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user