Skip to main content

lab_ops/cmd/
dockernet.rs

1//! Prints a table of Docker containers with their IPs and port bindings.
2//!
3//! Connects to the local Docker daemon, inspects all containers, and displays
4//! their name, status, IP addresses (per network), and host port mappings.
5
6use std::collections::HashMap;
7
8use bollard::Docker;
9use bollard::plugin::EndpointSettings;
10use bollard::plugin::PortBinding;
11use bollard::query_parameters::ListContainersOptionsBuilder;
12use color_eyre::eyre::Result;
13use comfy_table::Attribute;
14use comfy_table::Color;
15use comfy_table::Table;
16
17/// Displays a table of Docker containers with IPs and port bindings.
18///
19/// Skips containers that exited successfully (status `Exited (0)`).
20/// Rows are sorted by name, then status, then bindings.
21///
22/// # Examples
23///
24/// ```no_run
25/// use lab_ops::cmd::dockernet;
26/// let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
27/// rt.block_on(dockernet::run(true)).unwrap();
28/// ```
29pub async fn run(use_color: bool) -> Result<()> {
30    let mut table = Table::new();
31    let mut rows = Vec::new();
32
33    let headers: Vec<comfy_table::Cell> = if use_color {
34        vec![
35            comfy_table::Cell::new("Name")
36                .fg(Color::Cyan)
37                .add_attribute(Attribute::Bold),
38            comfy_table::Cell::new("Status")
39                .fg(Color::Cyan)
40                .add_attribute(Attribute::Bold),
41            comfy_table::Cell::new("IP")
42                .fg(Color::Cyan)
43                .add_attribute(Attribute::Bold),
44            comfy_table::Cell::new("Binds")
45                .fg(Color::Cyan)
46                .add_attribute(Attribute::Bold),
47        ]
48    } else {
49        vec!["Name".into(), "Status".into(), "IP".into(), "Binds".into()]
50    };
51    table.set_header(headers);
52
53    let docker = Docker::connect_with_local_defaults()?;
54    let opt = Some(ListContainersOptionsBuilder::new().all(true).build());
55
56    for cont in docker.list_containers(opt).await? {
57        let Some(id) = cont.id else { continue };
58        let status = cont.status.unwrap_or_default();
59        if is_exit(&status) {
60            continue;
61        }
62
63        let name = format_name(cont.names);
64
65        let Some(network) = docker.inspect_container(&id, None).await?.network_settings else {
66            continue;
67        };
68
69        let ips = format_ips(network.networks);
70        let binds = format_binds(network.ports);
71
72        rows.push([name, status, ips, binds]);
73    }
74
75    rows.sort_by_key(|r| r[0].clone());
76    rows.sort_by_key(|r| r[1].clone());
77    rows.sort_by_key(|r| r[3].clone());
78
79    if use_color {
80        for row in &rows {
81            let status_cell = if row[1].contains("running") || row[1].contains("Up") {
82                comfy_table::Cell::new(&row[1]).fg(Color::Green)
83            } else if row[1].contains("Exited") || row[1].contains("Dead") {
84                comfy_table::Cell::new(&row[1]).fg(Color::Red)
85            } else {
86                comfy_table::Cell::new(&row[1])
87            };
88            table.add_row(vec![
89                comfy_table::Cell::new(&row[0]),
90                status_cell,
91                comfy_table::Cell::new(&row[2]),
92                comfy_table::Cell::new(&row[3]),
93            ]);
94        }
95    } else {
96        table.add_rows(rows);
97    }
98
99    println!("{table}");
100    Ok(())
101}
102
103/// Returns whether the container status indicates a clean exit.
104fn is_exit(status: impl AsRef<str>) -> bool {
105    status.as_ref().contains("Exited (0)")
106}
107
108/// Returns the first container name, stripping leading slashes.
109fn format_name(names: Option<Vec<String>>) -> String {
110    names
111        .unwrap_or_default()
112        .into_iter()
113        .next()
114        .unwrap_or_default()
115}
116
117/// Formats container IPs from all attached networks, joined by newlines.
118fn format_ips(networks: Option<HashMap<String, EndpointSettings>>) -> String {
119    let Some(networks) = networks else {
120        return String::new();
121    };
122    networks
123        .values()
124        .filter_map(|nw| nw.ip_address.clone())
125        .collect::<Vec<_>>()
126        .join("\n")
127}
128
129/// Formats port bindings as `proto: host_ip:host_port` lines.
130fn format_binds(ports: Option<HashMap<String, Option<Vec<PortBinding>>>>) -> String {
131    let Some(ports) = ports else {
132        return String::new();
133    };
134    ports
135        .iter()
136        .filter_map(|(port, binds)| {
137            let binds = binds.as_ref()?;
138            let proto = port.split('/').nth(1).unwrap_or("");
139            let addrs = binds
140                .iter()
141                .filter_map(|b| {
142                    let ip = b.host_ip.as_ref()?;
143                    let port = b.host_port.as_deref().unwrap_or("");
144                    Some(format!("{ip}:{port}"))
145                })
146                .collect::<Vec<_>>()
147                .join(", ");
148            Some(format!("{proto}: {addrs}"))
149        })
150        .collect::<Vec<_>>()
151        .join("\n")
152}
153
154#[cfg(test)]
155mod tests {
156    use std::collections::HashMap;
157
158    use bollard::models::EndpointSettings;
159    use bollard::models::PortBinding;
160
161    use super::*;
162
163    fn make_networks(ips: &[&str]) -> Option<HashMap<String, EndpointSettings>> {
164        Some(
165            ips.iter()
166                .enumerate()
167                .map(|(i, ip)| {
168                    (
169                        format!("net{i}"),
170                        EndpointSettings {
171                            ip_address: Some(ip.to_string()),
172                            ..Default::default()
173                        },
174                    )
175                })
176                .collect(),
177        )
178    }
179
180    fn make_ports(
181        bindings: &[(&str, &[(&str, &str)])],
182    ) -> Option<HashMap<String, Option<Vec<PortBinding>>>> {
183        Some(
184            bindings
185                .iter()
186                .map(|(port, binds)| {
187                    let binds = binds
188                        .iter()
189                        .map(|(ip, p)| PortBinding {
190                            host_ip: Some(ip.to_string()),
191                            host_port: Some(p.to_string()),
192                        })
193                        .collect();
194                    (port.to_string(), Some(binds))
195                })
196                .collect(),
197        )
198    }
199
200    #[test]
201    fn format_ips_none() {
202        assert_eq!(format_ips(None), "");
203    }
204
205    #[test]
206    fn format_ips_single() {
207        assert_eq!(format_ips(make_networks(&["172.19.0.2"])), "172.19.0.2");
208    }
209
210    #[test]
211    fn format_ips_multiple() {
212        let result = format_ips(make_networks(&["172.19.0.2", "10.0.0.1"]));
213        let mut lines: Vec<&str> = result.lines().collect();
214        lines.sort();
215        assert_eq!(lines, vec!["10.0.0.1", "172.19.0.2"]);
216    }
217
218    #[test]
219    fn format_binds_none() {
220        assert_eq!(format_binds(None), "");
221    }
222
223    #[test]
224    fn format_binds_single() {
225        let ports = make_ports(&[("5432/tcp", &[("0.0.0.0", "5432")])]);
226        assert_eq!(format_binds(ports), "tcp: 0.0.0.0:5432");
227    }
228
229    #[test]
230    fn format_binds_multiple_ips() {
231        let ports = make_ports(&[("5432/tcp", &[("0.0.0.0", "5432"), ("::", "5432")])]);
232        assert_eq!(format_binds(ports), "tcp: 0.0.0.0:5432, :::5432");
233    }
234
235    #[test]
236    fn format_binds_no_proto() {
237        let ports = make_ports(&[("5432", &[("0.0.0.0", "5432")])]);
238        assert_eq!(format_binds(ports), ": 0.0.0.0:5432");
239    }
240
241    #[test]
242    fn format_binds_null_binding() {
243        let mut map = HashMap::new();
244        map.insert("5432/tcp".to_string(), None);
245        assert_eq!(format_binds(Some(map)), "");
246    }
247}