190 lines
7.8 KiB
TypeScript
190 lines
7.8 KiB
TypeScript
import type {
|
|
GpoAnalysisSnapshot,
|
|
GpoBackupInstance,
|
|
GpoSnapshotCompleteness,
|
|
GpoSnapshotStatus
|
|
} from '../domain/gpo-ingestion';
|
|
import { gpoBackupCompleteness, gpoBackupInstanceValidationErrors } from '../domain/gpo-ingestion';
|
|
|
|
export interface GpoSnapshotBuildOptions {
|
|
createdAt: string;
|
|
maximumCollectionSkewMs?: number;
|
|
}
|
|
|
|
function hash(value: string): string {
|
|
let result = 0x811c9dc5;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
result ^= value.charCodeAt(index);
|
|
result = Math.imul(result, 0x01000193);
|
|
}
|
|
return (result >>> 0).toString(16).padStart(8, '0');
|
|
}
|
|
|
|
function effectiveBackupSetId(instance: GpoBackupInstance): string {
|
|
return instance.backupSet.id ?? 'gpo-backup-set:' + instance.sourceArtifactId;
|
|
}
|
|
|
|
function groupKey(instance: GpoBackupInstance): string {
|
|
const domain = instance.backupSet.scope === 'selected-gpos' && instance.backupSet.evidence === 'user-selection'
|
|
? 'user-selected-backup-set'
|
|
: instance.domain.status === 'resolved' ? instance.domain.key : `unknown:${instance.id}`;
|
|
const backupSet = effectiveBackupSetId(instance);
|
|
return JSON.stringify([instance.projectId, domain, backupSet]);
|
|
}
|
|
|
|
function setDeclaration(instance: GpoBackupInstance): string {
|
|
return JSON.stringify([
|
|
instance.backupSet.scope,
|
|
instance.backupSet.evidence,
|
|
instance.backupSet.expectedGpoCount ?? null
|
|
]);
|
|
}
|
|
|
|
function collectionWindow(instances: GpoBackupInstance[]): { from: string; to: string } | undefined {
|
|
if (instances.some(instance => instance.collection.status === 'unknown')) return undefined;
|
|
const times = instances.map(instance => (instance.collection as Extract<GpoBackupInstance['collection'], { status: 'observed' }>).collectedAt)
|
|
.sort((left, right) => Date.parse(left) - Date.parse(right));
|
|
return times.length ? { from: times[0], to: times[times.length - 1] } : undefined;
|
|
}
|
|
|
|
function domainSplits(instances: GpoBackupInstance[]): Set<string> {
|
|
const domainsBySet = new Map<string, Set<string>>();
|
|
instances.forEach(instance => {
|
|
|
|
const domain = instance.domain.status === 'resolved' ? instance.domain.key : `unknown:${instance.id}`;
|
|
const backupSetId = effectiveBackupSetId(instance);
|
|
const domains = domainsBySet.get(backupSetId) ?? new Set<string>();
|
|
domains.add(domain);
|
|
domainsBySet.set(backupSetId, domains);
|
|
});
|
|
return new Set([...domainsBySet].filter(([, domains]) => domains.size > 1).map(([setId]) => setId));
|
|
}
|
|
|
|
function snapshotForGroup(
|
|
instances: GpoBackupInstance[],
|
|
splitSetIds: ReadonlySet<string>,
|
|
createdAt: string,
|
|
maximumCollectionSkewMs: number
|
|
): GpoAnalysisSnapshot {
|
|
const first = instances[0];
|
|
const diagnostics = new Set<string>();
|
|
let inconsistent = false;
|
|
let indeterminate = false;
|
|
|
|
if (first.domain.status === 'unknown') {
|
|
diagnostics.add('gpo-domain-unknown');
|
|
indeterminate = true;
|
|
}
|
|
const derivedBackupSet = !first.backupSet.id;
|
|
const backupSetId = effectiveBackupSetId(first);
|
|
const selectedUpload = first.backupSet.scope === 'selected-gpos' && first.backupSet.evidence === 'user-selection';
|
|
const mixedDomains = splitSetIds.has(backupSetId);
|
|
if (derivedBackupSet) diagnostics.add('gpo-backup-set-derived-from-source-artifact');
|
|
if (mixedDomains && selectedUpload) {
|
|
diagnostics.add('gpo-backup-set-domain-mixed');
|
|
indeterminate = true;
|
|
} else if (mixedDomains) {
|
|
diagnostics.add('gpo-backup-set-domain-split');
|
|
inconsistent = true;
|
|
}
|
|
if (new Set(instances.map(setDeclaration)).size > 1) {
|
|
diagnostics.add('gpo-backup-set-declaration-conflict');
|
|
inconsistent = true;
|
|
}
|
|
const resolvedGpoIds = instances.flatMap(instance => instance.identity.status === 'resolved' ? [instance.identity.gpoId] : []);
|
|
if (resolvedGpoIds.length !== new Set(resolvedGpoIds).size) {
|
|
diagnostics.add('gpo-duplicate-identity-in-snapshot');
|
|
inconsistent = true;
|
|
}
|
|
const resolvedBackupIds = instances.flatMap(instance => instance.identity.status === 'resolved' ? [instance.identity.backupId] : []);
|
|
if (resolvedBackupIds.length !== new Set(resolvedBackupIds).size) {
|
|
diagnostics.add('gpo-duplicate-backup-in-snapshot');
|
|
inconsistent = true;
|
|
}
|
|
if (instances.some(instance => instance.identity.status === 'unresolved')) {
|
|
diagnostics.add('gpo-identity-unresolved');
|
|
indeterminate = true;
|
|
}
|
|
if (instances.some(instance => gpoBackupCompleteness(instance) !== 'complete')) {
|
|
diagnostics.add('gpo-backup-instance-incomplete');
|
|
indeterminate = true;
|
|
}
|
|
|
|
const collectionRange = collectionWindow(instances);
|
|
if (!collectionRange) {
|
|
diagnostics.add('gpo-collection-time-unknown');
|
|
indeterminate = true;
|
|
} else if (Date.parse(collectionRange.to) - Date.parse(collectionRange.from) > maximumCollectionSkewMs) {
|
|
diagnostics.add('gpo-collection-range-exceeded');
|
|
inconsistent = true;
|
|
}
|
|
if (instances.some(instance => instance.collection.status === 'observed'
|
|
&& instance.collection.evidence !== 'collection-manifest')) {
|
|
diagnostics.add('gpo-collection-time-not-manifest-backed');
|
|
indeterminate = true;
|
|
}
|
|
|
|
let completeness: GpoSnapshotCompleteness;
|
|
if (inconsistent) completeness = 'inconsistent';
|
|
else if (first.backupSet.scope === 'selected-gpos' || derivedBackupSet) completeness = 'selected-subset';
|
|
else if (first.backupSet.scope !== 'complete-domain-export') completeness = 'unknown';
|
|
else {
|
|
const expected = first.backupSet.expectedGpoCount ?? 0;
|
|
const observed = new Set(resolvedGpoIds).size;
|
|
if (observed > expected) {
|
|
diagnostics.add('gpo-backup-set-count-conflict');
|
|
completeness = 'inconsistent';
|
|
inconsistent = true;
|
|
} else if (observed < expected) {
|
|
diagnostics.add('gpo-backup-set-incomplete');
|
|
completeness = 'unknown';
|
|
indeterminate = true;
|
|
} else completeness = 'complete';
|
|
}
|
|
|
|
const status: GpoSnapshotStatus = inconsistent ? 'inconsistent' : indeterminate ? 'indeterminate' : 'consistent';
|
|
const instanceIds = instances.map(instance => instance.id).sort();
|
|
return {
|
|
version: 1,
|
|
id: `gpo-snapshot-${hash(JSON.stringify([groupKey(first), instanceIds]))}`,
|
|
projectId: first.projectId,
|
|
domain: mixedDomains && selectedUpload ? { status: 'unknown' } : first.domain,
|
|
backupSetId,
|
|
collectionWindow: collectionRange,
|
|
instanceIds,
|
|
status,
|
|
completeness,
|
|
permitsSetAbsenceConclusions: status === 'consistent' && completeness === 'complete',
|
|
diagnosticCodes: [...diagnostics].sort(),
|
|
createdAt
|
|
};
|
|
}
|
|
|
|
export function buildGpoAnalysisSnapshots(
|
|
values: GpoBackupInstance[],
|
|
options: GpoSnapshotBuildOptions
|
|
): GpoAnalysisSnapshot[] {
|
|
if (!Number.isFinite(Date.parse(options.createdAt))) throw new Error('Der Snapshot-Erstellungszeitpunkt ist ungültig.');
|
|
const maximumCollectionSkewMs = options.maximumCollectionSkewMs ?? 15 * 60 * 1000;
|
|
if (!Number.isInteger(maximumCollectionSkewMs) || maximumCollectionSkewMs < 0 || maximumCollectionSkewMs > 24 * 60 * 60 * 1000) {
|
|
throw new Error('Das zulässige Erhebungszeitfenster ist ungültig.');
|
|
}
|
|
const ids = new Set<string>();
|
|
values.forEach(instance => {
|
|
const errors = gpoBackupInstanceValidationErrors(instance);
|
|
if (errors.length) throw new Error(`Ungültige GPO-Backupinstanz ${instance.id || '<ohne ID>'}: ${errors.join(' ')}`);
|
|
if (ids.has(instance.id)) throw new Error(`Doppelte GPO-Backupinstanz-ID: ${instance.id}.`);
|
|
ids.add(instance.id);
|
|
});
|
|
const sorted = [...values].sort((left, right) => left.id.localeCompare(right.id));
|
|
const grouped = new Map<string, GpoBackupInstance[]>();
|
|
sorted.forEach(instance => {
|
|
const key = groupKey(instance);
|
|
grouped.set(key, [...(grouped.get(key) ?? []), instance]);
|
|
});
|
|
const splitSetIds = domainSplits(sorted);
|
|
return [...grouped.values()]
|
|
.map(group => snapshotForGroup(group, splitSetIds, options.createdAt, maximumCollectionSkewMs))
|
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
}
|