fix: fix mobile view + add orphean deletions

This commit is contained in:
2026-05-20 08:48:14 +02:00
parent b3176c4dfa
commit 35643b2ea9
8 changed files with 1185 additions and 149 deletions

View File

@ -4,9 +4,9 @@ use bollard::{
ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
},
image::ListImagesOptions,
image::{ListImagesOptions, RemoveImageOptions},
network::ListNetworksOptions,
volume::ListVolumesOptions,
volume::{ListVolumesOptions, RemoveVolumeOptions},
Docker,
};
use futures_util::Stream;
@ -31,6 +31,23 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static {
fn list_volumes(&self) -> impl std::future::Future<Output = Result<Vec<VolumeInfo>>> + Send;
fn list_networks(&self) -> impl std::future::Future<Output = Result<Vec<NetworkInfo>>> + Send;
fn remove_image(
&self,
image_id: &str,
force: bool,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn remove_volume(
&self,
volume_name: &str,
force: bool,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn remove_network(
&self,
network_id: &str,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn logs(
&self,
id: &str,
@ -146,54 +163,139 @@ impl ContainerBackend for DockerClient {
}
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
// Fetch all containers to cross-reference image IDs
let containers = self
.inner
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await
.unwrap_or_default();
// Collect the set of image IDs currently used by containers
let used_image_ids: std::collections::HashSet<String> = containers
.iter()
.filter_map(|c| c.image_id.clone())
.collect();
let images = self
.inner
.list_images(None::<ListImagesOptions<String>>)
.await?;
Ok(images
.into_iter()
.map(|c| ImageInfo {
id: c.id,
tags: c.repo_tags,
size: c.size,
created_at: c.created,
.map(|c| {
let is_orphan = !used_image_ids.contains(&c.id);
ImageInfo {
id: c.id,
tags: c.repo_tags,
size: c.size,
created_at: c.created,
is_orphan,
}
})
.collect())
}
async fn list_volumes(&self) -> Result<Vec<VolumeInfo>> {
// Fetch containers to cross-reference volume mounts
let containers = self
.inner
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await
.unwrap_or_default();
// Collect volume names that are mounted in at least one container
let used_volumes: std::collections::HashSet<String> = containers
.iter()
.flat_map(|c| c.mounts.iter().flatten())
.filter_map(|m| m.name.clone())
.collect();
let info = self
.inner
.list_volumes(None::<ListVolumesOptions<String>>)
.await?;
Ok(info
.volumes
.unwrap_or_default()
.into_iter()
.map(|v| VolumeInfo {
name: v.name,
driver: v.driver,
mountpoint: v.mountpoint,
.map(|v| {
let is_orphan = !used_volumes.contains(&v.name);
VolumeInfo {
name: v.name,
driver: v.driver,
mountpoint: v.mountpoint,
is_orphan,
}
})
.collect())
}
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
const SYSTEM_NETWORKS: &[&str] = &["bridge", "host", "none"];
let networks = self
.inner
.list_networks(None::<ListNetworksOptions<String>>)
.await?;
Ok(networks
.into_iter()
.map(|n| NetworkInfo {
id: n.id.unwrap_or_default(),
name: n.name.unwrap_or_default(),
driver: n.driver.unwrap_or_default(),
scope: n.scope.unwrap_or_default(),
.map(|n| {
let name = n.name.clone().unwrap_or_default();
let is_system = SYSTEM_NETWORKS.contains(&name.as_str());
// A network is orphan if it is not a system network and has no
// containers connected to it (the `containers` map is absent or empty).
let has_containers = n
.containers
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false);
let is_orphan = !is_system && !has_containers;
NetworkInfo {
id: n.id.unwrap_or_default(),
name,
driver: n.driver.unwrap_or_default(),
scope: n.scope.unwrap_or_default(),
is_orphan,
}
})
.collect())
}
async fn remove_image(&self, image_id: &str, force: bool) -> Result<()> {
self.inner
.remove_image(
image_id,
Some(RemoveImageOptions {
force,
noprune: false,
}),
None,
)
.await?;
Ok(())
}
async fn remove_volume(&self, volume_name: &str, force: bool) -> Result<()> {
self.inner
.remove_volume(volume_name, Some(RemoveVolumeOptions { force }))
.await?;
Ok(())
}
async fn remove_network(&self, network_id: &str) -> Result<()> {
self.inner.remove_network(network_id).await?;
Ok(())
}
fn logs(
&self,
id: &str,
@ -294,6 +396,7 @@ pub mod tests {
tags: vec!["nginx:latest".to_string()],
size: 1024,
created_at: 1_700_000_000,
is_orphan: false,
}])
}
@ -304,6 +407,7 @@ pub mod tests {
name: "data".to_string(),
driver: "local".to_string(),
mountpoint: "/var/lib/docker/volumes/data/_data".to_string(),
is_orphan: false,
}])
}
@ -315,9 +419,25 @@ pub mod tests {
name: "bridge".to_string(),
driver: "bridge".to_string(),
scope: "local".to_string(),
is_orphan: false,
}])
}
async fn remove_image(&self, image_id: &str, _force: bool) -> Result<()> {
self.record(format!("remove_image:{image_id}"));
self.maybe_err()
}
async fn remove_volume(&self, volume_name: &str, _force: bool) -> Result<()> {
self.record(format!("remove_volume:{volume_name}"));
self.maybe_err()
}
async fn remove_network(&self, network_id: &str) -> Result<()> {
self.record(format!("remove_network:{network_id}"));
self.maybe_err()
}
async fn start(&self, id: &str) -> Result<()> {
self.record(format!("start:{id}"));
self.maybe_err()

View File

@ -338,6 +338,69 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
}).await;
});
}
Some(server_message::Payload::DeleteImage(cmd)) => {
let tx_clone = tx.clone();
let docker_clone = docker.clone();
tokio::spawn(async move {
let result = docker_clone.remove_image(&cmd.image_id, cmd.force).await;
let (success, error) = match result {
Ok(()) => (true, String::new()),
Err(e) => {
warn!("remove_image {} failed: {:#}", cmd.image_id, e);
(false, e.to_string())
}
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::Result(proto::CommandResult {
command_id: cmd.command_id,
success,
error,
})),
}).await;
});
}
Some(server_message::Payload::DeleteVolume(cmd)) => {
let tx_clone = tx.clone();
let docker_clone = docker.clone();
tokio::spawn(async move {
let result = docker_clone.remove_volume(&cmd.volume_name, cmd.force).await;
let (success, error) = match result {
Ok(()) => (true, String::new()),
Err(e) => {
warn!("remove_volume {} failed: {:#}", cmd.volume_name, e);
(false, e.to_string())
}
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::Result(proto::CommandResult {
command_id: cmd.command_id,
success,
error,
})),
}).await;
});
}
Some(server_message::Payload::DeleteNetwork(cmd)) => {
let tx_clone = tx.clone();
let docker_clone = docker.clone();
tokio::spawn(async move {
let result = docker_clone.remove_network(&cmd.network_id).await;
let (success, error) = match result {
Ok(()) => (true, String::new()),
Err(e) => {
warn!("remove_network {} failed: {:#}", cmd.network_id, e);
(false, e.to_string())
}
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::Result(proto::CommandResult {
command_id: cmd.command_id,
success,
error,
})),
}).await;
});
}
None => {}
}
}
@ -1157,4 +1220,110 @@ mod tests {
Some(agent_message::Payload::UpdateCheckResult(_))
));
}
// ── DeleteImage / DeleteVolume / DeleteNetwork via MockBackend ────────────
#[tokio::test]
async fn mock_remove_image_records_call() {
let backend = MockBackend::new();
backend.remove_image("sha256:abc", false).await.unwrap();
assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_image:sha256:abc"]);
}
#[tokio::test]
async fn mock_remove_image_force_records_call() {
let backend = MockBackend::new();
backend.remove_image("sha256:abc", true).await.unwrap();
assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_image:sha256:abc"]);
}
#[tokio::test]
async fn mock_remove_image_propagates_error() {
let backend = MockBackend::failing("image in use");
let err = backend.remove_image("sha256:abc", false).await.unwrap_err();
assert!(err.to_string().contains("image in use"));
}
#[tokio::test]
async fn mock_remove_volume_records_call() {
let backend = MockBackend::new();
backend.remove_volume("my-vol", false).await.unwrap();
assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_volume:my-vol"]);
}
#[tokio::test]
async fn mock_remove_volume_propagates_error() {
let backend = MockBackend::failing("volume in use");
let err = backend.remove_volume("my-vol", false).await.unwrap_err();
assert!(err.to_string().contains("volume in use"));
}
#[tokio::test]
async fn mock_remove_network_records_call() {
let backend = MockBackend::new();
backend.remove_network("net-abc").await.unwrap();
assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_network:net-abc"]);
}
#[tokio::test]
async fn mock_remove_network_propagates_error() {
let backend = MockBackend::failing("network active");
let err = backend.remove_network("net-abc").await.unwrap_err();
assert!(err.to_string().contains("network active"));
}
// ── is_orphan field on proto structs ──────────────────────────────────────
#[test]
fn image_info_orphan_field() {
let orphan = ImageInfo {
id: "sha256:orphan".to_string(),
tags: vec![],
size: 512,
created_at: 0,
is_orphan: true,
};
assert!(orphan.is_orphan);
let used = ImageInfo {
id: "sha256:used".to_string(),
tags: vec!["app:latest".to_string()],
size: 1024,
created_at: 0,
is_orphan: false,
};
assert!(!used.is_orphan);
}
#[test]
fn volume_info_orphan_field() {
let orphan = VolumeInfo {
name: "orphan-vol".to_string(),
driver: "local".to_string(),
mountpoint: "/var/lib/docker/volumes/orphan-vol/_data".to_string(),
is_orphan: true,
};
assert!(orphan.is_orphan);
}
#[test]
fn network_info_orphan_field() {
let orphan = NetworkInfo {
id: "net-orphan".to_string(),
name: "my-unused-net".to_string(),
driver: "bridge".to_string(),
scope: "local".to_string(),
is_orphan: true,
};
assert!(orphan.is_orphan);
let system = NetworkInfo {
id: "net-bridge".to_string(),
name: "bridge".to_string(),
driver: "bridge".to_string(),
scope: "local".to_string(),
is_orphan: false,
};
assert!(!system.is_orphan);
}
}