use anyhow::{Result, anyhow};
use opencode_cloud_core::docker::{DockerClient, DockerEndpoint};
use reqwest::Client;
use serde_json::Value;
use std::time::Duration;
use sysinfo::Disks;
#[derive(Clone, Copy)]
pub struct DiskUsageReport {
pub images: Option<i64>,
pub containers: Option<i64>,
pub volumes: Option<i64>,
pub build_cache: Option<i64>,
pub total: Option<i64>,
}
#[derive(Clone, Copy)]
pub struct HostDiskReport {
pub total: u64,
pub available: u64,
pub used: u64,
}
pub fn format_disk_usage_report(
stage: &str,
report: DiskUsageReport,
baseline: Option<DiskUsageReport>,
) -> Vec<String> {
let mut lines = vec![
format!("Docker disk usage ({stage}):"),
format!(" Images: {}", format_usage_value(report.images)),
format!(" Containers: {}", format_usage_value(report.containers)),
format!(" Volumes: {}", format_usage_value(report.volumes)),
format!(" Build cache: {}", format_usage_value(report.build_cache)),
format!(" Total: {}", format_usage_value(report.total)),
];
if let Some(delta) = format_delta_i64(report.total, baseline.and_then(|b| b.total)) {
lines.push(format!(" Delta: {delta}"));
}
lines
}
pub fn format_host_disk_report(
stage: &str,
report: HostDiskReport,
baseline: Option<HostDiskReport>,
) -> Vec<String> {
let mut lines = vec![
format!("Host disk ({stage}):"),
format!(
" Total: {}",
format_usage_value_u64(Some(report.total))
),
format!(
" Available: {}",
format_usage_value_u64(Some(report.available))
),
format!(
" Used: {}",
format_usage_value_u64(Some(report.used))
),
];
if let Some(delta) = format_delta_u64(Some(report.used), baseline.map(|baseline| baseline.used))
{
lines.push(format!(" Delta used: {delta}"));
}
lines
}
pub async fn get_disk_usage_report(client: &DockerClient) -> Result<DiskUsageReport> {
let data_usage = match fetch_system_df_json(client, true).await {
Ok(payload) => payload,
Err(err) => {
tracing::debug!("Verbose /system/df failed, retrying without verbose: {err}");
fetch_system_df_json(client, false).await?
}
};
Ok(build_disk_usage_report(&data_usage))
}
pub fn get_host_disk_report(client: &DockerClient) -> Result<Option<HostDiskReport>> {
if client.is_remote() {
return Ok(None);
}
let disks = Disks::new_with_refreshed_list();
Ok(build_host_disk_report(&disks))
}
pub fn format_bytes_i64(value: i64) -> String {
if value < 0 {
return "unknown".to_string();
}
format_bytes_u64(value as u64)
}
fn build_disk_usage_report(data_usage: &Value) -> DiskUsageReport {
let images =
parse_verbose_total(data_usage, "ImageUsage").or_else(|| parse_layers_size(data_usage));
let containers = parse_verbose_total(data_usage, "ContainerUsage")
.or_else(|| sum_container_sizes(data_usage));
let volumes =
parse_verbose_total(data_usage, "VolumeUsage").or_else(|| sum_volume_sizes(data_usage));
let build_cache = parse_verbose_total(data_usage, "BuildCacheUsage")
.or_else(|| sum_build_cache_sizes(data_usage));
let total = match (images, containers, volumes, build_cache) {
(Some(images), Some(containers), Some(volumes), Some(build_cache)) => {
Some(images + containers + volumes + build_cache)
}
_ => None,
};
DiskUsageReport {
images,
containers,
volumes,
build_cache,
total,
}
}
fn build_host_disk_report(disks: &Disks) -> Option<HostDiskReport> {
if disks.list().is_empty() {
return None;
}
let mut total = 0u64;
let mut available = 0u64;
for disk in disks.list() {
total = total.saturating_add(disk.total_space());
available = available.saturating_add(disk.available_space());
}
let used = total.saturating_sub(available);
Some(HostDiskReport {
total,
available,
used,
})
}
fn format_delta_i64(after: Option<i64>, before: Option<i64>) -> Option<String> {
let (after, before) = (after?, before?);
let delta = after - before;
let sign = if delta >= 0 { "+" } else { "-" };
Some(format!("{sign}{}", format_bytes_u64(delta.unsigned_abs())))
}
fn format_delta_u64(after: Option<u64>, before: Option<u64>) -> Option<String> {
let (after, before) = (after?, before?);
if after >= before {
Some(format!("+{}", format_bytes_u64(after - before)))
} else {
Some(format!("-{}", format_bytes_u64(before - after)))
}
}
fn format_bytes_u64(value: u64) -> String {
let units = ["B", "KB", "MB", "GB", "TB", "PB"];
let mut size = value as f64;
let mut index = 0usize;
while size >= 1024.0 && index + 1 < units.len() {
size /= 1024.0;
index += 1;
}
if index == 0 {
format!("{value} {}", units[index])
} else {
format!("{size:.2} {}", units[index])
}
}
fn format_usage_value(value: Option<i64>) -> String {
value
.map(format_bytes_i64)
.unwrap_or_else(|| "unknown".to_string())
}
fn format_usage_value_u64(value: Option<u64>) -> String {
value
.map(format_bytes_u64)
.unwrap_or_else(|| "unknown".to_string())
}
async fn fetch_system_df_json(client: &DockerClient, verbose: bool) -> Result<Value> {
let http = build_reqwest_client(client.endpoint())?;
let url = system_df_url(client.endpoint(), verbose)?;
let response = http
.get(url)
.send()
.await
.map_err(|e| anyhow!("Failed to request /system/df: {e}"))?
.error_for_status()
.map_err(|e| anyhow!("Docker /system/df returned error: {e}"))?;
response
.json::<Value>()
.await
.map_err(|e| anyhow!("Failed to parse /system/df JSON: {e}"))
}
fn build_reqwest_client(endpoint: &DockerEndpoint) -> Result<Client> {
let mut builder = Client::builder().timeout(Duration::from_secs(30));
match endpoint {
DockerEndpoint::Unix(path) => {
#[cfg(unix)]
{
builder = builder.unix_socket(path.clone());
}
#[cfg(not(unix))]
{
return Err(anyhow!(
"Unix socket Docker endpoints are not supported on this platform."
));
}
}
DockerEndpoint::Http(_) => {}
}
builder
.build()
.map_err(|e| anyhow!("Failed to build HTTP client: {e}"))
}
fn system_df_url(endpoint: &DockerEndpoint, verbose: bool) -> Result<String> {
let base = match endpoint {
DockerEndpoint::Unix(_) => "http://localhost".to_string(),
DockerEndpoint::Http(base) => base.trim_end_matches('/').to_string(),
};
let suffix = if verbose { "?verbose=true" } else { "" };
Ok(format!("{base}/system/df{suffix}"))
}
fn parse_verbose_total(data_usage: &Value, key: &str) -> Option<i64> {
data_usage
.get(key)
.and_then(|usage| usage.get("TotalSize"))
.and_then(|value| value.as_i64())
.filter(|value| *value >= 0)
}
fn parse_layers_size(data_usage: &Value) -> Option<i64> {
data_usage
.get("LayersSize")
.and_then(|value| value.as_i64())
.filter(|value| *value >= 0)
}
fn sum_container_sizes(data_usage: &Value) -> Option<i64> {
sum_array_sizes(data_usage, "Containers", &["SizeRw"])
}
fn sum_volume_sizes(data_usage: &Value) -> Option<i64> {
sum_array_sizes(data_usage, "Volumes", &["UsageData", "Size"])
}
fn sum_build_cache_sizes(data_usage: &Value) -> Option<i64> {
sum_array_sizes(data_usage, "BuildCache", &["Size"])
}
fn sum_array_sizes(data_usage: &Value, key: &str, path: &[&str]) -> Option<i64> {
let array = data_usage.get(key)?.as_array()?;
let mut total = 0i64;
let mut seen = false;
for item in array {
let mut cursor = item;
let mut missing = false;
for segment in path {
match cursor.get(*segment) {
Some(next) => cursor = next,
None => {
missing = true;
break;
}
}
}
if missing {
continue;
}
if let Some(value) = cursor.as_i64().filter(|value| *value >= 0) {
total = total.saturating_add(value);
seen = true;
}
}
seen.then_some(total)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_disk_usage_report_parses_verbose_fields() {
let payload = json!({
"ImageUsage": { "TotalSize": 10 },
"ContainerUsage": { "TotalSize": 20 },
"VolumeUsage": { "TotalSize": 30 },
"BuildCacheUsage": { "TotalSize": 40 }
});
let report = build_disk_usage_report(&payload);
assert_eq!(report.images, Some(10));
assert_eq!(report.containers, Some(20));
assert_eq!(report.volumes, Some(30));
assert_eq!(report.build_cache, Some(40));
assert_eq!(report.total, Some(100));
}
#[test]
fn build_disk_usage_report_parses_legacy_fields() {
let payload = json!({
"LayersSize": 5,
"Containers": [{ "SizeRw": 2 }, { "SizeRw": 3 }],
"Volumes": [{ "UsageData": { "Size": 7 } }],
"BuildCache": [{ "Size": 11 }, { "Size": 13 }]
});
let report = build_disk_usage_report(&payload);
assert_eq!(report.images, Some(5));
assert_eq!(report.containers, Some(5));
assert_eq!(report.volumes, Some(7));
assert_eq!(report.build_cache, Some(24));
assert_eq!(report.total, Some(41));
}
}