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:
pulse-triage[bot]
2026-10-04 14:07:55 +01:00
parent 6f4d8999bb
commit c5e83ceb3a
2 changed files with 212 additions and 0 deletions
@@ -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,
});
@@ -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']]);
});
});