1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct UrlInfo {
19 pub name: String,
21 pub url: String,
23 #[serde(default)]
25 pub up: bool,
26 #[serde(default)]
28 pub description: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct StepStatusRow {
34 pub id: String,
36 pub title: String,
38 pub status: String,
40 pub step_version: u32,
42 pub destructive: bool,
44 pub needs_reboot_after: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SetupStatusReport {
51 pub kind: String,
53 pub steps: Vec<StepStatusRow>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct BoxStatus {
60 pub hostname: String,
62 pub setup: SetupStatusReport,
64 pub doctor: DoctorReport,
66 pub backup: BackupStatus,
68 pub containers: Vec<ContainerInfo>,
70 pub leases: Vec<LeaseEntry>,
72 pub urls: Vec<UrlInfo>,
74}
75
76#[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#[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#[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}