diff --git a/ROADMAP.md b/ROADMAP.md index b933cfb..0c02afa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,7 +357,7 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect | WP-02 | M1 (shell) / M2 (rest) | done | shell 2026-09-02; encrypted settings, SMTP, job runner, scheduler 2026-09-02 | | WP-10 | M2 | done | 2026-09-02; snaps inventoried, no upstream check | | WP-11 | M2 | done | 2026-09-02; runs as root via systemd, sudoers scoping deferred to WP-41; log via polling | -| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done | +| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check added 2026-09-03 with image update suggestions | | WP-20 | M3 | done | 2026-09-02; Trivy rootfs + image scans, diff with first/last seen, fixed detection | | WP-21 | M3 | done | 2026-09-02; findings split into host (OS/packages/applications) and container categories, acknowledge, mail digest with severity threshold | | WP-30 | M4 | done | 2026-09-02; SMB via smbclient, FTP/FTPS via curl, credentials encrypted | diff --git a/backend/crates/application/src/image_update_service.rs b/backend/crates/application/src/image_update_service.rs index 0d7ff4a..0b77684 100644 --- a/backend/crates/application/src/image_update_service.rs +++ b/backend/crates/application/src/image_update_service.rs @@ -64,15 +64,21 @@ impl ImageUpdateService { } /// Scan the newest available tag and record which of the open findings it fixes. - pub async fn check( - &self, - image: &str, - log: &dyn JobLog, - ) -> Result, DomainError> { + pub async fn check(&self, image: &str, log: &dyn JobLog) -> Result { let Some(candidate) = self.candidate(image).await? else { log.line(&format!("{image}: already on the newest tag")) .await; - return Ok(None); + // record the check so the view can tell "current" from "never checked" + let update = ImageUpdate { + image: image.to_string(), + candidate: String::new(), + checked_at: Utc::now(), + fixed: Vec::new(), + fixed_counts: SeverityCounts::default(), + candidate_total: 0, + }; + self.updates.upsert(&update).await?; + return Ok(update); }; log.line(&format!("{image}: candidate {candidate}, scanning it")) .await; @@ -124,7 +130,7 @@ impl ImageUpdateService { update.candidate_total )) .await; - Ok(Some(update)) + Ok(update) } /// Check every image that currently runs on the cluster. @@ -138,8 +144,8 @@ impl ImageUpdateService { let mut found = 0; for image in images { match self.check(&image, log).await { - Ok(Some(_)) => found += 1, - Ok(None) => {} + Ok(u) if !u.up_to_date() => found += 1, + Ok(_) => {} Err(e) => log.line(&format!("{image}: check failed: {e}")).await, } } diff --git a/backend/crates/application/src/tests/image_update_tests.rs b/backend/crates/application/src/tests/image_update_tests.rs index 145f0e6..2f38e10 100644 --- a/backend/crates/application/src/tests/image_update_tests.rs +++ b/backend/crates/application/src/tests/image_update_tests.rs @@ -92,7 +92,8 @@ async fn a_check_records_which_findings_the_candidate_fixes() { seed(f.findings.clone(), running, Arc::new(MemCluster::default())).await; let log = VecLog::default(); - let update = svc.check(GITEA, &log).await.unwrap().unwrap(); + let update = svc.check(GITEA, &log).await.unwrap(); + assert!(!update.up_to_date()); assert_eq!(update.candidate, "gitea/gitea:1.23.0"); assert_eq!(update.fixed, vec!["CVE-1", "CVE-2"]); assert_eq!(update.fixed_counts.critical, 1); @@ -111,11 +112,19 @@ async fn a_check_records_which_findings_the_candidate_fixes() { } #[tokio::test] -async fn nothing_is_recorded_when_the_image_is_current() { +async fn being_up_to_date_is_recorded_as_a_result_too() { let (f, svc) = fixture(FakeScanner::default(), &["1.22.3"]); let log = VecLog::default(); - assert!(svc.check(GITEA, &log).await.unwrap().is_none()); - assert!(f.updates.0.lock().unwrap().is_empty()); + let update = svc.check(GITEA, &log).await.unwrap(); + assert!(update.up_to_date(), "no newer tag exists"); + assert!(update.candidate.is_empty()); + assert!(update.fixed.is_empty()); + // the result is stored, so the view can tell "checked and current" from "never checked" + assert_eq!(f.updates.0.lock().unwrap().len(), 1); + assert_eq!( + svc.stored(GITEA).await.unwrap().unwrap().checked_at, + update.checked_at + ); assert!(log .0 .lock() diff --git a/backend/crates/domain/src/image.rs b/backend/crates/domain/src/image.rs index 1367e08..3848663 100644 --- a/backend/crates/domain/src/image.rs +++ b/backend/crates/domain/src/image.rs @@ -59,6 +59,7 @@ pub struct ImageUpdate { /// The image reference as it runs on the cluster. pub image: String, /// The newer tag that was checked, e.g. `docker.gitea.com/gitea:1.25.1-rootless`. + /// Empty when the check found no newer tag. pub candidate: String, pub checked_at: chrono::DateTime, /// CVE ids that are open on the running image and gone in the candidate. @@ -68,6 +69,13 @@ pub struct ImageUpdate { pub candidate_total: usize, } +impl ImageUpdate { + /// True when the check found no newer tag for this image. + pub fn up_to_date(&self) -> bool { + self.candidate.is_empty() + } +} + /// Numeric parts of a tag plus its suffix, e.g. `1.24.2-rootless` → ([1, 24, 2], "rootless"). fn version_parts(tag: &str) -> Option<(Vec, String)> { let core = tag.strip_prefix('v').unwrap_or(tag); diff --git a/frontend/src/components/FindingGroups.test.ts b/frontend/src/components/FindingGroups.test.ts index acd4867..6e228f3 100644 --- a/frontend/src/components/FindingGroups.test.ts +++ b/frontend/src/components/FindingGroups.test.ts @@ -151,6 +151,26 @@ describe('FindingGroups for images', () => { expect(w.find('button[name=update-image]').exists()).toBe(true) }) + it('says the image is current when a check found no newer tag', () => { + const current = image({ + update: { + image: 'gitea/gitea:1.22.3', + candidate: '', + checked_at: '2026-09-03T08:00:00Z', + fixed: [], + fixed_counts: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }, + candidate_total: 0, + }, + }) + const w = mount(FindingGroups, { + props: { groups: [current], scope: 'container', canAct: true, load: vi.fn() }, + global: { stubs: { RouterLink: { template: '' } } }, + }) + expect(w.text()).toContain('newest tag') + expect(w.find('button[name=update-image]').exists()).toBe(false) + expect(w.find('button[name=check-image]').exists()).toBe(true) + }) + it('offers a check when nothing is known yet and hides the actions from viewers', () => { const w = mount(FindingGroups, { props: { groups: [image()], scope: 'container', canAct: true, load: vi.fn() }, diff --git a/frontend/src/components/FindingGroups.vue b/frontend/src/components/FindingGroups.vue index 1997f30..837f14b 100644 --- a/frontend/src/components/FindingGroups.vue +++ b/frontend/src/components/FindingGroups.vue @@ -127,7 +127,18 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0) -