Assessor_2.0/server/gpo/gpo-artifact-reconciler.ts

147 lines
6.3 KiB
TypeScript

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<GpoArtifactReconciliationResult> {
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<typeof setInterval>;
private active?: Promise<void>;
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<void> {
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
await this.active;
}
}