lab-ops 0.1.16

Personal utility tools for my homelab
Documentation
//! Prints a table of Docker containers with their IPs and port bindings.
//!
//! Connects to the local Docker daemon, inspects all containers, and displays
//! their name, status, IP addresses (per network), and host port mappings.

use std::collections::HashMap;

use bollard::Docker;
use bollard::plugin::EndpointSettings;
use bollard::plugin::PortBinding;
use bollard::query_parameters::ListContainersOptionsBuilder;
use color_eyre::eyre::Result;
use comfy_table::Attribute;
use comfy_table::Color;
use comfy_table::Table;

/// Displays a table of Docker containers with IPs and port bindings.
///
/// Skips containers that exited successfully (status `Exited (0)`).
/// Rows are sorted by name, then status, then bindings.
///
/// # Examples
///
/// ```no_run
/// use lab_ops::cmd::dockernet;
/// let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
/// rt.block_on(dockernet::run(true)).unwrap();
/// ```
pub async fn run(use_color: bool) -> Result<()> {
    let mut table = Table::new();
    let mut rows = Vec::new();

    let headers: Vec<comfy_table::Cell> = if use_color {
        vec![
            comfy_table::Cell::new("Name")
                .fg(Color::Cyan)
                .add_attribute(Attribute::Bold),
            comfy_table::Cell::new("Status")
                .fg(Color::Cyan)
                .add_attribute(Attribute::Bold),
            comfy_table::Cell::new("IP")
                .fg(Color::Cyan)
                .add_attribute(Attribute::Bold),
            comfy_table::Cell::new("Binds")
                .fg(Color::Cyan)
                .add_attribute(Attribute::Bold),
        ]
    } else {
        vec!["Name".into(), "Status".into(), "IP".into(), "Binds".into()]
    };
    table.set_header(headers);

    let docker = Docker::connect_with_local_defaults()?;
    let opt = Some(ListContainersOptionsBuilder::new().all(true).build());

    for cont in docker.list_containers(opt).await? {
        let Some(id) = cont.id else { continue };
        let status = cont.status.unwrap_or_default();
        if is_exit(&status) {
            continue;
        }

        let name = format_name(cont.names);

        let Some(network) = docker.inspect_container(&id, None).await?.network_settings else {
            continue;
        };

        let ips = format_ips(network.networks);
        let binds = format_binds(network.ports);

        rows.push([name, status, ips, binds]);
    }

    rows.sort_by_key(|r| r[0].clone());
    rows.sort_by_key(|r| r[1].clone());
    rows.sort_by_key(|r| r[3].clone());

    if use_color {
        for row in &rows {
            let status_cell = if row[1].contains("running") || row[1].contains("Up") {
                comfy_table::Cell::new(&row[1]).fg(Color::Green)
            } else if row[1].contains("Exited") || row[1].contains("Dead") {
                comfy_table::Cell::new(&row[1]).fg(Color::Red)
            } else {
                comfy_table::Cell::new(&row[1])
            };
            table.add_row(vec![
                comfy_table::Cell::new(&row[0]),
                status_cell,
                comfy_table::Cell::new(&row[2]),
                comfy_table::Cell::new(&row[3]),
            ]);
        }
    } else {
        table.add_rows(rows);
    }

    println!("{table}");
    Ok(())
}

/// Returns whether the container status indicates a clean exit.
fn is_exit(status: impl AsRef<str>) -> bool {
    status.as_ref().contains("Exited (0)")
}

/// Returns the first container name, stripping leading slashes.
fn format_name(names: Option<Vec<String>>) -> String {
    names
        .unwrap_or_default()
        .into_iter()
        .next()
        .unwrap_or_default()
}

/// Formats container IPs from all attached networks, joined by newlines.
fn format_ips(networks: Option<HashMap<String, EndpointSettings>>) -> String {
    let Some(networks) = networks else {
        return String::new();
    };
    networks
        .values()
        .filter_map(|nw| nw.ip_address.clone())
        .collect::<Vec<_>>()
        .join("\n")
}

/// Formats port bindings as `proto: host_ip:host_port` lines.
fn format_binds(ports: Option<HashMap<String, Option<Vec<PortBinding>>>>) -> String {
    let Some(ports) = ports else {
        return String::new();
    };
    ports
        .iter()
        .filter_map(|(port, binds)| {
            let binds = binds.as_ref()?;
            let proto = port.split('/').nth(1).unwrap_or("");
            let addrs = binds
                .iter()
                .filter_map(|b| {
                    let ip = b.host_ip.as_ref()?;
                    let port = b.host_port.as_deref().unwrap_or("");
                    Some(format!("{ip}:{port}"))
                })
                .collect::<Vec<_>>()
                .join(", ");
            Some(format!("{proto}: {addrs}"))
        })
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use bollard::models::EndpointSettings;
    use bollard::models::PortBinding;

    use super::*;

    fn make_networks(ips: &[&str]) -> Option<HashMap<String, EndpointSettings>> {
        Some(
            ips.iter()
                .enumerate()
                .map(|(i, ip)| {
                    (
                        format!("net{i}"),
                        EndpointSettings {
                            ip_address: Some(ip.to_string()),
                            ..Default::default()
                        },
                    )
                })
                .collect(),
        )
    }

    fn make_ports(
        bindings: &[(&str, &[(&str, &str)])],
    ) -> Option<HashMap<String, Option<Vec<PortBinding>>>> {
        Some(
            bindings
                .iter()
                .map(|(port, binds)| {
                    let binds = binds
                        .iter()
                        .map(|(ip, p)| PortBinding {
                            host_ip: Some(ip.to_string()),
                            host_port: Some(p.to_string()),
                        })
                        .collect();
                    (port.to_string(), Some(binds))
                })
                .collect(),
        )
    }

    #[test]
    fn test_format_ips_none() {
        assert_eq!(format_ips(None), "");
    }

    #[test]
    fn test_format_ips_single() {
        assert_eq!(format_ips(make_networks(&["172.19.0.2"])), "172.19.0.2");
    }

    #[test]
    fn test_format_ips_multiple() {
        let result = format_ips(make_networks(&["172.19.0.2", "10.0.0.1"]));
        let mut lines: Vec<&str> = result.lines().collect();
        lines.sort();
        assert_eq!(lines, vec!["10.0.0.1", "172.19.0.2"]);
    }

    #[test]
    fn test_format_binds_none() {
        assert_eq!(format_binds(None), "");
    }

    #[test]
    fn test_format_binds_single() {
        let ports = make_ports(&[("5432/tcp", &[("0.0.0.0", "5432")])]);
        assert_eq!(format_binds(ports), "tcp: 0.0.0.0:5432");
    }

    #[test]
    fn test_format_binds_multiple_ips() {
        let ports = make_ports(&[("5432/tcp", &[("0.0.0.0", "5432"), ("::", "5432")])]);
        assert_eq!(format_binds(ports), "tcp: 0.0.0.0:5432, :::5432");
    }

    #[test]
    fn test_format_binds_no_proto() {
        let ports = make_ports(&[("5432", &[("0.0.0.0", "5432")])]);
        assert_eq!(format_binds(ports), ": 0.0.0.0:5432");
    }

    #[test]
    fn test_format_binds_null_binding() {
        let mut map = HashMap::new();
        map.insert("5432/tcp".to_string(), None);
        assert_eq!(format_binds(Some(map)), "");
    }
}