Skip to main content

olai_stack_runtime/
status.rs

1//! Typed project and service status, parsed from `docker compose ls/ps --format json`.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7/// One Compose project Docker knows of (a row of `docker compose ls --all`).
8#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
9pub struct ProjectStatus {
10    /// The project name.
11    pub name: String,
12    /// Compose's status string as it prints it, e.g. `exited(1), running(2)`.
13    pub status: String,
14    /// The containers per state, parsed from [`status`](Self::status), e.g. `running → 2`.
15    pub states: BTreeMap<String, u32>,
16    /// The compose files the project was started from, comma-separated as Compose lists them.
17    pub config_files: String,
18}
19
20impl ProjectStatus {
21    /// How many of the project's containers are running.
22    pub fn running(&self) -> u32 {
23        self.states.get("running").copied().unwrap_or(0)
24    }
25
26    /// Whether any of the project's containers is running.
27    pub fn is_running(&self) -> bool {
28        self.running() > 0
29    }
30}
31
32/// Parse `docker compose ls --all --format json`: a JSON array of projects.
33pub fn parse_ls(json: &str) -> Result<Vec<ProjectStatus>, serde_json::Error> {
34    #[derive(Deserialize)]
35    struct Row {
36        #[serde(rename = "Name")]
37        name: String,
38        #[serde(rename = "Status", default)]
39        status: String,
40        #[serde(rename = "ConfigFiles", default)]
41        config_files: String,
42    }
43    if json.trim().is_empty() {
44        return Ok(Vec::new());
45    }
46    let rows: Vec<Row> = serde_json::from_str(json)?;
47    Ok(rows
48        .into_iter()
49        .map(|r| ProjectStatus {
50            states: parse_states(&r.status),
51            name: r.name,
52            status: r.status,
53            config_files: r.config_files,
54        })
55        .collect())
56}
57
58/// `exited(1), running(2)` → `{exited: 1, running: 2}`. Parts that don't parse are skipped.
59fn parse_states(status: &str) -> BTreeMap<String, u32> {
60    status
61        .split(',')
62        .filter_map(|part| {
63            let (state, count) = part.trim().split_once('(')?;
64            let count = count.strip_suffix(')')?.parse().ok()?;
65            Some((state.to_owned(), count))
66        })
67        .collect()
68}
69
70/// A container's lifecycle state, as Docker reports it.
71#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum State {
74    /// Created, never started.
75    Created,
76    /// Running.
77    Running,
78    /// Paused.
79    Paused,
80    /// Restarting after an exit, under a restart policy.
81    Restarting,
82    /// Being removed.
83    Removing,
84    /// Exited.
85    Exited,
86    /// Dead (could not be stopped or removed cleanly).
87    Dead,
88    /// A state this crate doesn't know.
89    Other(String),
90}
91
92impl State {
93    fn parse(s: &str) -> Self {
94        match s {
95            "created" => State::Created,
96            "running" => State::Running,
97            "paused" => State::Paused,
98            "restarting" => State::Restarting,
99            "removing" => State::Removing,
100            "exited" => State::Exited,
101            "dead" => State::Dead,
102            other => State::Other(other.to_owned()),
103        }
104    }
105}
106
107/// A container's health, as its healthcheck reports it.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum Health {
111    /// The service declares no healthcheck.
112    None,
113    /// The healthcheck hasn't passed yet (within its start period).
114    Starting,
115    /// The healthcheck passes.
116    Healthy,
117    /// The healthcheck fails.
118    Unhealthy,
119}
120
121impl Health {
122    /// Parse Docker's health word; an empty string (no healthcheck) and unknown words are
123    /// [`Health::None`].
124    pub fn parse(s: &str) -> Self {
125        match s.trim() {
126            "starting" => Health::Starting,
127            "healthy" => Health::Healthy,
128            "unhealthy" => Health::Unhealthy,
129            _ => Health::None,
130        }
131    }
132}
133
134/// A host port a container publishes.
135#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
136pub struct Published {
137    /// The host address it is bound on (e.g. `0.0.0.0`, `127.0.0.1`).
138    pub address: String,
139    /// The host port.
140    pub host_port: u16,
141    /// The container port.
142    pub container_port: u16,
143    /// `tcp` or `udp`.
144    pub protocol: String,
145}
146
147/// One container of a project (a row of `docker compose ps --all`).
148#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
149pub struct ServiceStatus {
150    /// The compose service.
151    pub service: String,
152    /// The container's name.
153    pub container: String,
154    /// Its lifecycle state.
155    pub state: State,
156    /// Its health.
157    pub health: Health,
158    /// Its exit code (meaningful once [`State::Exited`]).
159    pub exit_code: i32,
160    /// Docker's human status, e.g. `Up 5 seconds (healthy)`.
161    pub status: String,
162    /// The host ports it publishes.
163    pub published: Vec<Published>,
164}
165
166impl ServiceStatus {
167    /// Whether the container runs and, if it has a healthcheck, passes it.
168    pub fn is_ready(&self) -> bool {
169        self.state == State::Running && matches!(self.health, Health::None | Health::Healthy)
170    }
171}
172
173/// Parse `docker compose ps --all --format json`. Compose prints one JSON object per line;
174/// some versions print a single JSON array instead, which is accepted too.
175pub fn parse_ps(json: &str) -> Result<Vec<ServiceStatus>, serde_json::Error> {
176    #[derive(Deserialize)]
177    struct Publisher {
178        #[serde(rename = "URL", default)]
179        url: String,
180        #[serde(rename = "TargetPort", default)]
181        target_port: u16,
182        #[serde(rename = "PublishedPort", default)]
183        published_port: u16,
184        #[serde(rename = "Protocol", default)]
185        protocol: String,
186    }
187    #[derive(Deserialize)]
188    struct Row {
189        #[serde(rename = "Service")]
190        service: String,
191        #[serde(rename = "Name", default)]
192        name: String,
193        #[serde(rename = "State", default)]
194        state: String,
195        #[serde(rename = "Health", default)]
196        health: String,
197        #[serde(rename = "ExitCode", default)]
198        exit_code: i32,
199        #[serde(rename = "Status", default)]
200        status: String,
201        #[serde(rename = "Publishers", default)]
202        publishers: Option<Vec<Publisher>>,
203    }
204    let json = json.trim();
205    let rows: Vec<Row> = if json.starts_with('[') {
206        serde_json::from_str(json)?
207    } else {
208        json.lines()
209            .filter(|l| !l.trim().is_empty())
210            .map(serde_json::from_str)
211            .collect::<Result<_, _>>()?
212    };
213    Ok(rows
214        .into_iter()
215        .map(|r| ServiceStatus {
216            service: r.service,
217            container: r.name,
218            state: State::parse(&r.state),
219            health: Health::parse(&r.health),
220            exit_code: r.exit_code,
221            status: r.status,
222            published: r
223                .publishers
224                .unwrap_or_default()
225                .into_iter()
226                // Compose lists exposed-but-unpublished ports with a zero host port.
227                .filter(|p| p.published_port != 0)
228                .map(|p| Published {
229                    address: p.url,
230                    host_port: p.published_port,
231                    container_port: p.target_port,
232                    protocol: p.protocol,
233                })
234                .collect(),
235        })
236        .collect())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn states_parse_from_the_status_string() {
245        let states = parse_states("exited(1), running(2)");
246        assert_eq!(states.get("exited"), Some(&1));
247        assert_eq!(states.get("running"), Some(&2));
248        assert!(parse_states("").is_empty());
249        assert!(parse_states("garbage").is_empty());
250    }
251
252    #[test]
253    fn an_empty_ls_is_no_projects() {
254        assert!(parse_ls("").unwrap().is_empty());
255        assert!(parse_ls("[]\n").unwrap().is_empty());
256    }
257
258    #[test]
259    fn ps_accepts_an_array_too() {
260        let rows = parse_ps(
261            r#"[{"Service":"web","Name":"p-web-1","State":"running","Health":"","ExitCode":0,
262                 "Status":"Up","Publishers":null}]"#,
263        )
264        .unwrap();
265        assert_eq!(rows.len(), 1);
266        assert!(rows[0].is_ready());
267        assert!(rows[0].published.is_empty());
268    }
269}