Record and show that an image is already on its newest tag
An image that had been checked and is current looked exactly like one that was never checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -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-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-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-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-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-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 |
|
| WP-30 | M4 | done | 2026-09-02; SMB via smbclient, FTP/FTPS via curl, credentials encrypted |
|
||||||
|
|||||||
@ -64,15 +64,21 @@ impl ImageUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scan the newest available tag and record which of the open findings it fixes.
|
/// Scan the newest available tag and record which of the open findings it fixes.
|
||||||
pub async fn check(
|
pub async fn check(&self, image: &str, log: &dyn JobLog) -> Result<ImageUpdate, DomainError> {
|
||||||
&self,
|
|
||||||
image: &str,
|
|
||||||
log: &dyn JobLog,
|
|
||||||
) -> Result<Option<ImageUpdate>, DomainError> {
|
|
||||||
let Some(candidate) = self.candidate(image).await? else {
|
let Some(candidate) = self.candidate(image).await? else {
|
||||||
log.line(&format!("{image}: already on the newest tag"))
|
log.line(&format!("{image}: already on the newest tag"))
|
||||||
.await;
|
.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"))
|
log.line(&format!("{image}: candidate {candidate}, scanning it"))
|
||||||
.await;
|
.await;
|
||||||
@ -124,7 +130,7 @@ impl ImageUpdateService {
|
|||||||
update.candidate_total
|
update.candidate_total
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
Ok(Some(update))
|
Ok(update)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check every image that currently runs on the cluster.
|
/// Check every image that currently runs on the cluster.
|
||||||
@ -138,8 +144,8 @@ impl ImageUpdateService {
|
|||||||
let mut found = 0;
|
let mut found = 0;
|
||||||
for image in images {
|
for image in images {
|
||||||
match self.check(&image, log).await {
|
match self.check(&image, log).await {
|
||||||
Ok(Some(_)) => found += 1,
|
Ok(u) if !u.up_to_date() => found += 1,
|
||||||
Ok(None) => {}
|
Ok(_) => {}
|
||||||
Err(e) => log.line(&format!("{image}: check failed: {e}")).await,
|
Err(e) => log.line(&format!("{image}: check failed: {e}")).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,7 +92,8 @@ async fn a_check_records_which_findings_the_candidate_fixes() {
|
|||||||
seed(f.findings.clone(), running, Arc::new(MemCluster::default())).await;
|
seed(f.findings.clone(), running, Arc::new(MemCluster::default())).await;
|
||||||
|
|
||||||
let log = VecLog::default();
|
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.candidate, "gitea/gitea:1.23.0");
|
||||||
assert_eq!(update.fixed, vec!["CVE-1", "CVE-2"]);
|
assert_eq!(update.fixed, vec!["CVE-1", "CVE-2"]);
|
||||||
assert_eq!(update.fixed_counts.critical, 1);
|
assert_eq!(update.fixed_counts.critical, 1);
|
||||||
@ -111,11 +112,19 @@ async fn a_check_records_which_findings_the_candidate_fixes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 (f, svc) = fixture(FakeScanner::default(), &["1.22.3"]);
|
||||||
let log = VecLog::default();
|
let log = VecLog::default();
|
||||||
assert!(svc.check(GITEA, &log).await.unwrap().is_none());
|
let update = svc.check(GITEA, &log).await.unwrap();
|
||||||
assert!(f.updates.0.lock().unwrap().is_empty());
|
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
|
assert!(log
|
||||||
.0
|
.0
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@ -59,6 +59,7 @@ pub struct ImageUpdate {
|
|||||||
/// The image reference as it runs on the cluster.
|
/// The image reference as it runs on the cluster.
|
||||||
pub image: String,
|
pub image: String,
|
||||||
/// The newer tag that was checked, e.g. `docker.gitea.com/gitea:1.25.1-rootless`.
|
/// 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 candidate: String,
|
||||||
pub checked_at: chrono::DateTime<chrono::Utc>,
|
pub checked_at: chrono::DateTime<chrono::Utc>,
|
||||||
/// CVE ids that are open on the running image and gone in the candidate.
|
/// 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,
|
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").
|
/// 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<u64>, String)> {
|
fn version_parts(tag: &str) -> Option<(Vec<u64>, String)> {
|
||||||
let core = tag.strip_prefix('v').unwrap_or(tag);
|
let core = tag.strip_prefix('v').unwrap_or(tag);
|
||||||
|
|||||||
@ -151,6 +151,26 @@ describe('FindingGroups for images', () => {
|
|||||||
expect(w.find('button[name=update-image]').exists()).toBe(true)
|
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: '<a><slot /></a>' } } },
|
||||||
|
})
|
||||||
|
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', () => {
|
it('offers a check when nothing is known yet and hides the actions from viewers', () => {
|
||||||
const w = mount(FindingGroups, {
|
const w = mount(FindingGroups, {
|
||||||
props: { groups: [image()], scope: 'container', canAct: true, load: vi.fn() },
|
props: { groups: [image()], scope: 'container', canAct: true, load: vi.fn() },
|
||||||
|
|||||||
@ -127,7 +127,18 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0)
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="scope === 'container'" class="whitespace-nowrap text-xs" @click.stop>
|
<td v-if="scope === 'container'" class="whitespace-nowrap text-xs" @click.stop>
|
||||||
<template v-if="asImage(g).update">
|
<template v-if="asImage(g).update && !asImage(g).update!.candidate">
|
||||||
|
<div class="text-gray-500">on the newest tag</div>
|
||||||
|
<button
|
||||||
|
v-if="canAct"
|
||||||
|
name="check-image"
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
@click="emit('check', asImage(g))"
|
||||||
|
>
|
||||||
|
Check again
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="asImage(g).update">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-mono font-medium text-green-700">{{
|
<span class="font-mono font-medium text-green-700">{{
|
||||||
tagOf(asImage(g).update!.candidate)
|
tagOf(asImage(g).update!.candidate)
|
||||||
|
|||||||
Reference in New Issue
Block a user