Link image findings to their workloads and suggest a fixing image update
Container findings now name the workloads that run the image and link into the Kubernetes view, which highlights them. An update check asks the registry for newer tags of the same variant, scans the newest one and records which of the open findings are gone in it. The image row then shows the candidate tag, how many findings it fixes and how many remain, marks those CVEs in the expanded list, and offers to roll every workload over to it. The check runs as a job, nightly for all running images or on demand for one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -70,6 +70,14 @@ pub struct ClusterOverview {
|
||||
}
|
||||
|
||||
impl ClusterOverview {
|
||||
/// Workloads that currently run `image`.
|
||||
pub fn workloads_running(&self, image: &str) -> Vec<&Workload> {
|
||||
self.workloads
|
||||
.iter()
|
||||
.filter(|w| w.containers.iter().any(|c| c.image == image))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Distinct images across all workloads (input for vulnerability scans).
|
||||
pub fn images(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self
|
||||
@ -84,7 +92,7 @@ impl ClusterOverview {
|
||||
}
|
||||
|
||||
/// Reference to a workload for actions.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkloadRef {
|
||||
pub namespace: String,
|
||||
pub kind: WorkloadKind,
|
||||
|
||||
234
backend/crates/domain/src/image.rs
Normal file
234
backend/crates/domain/src/image.rs
Normal file
@ -0,0 +1,234 @@
|
||||
//! Container image references and picking a newer tag.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A parsed image reference: `[registry/]repository[:tag]`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImageRef {
|
||||
pub registry: String,
|
||||
pub repository: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
impl ImageRef {
|
||||
/// `docker.io/library/nginx:1.2` → registry `docker.io`, repository `library/nginx`, tag `1.2`.
|
||||
pub fn parse(image: &str) -> Option<ImageRef> {
|
||||
let image = image.split('@').next()?; // ignore a digest
|
||||
let (head, tag) = match image.rsplit_once(':') {
|
||||
Some((h, t)) if !t.contains('/') => (h, t.to_string()),
|
||||
_ => (image, "latest".to_string()),
|
||||
};
|
||||
if head.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (registry, repository) = match head.split_once('/') {
|
||||
// a first segment with a dot or a port is a registry, everything else is Docker Hub
|
||||
Some((first, rest))
|
||||
if first.contains('.') || first.contains(':') || first == "localhost" =>
|
||||
{
|
||||
(first.to_string(), rest.to_string())
|
||||
}
|
||||
Some(_) => ("docker.io".to_string(), head.to_string()),
|
||||
None => ("docker.io".to_string(), format!("library/{head}")),
|
||||
};
|
||||
Some(ImageRef {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_tag(&self, tag: &str) -> String {
|
||||
let repo = if self.registry == "docker.io" {
|
||||
self.repository
|
||||
.strip_prefix("library/")
|
||||
.unwrap_or(&self.repository)
|
||||
.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.registry, self.repository)
|
||||
};
|
||||
format!("{repo}:{tag}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of checking whether a newer tag fixes the findings of a running image.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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`.
|
||||
pub candidate: String,
|
||||
pub checked_at: chrono::DateTime<chrono::Utc>,
|
||||
/// CVE ids that are open on the running image and gone in the candidate.
|
||||
pub fixed: Vec<String>,
|
||||
pub fixed_counts: crate::vuln::SeverityCounts,
|
||||
/// Findings the candidate still has.
|
||||
pub candidate_total: usize,
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let core = tag.strip_prefix('v').unwrap_or(tag);
|
||||
let (numbers, suffix) = match core.split_once('-') {
|
||||
Some((n, s)) => (n, s.to_string()),
|
||||
None => (core, String::new()),
|
||||
};
|
||||
let parts: Vec<u64> = numbers
|
||||
.split('.')
|
||||
.map(|p| p.parse().ok())
|
||||
.collect::<Option<_>>()?;
|
||||
(!parts.is_empty()).then_some((parts, suffix))
|
||||
}
|
||||
|
||||
/// True for tags that are not meant for production (release candidates, nightlies …).
|
||||
fn is_prerelease(suffix: &str) -> bool {
|
||||
let s = suffix.to_ascii_lowercase();
|
||||
[
|
||||
"rc", "alpha", "beta", "dev", "nightly", "snapshot", "pre", "test",
|
||||
]
|
||||
.iter()
|
||||
.any(|m| s.starts_with(m) || s.contains(&format!("-{m}")))
|
||||
}
|
||||
|
||||
/// Tags from the registry that are newer than `current`, oldest first. Only tags with the
|
||||
/// same suffix (`-rootless`, `-debian-12` …) are considered, so the variant stays the same.
|
||||
pub fn newer_tags(current: &str, available: &[String]) -> Vec<String> {
|
||||
let Some((now, suffix)) = version_parts(current) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut newer: Vec<(Vec<u64>, String)> = available
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let (v, s) = version_parts(t)?;
|
||||
(s == suffix
|
||||
&& !is_prerelease(&s)
|
||||
&& cmp_version(&v, &now) == std::cmp::Ordering::Greater)
|
||||
.then_some((v, t.clone()))
|
||||
})
|
||||
.collect();
|
||||
// equal versions (1.10 and 1.10.0) keep a stable, predictable order
|
||||
newer.sort_by(|a, b| cmp_version(&a.0, &b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
newer.into_iter().map(|(_, t)| t).collect()
|
||||
}
|
||||
|
||||
/// The newest tag that is newer than `current`, if any.
|
||||
pub fn newest_tag(current: &str, available: &[String]) -> Option<String> {
|
||||
newer_tags(current, available).pop()
|
||||
}
|
||||
|
||||
fn cmp_version(a: &[u64], b: &[u64]) -> std::cmp::Ordering {
|
||||
let len = a.len().max(b.len());
|
||||
for i in 0..len {
|
||||
let (x, y) = (
|
||||
a.get(i).copied().unwrap_or(0),
|
||||
b.get(i).copied().unwrap_or(0),
|
||||
);
|
||||
if x != y {
|
||||
return x.cmp(&y);
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_the_common_reference_shapes() {
|
||||
let r = ImageRef::parse("docker.gitea.com/gitea:1.24.2-rootless").unwrap();
|
||||
assert_eq!(
|
||||
(r.registry.as_str(), r.repository.as_str(), r.tag.as_str()),
|
||||
("docker.gitea.com", "gitea", "1.24.2-rootless")
|
||||
);
|
||||
let r = ImageRef::parse("docker.io/bitnami/postgresql:17.5.0").unwrap();
|
||||
assert_eq!(
|
||||
(r.registry.as_str(), r.repository.as_str()),
|
||||
("docker.io", "bitnami/postgresql")
|
||||
);
|
||||
let r = ImageRef::parse("coredns/coredns:1.10.1").unwrap();
|
||||
assert_eq!(
|
||||
(r.registry.as_str(), r.repository.as_str()),
|
||||
("docker.io", "coredns/coredns")
|
||||
);
|
||||
let r = ImageRef::parse("nginx").unwrap();
|
||||
assert_eq!(
|
||||
(r.registry.as_str(), r.repository.as_str(), r.tag.as_str()),
|
||||
("docker.io", "library/nginx", "latest")
|
||||
);
|
||||
let r = ImageRef::parse("registry.k8s.io/ingress-nginx/controller:v1.11.5").unwrap();
|
||||
assert_eq!(
|
||||
(r.registry.as_str(), r.repository.as_str(), r.tag.as_str()),
|
||||
("registry.k8s.io", "ingress-nginx/controller", "v1.11.5")
|
||||
);
|
||||
let r = ImageRef::parse("localhost:32000/app:1").unwrap();
|
||||
assert_eq!(r.registry, "localhost:32000");
|
||||
assert_eq!(ImageRef::parse("").map(|r| r.repository), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilds_a_reference_with_another_tag() {
|
||||
assert_eq!(
|
||||
ImageRef::parse("docker.gitea.com/gitea:1.24.2")
|
||||
.unwrap()
|
||||
.with_tag("1.25.0"),
|
||||
"docker.gitea.com/gitea:1.25.0"
|
||||
);
|
||||
assert_eq!(
|
||||
ImageRef::parse("coredns/coredns:1.10.1")
|
||||
.unwrap()
|
||||
.with_tag("1.11.0"),
|
||||
"coredns/coredns:1.11.0"
|
||||
);
|
||||
assert_eq!(
|
||||
ImageRef::parse("nginx:1.0").unwrap().with_tag("1.1"),
|
||||
"nginx:1.1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggests_only_newer_tags_of_the_same_variant() {
|
||||
let tags: Vec<String> = [
|
||||
"1.23.8",
|
||||
"1.24.2",
|
||||
"1.24.3",
|
||||
"1.25.0",
|
||||
"1.25.1",
|
||||
"1.25.1-rootless",
|
||||
"1.24.2-rootless",
|
||||
"1.26.0-rc1",
|
||||
"latest",
|
||||
"dev",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
newer_tags("1.24.2", &tags),
|
||||
vec!["1.24.3", "1.25.0", "1.25.1"]
|
||||
);
|
||||
assert_eq!(newest_tag("1.24.2", &tags).as_deref(), Some("1.25.1"));
|
||||
// the variant is kept
|
||||
assert_eq!(
|
||||
newer_tags("1.24.2-rootless", &tags),
|
||||
vec!["1.25.1-rootless"]
|
||||
);
|
||||
// release candidates and non-version tags are ignored
|
||||
assert!(!newer_tags("1.25.1", &tags).iter().any(|t| t.contains("rc")));
|
||||
assert_eq!(newest_tag("1.25.1", &tags), None, "already the newest");
|
||||
assert_eq!(newest_tag("latest", &tags), None, "no version to compare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_versions_by_number_not_by_text() {
|
||||
let tags: Vec<String> = ["1.9.0", "1.10.0", "1.10", "2.0.0"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(newest_tag("1.9.0", &tags).as_deref(), Some("2.0.0"));
|
||||
assert_eq!(newer_tags("1.9.0", &tags), vec!["1.10", "1.10.0", "2.0.0"]);
|
||||
assert_eq!(
|
||||
newest_tag("v0.6.3", &["v0.7.0".to_string(), "v0.6.4".to_string()]).as_deref(),
|
||||
Some("v0.7.0")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ pub enum JobKind {
|
||||
PackageRefresh,
|
||||
PackageUpgrade,
|
||||
VulnerabilityScan,
|
||||
ImageUpdateCheck,
|
||||
Backup,
|
||||
}
|
||||
|
||||
@ -24,6 +25,7 @@ impl JobKind {
|
||||
JobKind::PackageRefresh => "package_refresh",
|
||||
JobKind::PackageUpgrade => "package_upgrade",
|
||||
JobKind::VulnerabilityScan => "vulnerability_scan",
|
||||
JobKind::ImageUpdateCheck => "image_update_check",
|
||||
JobKind::Backup => "backup",
|
||||
}
|
||||
}
|
||||
@ -37,6 +39,7 @@ impl JobKind {
|
||||
match self {
|
||||
JobKind::PackageRefresh => Some("0 0 * * * *"),
|
||||
JobKind::VulnerabilityScan => Some("0 0 3 * * *"),
|
||||
JobKind::ImageUpdateCheck => Some("0 30 4 * * *"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ pub mod backup;
|
||||
pub mod cluster;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod image;
|
||||
pub mod jobs;
|
||||
pub mod ports;
|
||||
pub mod settings;
|
||||
|
||||
@ -6,6 +6,7 @@ use crate::auth::{AccessClaims, AuthEvent, RefreshToken};
|
||||
use crate::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, RemoteFile};
|
||||
use crate::cluster::{ClusterOverview, WorkloadRef};
|
||||
use crate::host::{Inventory, OsInfo, Package};
|
||||
use crate::image::ImageUpdate;
|
||||
use crate::jobs::{JobKind, JobRun, JobStatus};
|
||||
use crate::settings::SmtpSettings;
|
||||
use crate::user::{User, UserUpdate};
|
||||
@ -221,3 +222,16 @@ pub trait BackupCollector: Send + Sync {
|
||||
pub trait FileEncryptor: Send + Sync {
|
||||
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError>;
|
||||
}
|
||||
|
||||
/// Reads the available tags of a container repository.
|
||||
#[async_trait]
|
||||
pub trait ImageRegistry: Send + Sync {
|
||||
async fn tags(&self, image: &str) -> Result<Vec<String>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ImageUpdateRepository: Send + Sync {
|
||||
async fn upsert(&self, update: &ImageUpdate) -> Result<(), DomainError>;
|
||||
async fn get(&self, image: &str) -> Result<Option<ImageUpdate>, DomainError>;
|
||||
async fn list(&self) -> Result<Vec<ImageUpdate>, DomainError>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user