diff --git a/src/DockerUpdateGuard/UI/ApplicationViewService.cs b/src/DockerUpdateGuard/UI/ApplicationViewService.cs index bacb547..43e724c 100644 --- a/src/DockerUpdateGuard/UI/ApplicationViewService.cs +++ b/src/DockerUpdateGuard/UI/ApplicationViewService.cs @@ -1026,37 +1026,61 @@ private async Task> LoadRecommendedImageVersionsA /// Latest snapshots private async Task> GetLatestContainerSnapshotsAsync(CancellationToken cancellationToken) { - var snapshots = await _dbContext.ContainerSnapshots.Include(entity => entity.DockerInstance) - .ThenInclude(entity => entity.PortainerEndpoint) - .Include(entity => entity.ImageVersion) - .ThenInclude(entity => entity.RegistryRepository) - .Include(entity => entity.ScanRun) - .AsNoTracking() - .OrderByDescending(entity => entity.RecordedAtUtc) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); + var runtimeScanReferences = await _dbContext.ContainerSnapshots.Where(entity => entity.ScanRunId != null + && entity.ScanRun!.Type == ScanRunType.RuntimeContainer) + .Select(entity => new + { + entity.DockerInstanceId, + entity.ScanRunId, + entity.ScanRun!.StartedAtUtc, + }) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var targetScanRunByInstance = runtimeScanReferences.GroupBy(reference => reference.DockerInstanceId) + .ToDictionary(group => group.Key, + group => group.OrderByDescending(reference => reference.StartedAtUtc) + .First() + .ScanRunId); var currentSnapshots = new List(); - foreach (var instanceGroup in snapshots.GroupBy(entity => entity.DockerInstanceId)) + if (targetScanRunByInstance.Count > 0) { - var latestRuntimeScanSnapshots = instanceGroup.Where(entity => entity.ScanRun?.Type == ScanRunType.RuntimeContainer) - .GroupBy(entity => entity.ScanRunId) - .OrderByDescending(group => group.Max(item => item.ScanRun?.StartedAtUtc ?? item.RecordedAtUtc)) - .Select(group => group.OrderByDescending(item => item.RecordedAtUtc) - .ToList()) - .FirstOrDefault(); - - if (latestRuntimeScanSnapshots is not null) - { - currentSnapshots.AddRange(latestRuntimeScanSnapshots); + var targetScanRunIds = targetScanRunByInstance.Values.ToList(); + var runtimeSnapshots = await _dbContext.ContainerSnapshots.Include(entity => entity.DockerInstance) + .ThenInclude(entity => entity.PortainerEndpoint) + .Include(entity => entity.ImageVersion) + .ThenInclude(entity => entity.RegistryRepository) + .Include(entity => entity.ScanRun) + .Where(entity => targetScanRunIds.Contains(entity.ScanRunId)) + .AsNoTracking() + .OrderByDescending(entity => entity.RecordedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); - continue; - } + currentSnapshots.AddRange(runtimeSnapshots.Where(entity => targetScanRunByInstance.TryGetValue(entity.DockerInstanceId, out var scanRunId) + && scanRunId == entity.ScanRunId)); + } + + var runtimeInstanceIds = targetScanRunByInstance.Keys.ToList(); + var fallbackSnapshots = await _dbContext.ContainerSnapshots.Include(entity => entity.DockerInstance) + .ThenInclude(entity => entity.PortainerEndpoint) + .Include(entity => entity.ImageVersion) + .ThenInclude(entity => entity.RegistryRepository) + .Include(entity => entity.ScanRun) + .Where(entity => runtimeInstanceIds.Contains(entity.DockerInstanceId) == false + && _dbContext.ContainerSnapshots.Any(other => other.DockerInstanceId == entity.DockerInstanceId + && other.ContainerId == entity.ContainerId + && other.RecordedAtUtc > entity.RecordedAtUtc) == false) + .AsNoTracking() + .OrderByDescending(entity => entity.RecordedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); - currentSnapshots.AddRange(instanceGroup.GroupBy(entity => CreateContainerKey(entity.DockerInstanceId, entity.ContainerId)) + currentSnapshots.AddRange(fallbackSnapshots.GroupBy(entity => CreateContainerKey(entity.DockerInstanceId, entity.ContainerId)) .Select(group => group.First())); - } return currentSnapshots; } diff --git a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs index 923bf29..56649de 100644 --- a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs @@ -2437,5 +2437,67 @@ await dbContext.SaveChangesAsync(CancellationToken.None) } } + /// + /// Verify runtime container projections collapse historical snapshots without a runtime scan to the latest per container + /// + /// Task + [TestMethod] + public async Task ApplicationViewServiceRuntimeContainersCollapseHistoricalSnapshotsToLatestAsync() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbContext = new DockerUpdateGuardDbContext(options); + + await using (dbContext.ConfigureAwait(false)) + { + var imageCatalogRepository = new ImageCatalogRepository(dbContext); + var imageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io", + "company/api", + "1.0.0", + "sha256:api", + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + var dockerInstance = new DockerInstance + { + Name = "Production", + EndpointUri = "https://docker.example.test", + ConnectionKind = DockerConnectionKind.Https, + }; + + var recordedAt = DateTimeOffset.UtcNow.AddHours(-3); + + for (var index = 0; index < 5; index++) + { + dbContext.ContainerSnapshots.Add(new ContainerSnapshot + { + DockerInstance = dockerInstance, + ImageVersionId = imageVersion.Id, + ContainerId = "container-history", + Name = $"api-{index}", + Status = ContainerRuntimeStatus.Running, + IsRunning = true, + RecordedAtUtc = recordedAt.AddMinutes(index * 10), + }); + } + + await dbContext.SaveChangesAsync(CancellationToken.None) + .ConfigureAwait(false); + + var service = new ApplicationViewService(dbContext, + new ImageReferenceParser(), + new SharedBaseImageQueryService(dbContext)); + var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None) + .ConfigureAwait(false); + + Assert.HasCount(1, + runtimeContainers, + "Historical snapshots without a runtime scan must collapse to a single latest projection per container"); + Assert.AreEqual("api-4", + runtimeContainers.Single().ContainerName, + "The collapsed runtime container projection must expose the most recently recorded snapshot"); + } + } + #endregion // Methods } \ No newline at end of file