115 lines
6.7 KiB
TypeScript
115 lines
6.7 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 runInstances = run ? instances.filter(item => item.processingRunId === run.id) : [];
|
|
const gpoCount = runInstances.length;
|
|
// Only backups processed with retained link facts can be counted; older ones stay unknown.
|
|
const linkFacts = runInstances.filter(item => item.instance.links);
|
|
// 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,
|
|
...(linkFacts.length ? { notLinkedGpoCount: linkFacts.filter(item => item.instance.links!.status === 'not-linked').length } : {})
|
|
});
|
|
}
|
|
records.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt) || right.artifactId.localeCompare(left.artifactId));
|
|
return { version: 1, projectId, generatedAt, records };
|
|
}
|