fix: fix mobile view + add orphean deletions
This commit is contained in:
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user