import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import { isBackendArtifactStorageKey, type BackendArtifactStore, type ArtifactStoreReconciliationResult } from '../artifacts/backend-artifact-store'; import { GPO_BACKUP_ANALYZER_VERSION, createGpoBackupAnalysisJob, createGpoReprocessingJob } from './gpo-backup-submission-service'; import { processArtifactDeletionRequest } from './gpo-backup-management-service'; import { gpoSourceAvailability } from './gpo-source-availability'; export interface GpoArtifactReconciliationResult extends ArtifactStoreReconciliationResult { repairedMissingJobs: number; failedJobRepairs: number; completedDeletionRequests: number; pendingDeletionRequests: number; invalidStorageReferences: number; unavailableSources: number; } export interface GpoArtifactReconcilerOptions { gracePeriodMs?: number; maximumRemovals?: number; now?: () => Date; } export class GpoArtifactReconciler { private readonly gracePeriodMs: number; private readonly maximumRemovals: number; private readonly now: () => Date; constructor( private readonly repository: AssessorRepository, private readonly artifactStore: BackendArtifactStore, options: GpoArtifactReconcilerOptions = {} ) { this.gracePeriodMs = options.gracePeriodMs ?? 24 * 60 * 60 * 1_000; this.maximumRemovals = options.maximumRemovals ?? 100; this.now = options.now ?? (() => new Date()); if (!Number.isInteger(this.gracePeriodMs) || this.gracePeriodMs < 60_000 || !Number.isInteger(this.maximumRemovals) || this.maximumRemovals < 1 || this.maximumRemovals > 10_000) { throw new Error('Die GPO-Artefakt-Reconciliation-Konfiguration ist ungültig.'); } } async run(): Promise { const now = this.now(); let completedDeletionRequests = 0; let pendingDeletionRequests = 0; const deletionRequests = await this.repository.listArtifactDeletionRequests(now.toISOString(), this.maximumRemovals); for (const request of deletionRequests) { const result = await processArtifactDeletionRequest(this.repository, this.artifactStore, request, this.now); if (result === 'complete') completedDeletionRequests += 1; else pendingDeletionRequests += 1; } const projects = await this.repository.listProjects(); const referencedStorageKeys: string[] = []; let invalidStorageReferences = 0; const baselines = await this.repository.listMicrosoftSecurityBaselines(); for (const baseline of baselines) { if (isBackendArtifactStorageKey(baseline.storageKey)) referencedStorageKeys.push(baseline.storageKey); else invalidStorageReferences += 1; } let repairedMissingJobs = 0; let failedJobRepairs = 0; let unavailableSources = 0; for (const project of projects) { const [artifacts, jobs, processingRuns, policyAnalyses] = await Promise.all([ this.repository.listImportArtifacts(project.id), this.repository.listIngestionJobs(project.id), this.repository.listProjectProcessingRuns(project.id), this.repository.listGpoPolicyAnalyses(project.id) ]); const currentJobKeys = new Set(jobs.filter(job => job.kind === 'analyze-gpo-backup').map(job => job.idempotencyKey)); const currentPolicySourceRuns = new Set(policyAnalyses .filter(item => item.run.analyzer.version === GPO_BACKUP_ANALYZER_VERSION) .map(item => item.run.sourceProcessingRunId)); for (const artifact of artifacts) { if (artifact.storage.kind === 'backend-controlled') { if (isBackendArtifactStorageKey(artifact.storage.storageKey)) referencedStorageKeys.push(artifact.storage.storageKey); else invalidStorageReferences += 1; } if (artifact.kind !== 'source-file' || !artifact.relativePath.startsWith('gpo-backups/') || artifact.storage.kind !== 'backend-controlled') continue; const latestRun = processingRuns.filter(item => item.artifactId === artifact.id) .sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id))[0]; if (latestRun && currentPolicySourceRuns.has(latestRun.id)) continue; const expectedJob = latestRun ? createGpoReprocessingJob(artifact, latestRun.id, now.toISOString()) : createGpoBackupAnalysisJob(artifact, now.toISOString()); if (currentJobKeys.has(expectedJob.idempotencyKey)) continue; if (await gpoSourceAvailability(artifact, this.artifactStore) !== 'available') { unavailableSources += 1; continue; } try { const committed = await this.repository.saveImportArtifactAndEnqueueJob(artifact, expectedJob); if (committed.jobCreated) repairedMissingJobs += 1; } catch { failedJobRepairs += 1; } } } const cutoff = new Date(now.getTime() - this.gracePeriodMs).toISOString(); const storage = await this.artifactStore.reconcile({ referencedStorageKeys, allowUnreferencedObjectRemoval: invalidStorageReferences === 0 && referencedStorageKeys.length > 0, removeUnreferencedBefore: cutoff, removeStagingBefore: cutoff, maximumRemovals: this.maximumRemovals }); return { ...storage, repairedMissingJobs, failedJobRepairs, completedDeletionRequests, pendingDeletionRequests, invalidStorageReferences, unavailableSources }; } } export class GpoArtifactReconciliationRunner { private timer?: ReturnType; private active?: Promise; constructor( private readonly reconciler: GpoArtifactReconciler, private readonly intervalMs = 60 * 60 * 1_000, private readonly onError: (error: unknown) => void = () => undefined ) { if (!Number.isInteger(intervalMs) || intervalMs < 60_000 || intervalMs > 24 * 60 * 60 * 1_000) { throw new Error('Das GPO-Reconciliation-Intervall ist ungültig.'); } } start(): void { if (this.timer) return; const tick = () => { if (this.active) return; this.active = this.reconciler.run().then(() => undefined, this.onError) .finally(() => { this.active = undefined; }); }; tick(); this.timer = setInterval(tick, this.intervalMs); this.timer.unref?.(); } async stop(): Promise { if (this.timer) clearInterval(this.timer); this.timer = undefined; await this.active; } }