147 lines
6.3 KiB
TypeScript
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;
|
|
}
|
|
}
|