1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{
6 CommandExecutor, CommandOutput, CommandRequest, HostId, HostRecord, TopologyRevision,
7};
8use soma_ops::Timestamp;
9use tokio_util::sync::CancellationToken;
10
11use crate::{InfraError, InfraResult};
12
13const HOST_OUTPUT_LIMIT: usize = 64 * 1024;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct HostInspectRequest {
18 deadline: Timestamp,
19}
20
21impl HostInspectRequest {
22 #[must_use]
24 pub const fn new(deadline: Timestamp) -> Self {
25 Self { deadline }
26 }
27
28 #[must_use]
30 pub const fn deadline(self) -> Timestamp {
31 self.deadline
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct HostIdentity {
38 pub hostname: String,
40 pub operating_system: String,
42 pub kernel_release: String,
44 pub architecture: String,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub struct HostMemory {
51 pub total_bytes: u64,
53 pub available_bytes: u64,
55 pub used_bytes: u64,
57 pub usage_percent: u8,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63pub struct HostLoadAverage {
64 pub one: f64,
66 pub five: f64,
68 pub fifteen: f64,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct HostInspection {
75 pub host: HostId,
77 pub topology_revision: TopologyRevision,
79 pub identity: HostIdentity,
81 pub uptime_seconds: f64,
83 pub memory: HostMemory,
85 pub load: HostLoadAverage,
87}
88
89#[async_trait]
91pub trait HostInspector: Send + Sync {
92 async fn inspect(
94 &self,
95 host: &HostRecord,
96 request: HostInspectRequest,
97 cancellation: &CancellationToken,
98 ) -> InfraResult<HostInspection>;
99}
100
101pub struct LinuxCommandHostInspector<E> {
103 executor: Arc<E>,
104}
105
106impl<E> LinuxCommandHostInspector<E> {
107 #[must_use]
109 pub fn new(executor: Arc<E>) -> Self {
110 Self { executor }
111 }
112}
113
114#[async_trait]
115impl<E> HostInspector for LinuxCommandHostInspector<E>
116where
117 E: CommandExecutor,
118{
119 async fn inspect(
120 &self,
121 host: &HostRecord,
122 request: HostInspectRequest,
123 cancellation: &CancellationToken,
124 ) -> InfraResult<HostInspection> {
125 if cancellation.is_cancelled() {
126 return Err(soma_fleet::FleetError::Cancelled.into());
127 }
128 let hostname = self
129 .run_text(host, "hostname", &[], request.deadline, cancellation)
130 .await?;
131 let operating_system = self
132 .run_text(host, "uname", &["-s"], request.deadline, cancellation)
133 .await?;
134 let kernel_release = self
135 .run_text(host, "uname", &["-r"], request.deadline, cancellation)
136 .await?;
137 let architecture = self
138 .run_text(host, "uname", &["-m"], request.deadline, cancellation)
139 .await?;
140 let uptime = self
141 .run_text(
142 host,
143 "cat",
144 &["/proc/uptime"],
145 request.deadline,
146 cancellation,
147 )
148 .await?;
149 let meminfo = self
150 .run_text(
151 host,
152 "cat",
153 &["/proc/meminfo"],
154 request.deadline,
155 cancellation,
156 )
157 .await?;
158 let loadavg = self
159 .run_text(
160 host,
161 "cat",
162 &["/proc/loadavg"],
163 request.deadline,
164 cancellation,
165 )
166 .await?;
167
168 Ok(HostInspection {
169 host: host.id().clone(),
170 topology_revision: host.revision().clone(),
171 identity: HostIdentity {
172 hostname,
173 operating_system,
174 kernel_release,
175 architecture,
176 },
177 uptime_seconds: parse_uptime(&uptime)?,
178 memory: parse_meminfo(&meminfo)?,
179 load: parse_loadavg(&loadavg)?,
180 })
181 }
182}
183
184impl<E> LinuxCommandHostInspector<E>
185where
186 E: CommandExecutor,
187{
188 async fn run_text(
189 &self,
190 host: &HostRecord,
191 program: &str,
192 args: &[&str],
193 deadline: Timestamp,
194 cancellation: &CancellationToken,
195 ) -> InfraResult<String> {
196 let request = CommandRequest::new(program, args.iter().copied(), deadline)
197 .map_err(soma_fleet::FleetError::from)?
198 .with_output_limits(HOST_OUTPUT_LIMIT, HOST_OUTPUT_LIMIT)
199 .map_err(soma_fleet::FleetError::from)?;
200 let output = self.executor.execute(host, &request, cancellation).await?;
201 checked_text(host, output)
202 }
203}
204
205fn checked_text(host: &HostRecord, output: CommandOutput) -> InfraResult<String> {
206 if output.exit_code() != Some(0) {
207 return Err(InfraError::CommandFailed {
208 domain: "host",
209 host: host.id().clone(),
210 exit_code: output.exit_code(),
211 stderr: crate::error::public_diagnostic(output.stderr()),
212 });
213 }
214 if output.truncated() {
215 return Err(InfraError::Parse {
216 domain: "host",
217 message: "bounded command output was truncated".into(),
218 });
219 }
220 String::from_utf8(output.stdout().to_vec())
221 .map(|value| value.trim().to_owned())
222 .map_err(|error| InfraError::Parse {
223 domain: "host",
224 message: format!("output was not UTF-8: {error}"),
225 })
226}
227
228fn parse_uptime(raw: &str) -> InfraResult<f64> {
229 raw.split_whitespace()
230 .next()
231 .and_then(|value| value.parse::<f64>().ok())
232 .filter(|value| value.is_finite() && *value >= 0.0)
233 .ok_or_else(|| InfraError::Parse {
234 domain: "host",
235 message: "invalid /proc/uptime value".into(),
236 })
237}
238
239fn parse_meminfo(raw: &str) -> InfraResult<HostMemory> {
240 let mut total_kib = None;
241 let mut available_kib = None;
242 for line in raw.lines() {
243 let mut fields = line.split_whitespace();
244 let key = fields.next().unwrap_or_default().trim_end_matches(':');
245 let value = fields.next().and_then(|value| value.parse::<u64>().ok());
246 let unit = fields.next();
247 match key {
248 "MemTotal" if unit == Some("kB") => total_kib = value,
249 "MemAvailable" if unit == Some("kB") => available_kib = value,
250 _ => {}
251 }
252 }
253 let total_bytes = total_kib
254 .and_then(|value| value.checked_mul(1024))
255 .ok_or_else(|| InfraError::Parse {
256 domain: "host",
257 message: "missing or overflowing MemTotal".into(),
258 })?;
259 let available_bytes = available_kib
260 .and_then(|value| value.checked_mul(1024))
261 .ok_or_else(|| InfraError::Parse {
262 domain: "host",
263 message: "missing or overflowing MemAvailable".into(),
264 })?;
265 if total_bytes == 0 || available_bytes > total_bytes {
266 return Err(InfraError::Parse {
267 domain: "host",
268 message: "inconsistent MemTotal and MemAvailable".into(),
269 });
270 }
271 let used_bytes = total_bytes - available_bytes;
272 let usage_percent = ((used_bytes as f64 / total_bytes as f64) * 100.0)
273 .round()
274 .clamp(0.0, 100.0) as u8;
275 Ok(HostMemory {
276 total_bytes,
277 available_bytes,
278 used_bytes,
279 usage_percent,
280 })
281}
282
283fn parse_loadavg(raw: &str) -> InfraResult<HostLoadAverage> {
284 let values = raw
285 .split_whitespace()
286 .take(3)
287 .map(str::parse::<f64>)
288 .collect::<Result<Vec<_>, _>>()
289 .map_err(|error| InfraError::Parse {
290 domain: "host",
291 message: format!("invalid /proc/loadavg value: {error}"),
292 })?;
293 if values.len() != 3
294 || values
295 .iter()
296 .any(|value| !value.is_finite() || *value < 0.0)
297 {
298 return Err(InfraError::Parse {
299 domain: "host",
300 message: "expected three non-negative load averages".into(),
301 });
302 }
303 Ok(HostLoadAverage {
304 one: values[0],
305 five: values[1],
306 fifteen: values[2],
307 })
308}
309
310#[cfg(test)]
311#[path = "host_tests.rs"]
312mod tests;