Assessor_2.0/server/gpo/gpo-backup-status-service.ts

111 lines
6.4 KiB
TypeScript

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<AssessorRepository, Map<string, { revision: number; value: Promise<GpoEvaluationUpdate[]> }>>();
function evaluationUpdates(repository: AssessorRepository, projectId: string): Promise<GpoEvaluationUpdate[]> {
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<GpoBackupStatusResponse> {
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<string, GpoAnalysisUpdate>();
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 };
}