import type { GpoAnalysisUpdate, GpoBackupStatusRecord, GpoBackupStatusResponse } from '../../src/api/assessor-api-contract'; import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service'; import type { BackendArtifactStore } from '../artifacts/backend-artifact-store'; import { gpoSourceAvailability } from './gpo-source-availability'; import type { ImportProcessingRun, IngestionJob } from '../../src/domain/ingestion-persistence'; import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import { RepositoryNotFoundError } from '../../src/persistence/repository-errors'; import { gpoEvaluationUpdates, type GpoEvaluationUpdate } from '../../src/domain/policy-analysis-overview'; // Bounded per repository; every persisted change invalidates the derived readiness. const evaluationCache = new WeakMap }>>(); function evaluationUpdates(repository: AssessorRepository, projectId: string): Promise { let cache = evaluationCache.get(repository); if (!cache) evaluationCache.set(repository, cache = new Map()); const cached = cache.get(projectId); if (cached?.revision === repository.revision) return cached.value; const revision = repository.revision; const value = Promise.all([ repository.listGpoPolicyAnalyses(projectId), repository.listEnvironmentProfiles(projectId), repository.listPolicySnapshotAggregations(projectId), repository.listPolicyEvaluations(projectId), // A retained evaluation stays pinned to its own baseline analysis ids. Readiness // therefore needs the current analysis generation, not only the engine version. repository.listMicrosoftSecurityBaselineAnalyses() ]).then(([identityAnalyses, profiles, aggregations, evaluations, baselineAnalyses]) => gpoEvaluationUpdates({ identityAnalyses, profiles, aggregations, evaluations, baselineAnalyses })); if (cache.size >= 128) cache.delete(cache.keys().next().value!); const entry = { revision, value }; cache.set(projectId, entry); void value.catch(() => { if (cache.get(projectId) === entry) cache.delete(projectId); }); return value; } function latestByUpdatedAt(jobs: IngestionJob[]): IngestionJob | undefined { return jobs.slice().sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id))[0]; } function latestRun(runs: ImportProcessingRun[]): ImportProcessingRun | undefined { return runs.slice().sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id))[0]; } function displayState(job: IngestionJob | undefined, run: ImportProcessingRun | undefined): GpoBackupStatusRecord['state'] { if (!job) return 'recovery-required'; if (job.status === 'dead-letter') return 'dead-letter'; if (job.status === 'queued' || job.status === 'running') return job.status; return run?.status === 'partial' ? 'partial' : 'succeeded'; } export async function listProjectGpoBackupStatus( repository: AssessorRepository, projectId: string, generatedAt: string, artifactStore?: BackendArtifactStore ): Promise { if (!await repository.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); const [artifacts, jobs, instances, runs] = await Promise.all([ repository.listImportArtifacts(projectId), repository.listIngestionJobs(projectId), repository.listGpoBackupInstances(projectId), repository.listProjectProcessingRuns(projectId) ]); const records: GpoBackupStatusRecord[] = []; const hasGpoBackups = artifacts.some(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/')); const updates = hasGpoBackups ? await evaluationUpdates(repository, projectId) : []; // A newer analyzer changes what parsing retains, so the stored source needs reprocessing. // Superseded analyses are historical and never request work of their own. const analyses = hasGpoBackups ? await repository.listGpoPolicyAnalyses(projectId) : []; const supersededAnalysisIds = new Set(analyses.flatMap(item => item.run.supersedesAnalysisRunId ? [item.run.supersedesAnalysisRunId] : [])); const outdatedAnalyses = new Map(); for (const item of analyses) { if (supersededAnalysisIds.has(item.run.id) || item.run.analyzer.version === GPO_BACKUP_ANALYZER_VERSION) continue; outdatedAnalyses.set(item.run.artifactId, { identityAnalysisRunId: item.run.id, expectedLatestProcessingRunId: item.run.sourceProcessingRunId, previousAnalyzerVersion: item.run.analyzer.version, currentAnalyzerVersion: GPO_BACKUP_ANALYZER_VERSION }); } for (const artifact of artifacts.filter(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'))) { const job = latestByUpdatedAt(jobs.filter(item => item.kind === 'analyze-gpo-backup' && item.artifactId === artifact.id)); const run = latestRun(runs.filter(item => item.artifactId === artifact.id)); const gpoCount = run ? instances.filter(item => item.processingRunId === run.id).length : 0; // Availability changes independently of the repository revision; do not cache it. const sourceAvailability = await gpoSourceAvailability(artifact, artifactStore); records.push({ version: 1, artifactId: artifact.id, evaluationUpdates: updates.filter(update => update.artifactIds.includes(artifact.id)), analysisUpdate: outdatedAnalyses.get(artifact.id), assessmentId: artifact.assessmentId, jobId: job?.id, fileName: artifact.relativePath.slice('gpo-backups/'.length), sizeBytes: artifact.sizeBytes, submittedAt: artifact.createdAt, updatedAt: job?.updatedAt ?? artifact.createdAt, state: sourceAvailability === 'missing' ? 'upload-required' : sourceAvailability === 'unavailable' ? 'storage-unavailable' : displayState(job, run), jobStatus: job?.status, attempts: job?.attempts ?? 0, maxAttempts: job?.maxAttempts ?? 3, lastErrorCode: job?.lastErrorCode, processingStatus: run?.status, completedAt: run?.completedAt, gpoCount, backupInstanceCount: gpoCount }); } records.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt) || right.artifactId.localeCompare(left.artifactId)); return { version: 1, projectId, generatedAt, records }; }