Skip to main content

horto_os_ui_shared/ops/
status.rs

1//! Aggregate box status for CLI / TUI / status API.
2
3use crate::context::HostContext;
4use crate::embed;
5use crate::kits::docker::{self, ContainerInfo};
6use crate::kits::envfile;
7use crate::ops::backup::{self, BackupStatus};
8use crate::ops::catalog;
9use crate::ops::doctor::{self, DoctorReport};
10use crate::ops::leases::{self, LeaseEntry};
11use crate::pipeline::{self, SetupKind};
12use crate::resume::{self, StepStatus};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// One service link derived from `service_links.env` (or embedded defaults).
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct UrlInfo {
19    /// Display name (e.g. `Homepage`).
20    pub name: String,
21    /// Absolute URL including scheme, host, and port.
22    pub url: String,
23    /// TCP probe on the box loopback for this link's port (`true` = accepting).
24    #[serde(default)]
25    pub up: bool,
26    /// Short blurb for known Horto services when catalog matches.
27    #[serde(default)]
28    pub description: Option<String>,
29}
30
31/// One pipeline step row in a setup status report.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct StepStatusRow {
34    /// Step id (`s1`, …).
35    pub id: String,
36    /// Human title from [`Step::title`](crate::step::Step::title).
37    pub title: String,
38    /// `pending` / `done` / `stale` / `failed`.
39    pub status: String,
40    /// Binary [`Step::step_version`](crate::step::Step::step_version).
41    pub step_version: u32,
42    /// Whether apply is destructive.
43    pub destructive: bool,
44    /// Whether a reboot is advised after this step.
45    pub needs_reboot_after: bool,
46}
47
48/// Setup pipeline kind plus per-step status rows.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SetupStatusReport {
51    /// [`SetupKind::as_str`] value (`full` / `minimal`).
52    pub kind: String,
53    /// Ordered rows matching [`pipeline`](crate::pipeline::pipeline).
54    pub steps: Vec<StepStatusRow>,
55}
56
57/// Full box snapshot: hostname, setup, doctor, backup, docker, leases, service URLs.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct BoxStatus {
60    /// Box hostname from `/etc/hostname`, `hostname`, or `HOSTNAME`.
61    pub hostname: String,
62    /// Resume-aware setup status for the requested kind.
63    pub setup: SetupStatusReport,
64    /// Local doctor checks.
65    pub doctor: DoctorReport,
66    /// Backup path presence / disk probe summary.
67    pub backup: BackupStatus,
68    /// Running docker containers (empty when docker is unavailable).
69    pub containers: Vec<ContainerInfo>,
70    /// DHCP lease entries when lease files exist.
71    pub leases: Vec<LeaseEntry>,
72    /// Service links with loopback TCP `up` probes.
73    pub urls: Vec<UrlInfo>,
74}
75
76/// Build service links from `active_setup/service_links.env`, else embedded defaults.
77///
78/// `link_host` is normally the box hostname. Explicit `HOST=` in the env file wins.
79/// Each link's [`UrlInfo::up`] is set by a short TCP probe to `127.0.0.1:<port>`.
80#[must_use]
81pub fn service_urls(ctx: &HostContext, link_host: &str) -> Vec<UrlInfo> {
82    let mut urls = urls_from_map(&load_service_links(ctx), link_host);
83    for url in &mut urls {
84        url.up = port_from_url(&url.url).is_some_and(tcp_port_open);
85        url.description = catalog::describe_service(&url.name).map(str::to_owned);
86    }
87    urls
88}
89
90fn load_service_links(ctx: &HostContext) -> BTreeMap<String, String> {
91    let path = ctx.paths.service_links_file();
92    if path.is_file() {
93        if let Ok(map) = envfile::load(&path) {
94            return map;
95        }
96    }
97    embed::get_str("config/service_links.env")
98        .map(|raw| envfile::parse(&raw))
99        .unwrap_or_default()
100}
101
102fn urls_from_map(map: &BTreeMap<String, String>, link_host: &str) -> Vec<UrlInfo> {
103    let scheme = map
104        .get("SCHEME")
105        .map(String::as_str)
106        .map(str::trim)
107        .filter(|s| !s.is_empty())
108        .unwrap_or("http");
109    let configured_host = map
110        .get("HOST")
111        .map(String::as_str)
112        .map(str::trim)
113        .filter(|s| !s.is_empty());
114    let host = configured_host.unwrap_or_else(|| {
115        let h = link_host.trim();
116        if h.is_empty() || h.eq_ignore_ascii_case("unknown") {
117            "localhost"
118        } else {
119            h
120        }
121    });
122    let links = map.get("LINKS").map_or("", String::as_str);
123    parse_links(scheme, host, links)
124}
125
126fn parse_links(scheme: &str, host: &str, links: &str) -> Vec<UrlInfo> {
127    links
128        .split(',')
129        .filter_map(|entry| {
130            let entry = entry.trim();
131            if entry.is_empty() {
132                return None;
133            }
134            let (name, port) = entry.split_once(':')?;
135            let name = name.trim();
136            let port = port.trim();
137            if name.is_empty() || port.is_empty() {
138                return None;
139            }
140            Some(UrlInfo {
141                name: name.to_owned(),
142                url: format!("{scheme}://{host}:{port}"),
143                up: false,
144                description: None,
145            })
146        })
147        .collect()
148}
149
150fn port_from_url(url: &str) -> Option<u16> {
151    let after_scheme = url.split("://").nth(1)?;
152    let host_port = after_scheme.split('/').next()?;
153    let (_, port) = host_port.rsplit_once(':')?;
154    port.parse().ok()
155}
156
157fn tcp_port_open(port: u16) -> bool {
158    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
159    use std::time::Duration;
160
161    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
162    TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok()
163}
164
165/// Build resume-aware step status rows for `kind`.
166///
167/// Pending resume records fall back to [`Step::is_done`](crate::step::Step::is_done)
168/// when the host already looks complete.
169#[must_use]
170pub fn setup_status(ctx: &HostContext, kind: SetupKind) -> SetupStatusReport {
171    let state = resume::load(&ctx.paths.resume_file()).unwrap_or_default();
172    let steps = pipeline::pipeline(kind)
173        .iter()
174        .map(|s| {
175            let st = resume::status_for(&state, *s);
176            let status = match st {
177                StepStatus::Pending => {
178                    if s.is_done(ctx) {
179                        "done".to_string()
180                    } else {
181                        "pending".to_string()
182                    }
183                }
184                StepStatus::Done => "done".into(),
185                StepStatus::Stale => "stale".into(),
186                StepStatus::Failed => "failed".into(),
187            };
188            StepStatusRow {
189                id: s.id().into(),
190                title: s.title().into(),
191                status,
192                step_version: s.step_version(),
193                destructive: s.destructive(),
194                needs_reboot_after: s.needs_reboot_after(),
195            }
196        })
197        .collect();
198    SetupStatusReport {
199        kind: kind.as_str().into(),
200        steps,
201    }
202}
203
204/// Collect box status. Service links use the box hostname unless `HOST=` is set
205/// in `service_links.env`.
206#[must_use]
207pub fn box_status(ctx: &HostContext, kind: SetupKind) -> BoxStatus {
208    let hostname = fs_hostname().unwrap_or_else(|| "unknown".into());
209    let urls = service_urls(ctx, &hostname);
210    let mut containers = docker::list_containers().unwrap_or_default();
211    for container in &mut containers {
212        container.description =
213            catalog::describe_container(&container.names, &container.image).map(str::to_owned);
214    }
215    BoxStatus {
216        hostname,
217        setup: setup_status(ctx, kind),
218        doctor: doctor::doctor(ctx),
219        backup: backup::backup_status(ctx),
220        containers,
221        leases: leases::read_leases(&ctx.paths.lease_file, &ctx.paths.leases_json()),
222        urls,
223    }
224}
225
226fn fs_hostname() -> Option<String> {
227    std::fs::read_to_string("/etc/hostname")
228        .ok()
229        .map(|s| s.trim().to_string())
230        .filter(|s| !s.is_empty())
231        .or_else(|| hostname_cmd().or_else(|| std::env::var("HOSTNAME").ok()))
232}
233
234fn hostname_cmd() -> Option<String> {
235    let o = std::process::Command::new("hostname").output().ok()?;
236    if o.status.success() {
237        let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
238        if s.is_empty() {
239            None
240        } else {
241            Some(s)
242        }
243    } else {
244        None
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::{parse_links, urls_from_map};
251    use std::collections::BTreeMap;
252
253    #[test]
254    fn parse_links_builds_urls() {
255        let urls = parse_links("https", "box.local", "Homepage:3021, Dockge:5001");
256        assert_eq!(urls.len(), 2);
257        assert_eq!(urls[0].name, "Homepage");
258        assert_eq!(urls[0].url, "https://box.local:3021");
259        assert_eq!(urls[1].url, "https://box.local:5001");
260    }
261
262    #[test]
263    fn urls_from_map_uses_configured_host_and_ports() {
264        let mut map = BTreeMap::new();
265        map.insert("SCHEME".into(), "http".into());
266        map.insert("HOST".into(), "horto-box".into());
267        map.insert("LINKS".into(), "Homepage:3021,Cockpit:9890".into());
268        let urls = urls_from_map(&map, "ignored");
269        assert_eq!(urls[0].url, "http://horto-box:3021");
270        assert_eq!(urls[1].url, "http://horto-box:9890");
271    }
272
273    #[test]
274    fn urls_from_map_falls_back_to_hostname() {
275        let mut map = BTreeMap::new();
276        map.insert("LINKS".into(), "Dockge:5001".into());
277        let urls = urls_from_map(&map, "my-box");
278        assert_eq!(urls[0].url, "http://my-box:5001");
279    }
280
281    #[test]
282    fn urls_from_map_unknown_hostname_uses_localhost() {
283        let mut map = BTreeMap::new();
284        map.insert("LINKS".into(), "Dockge:5001".into());
285        let urls = urls_from_map(&map, "unknown");
286        assert_eq!(urls[0].url, "http://localhost:5001");
287    }
288
289    #[test]
290    fn port_from_url_parses_http() {
291        assert_eq!(super::port_from_url("http://deb:3021/"), Some(3021));
292        assert_eq!(super::port_from_url("https://box.local:5001"), Some(5001));
293        assert_eq!(super::port_from_url("http://box/"), None);
294    }
295}