Skip to main content

soma_infra/
host_system.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use soma_fleet::{HostId, HostRecord, TopologyRevision};
4use soma_ops::Timestamp;
5use tokio_util::sync::CancellationToken;
6
7use crate::{InfraError, InfraResult};
8
9const MAX_FILTER_CHARS: usize = 256;
10const MAX_PORT_ROWS: u32 = 5000;
11
12/// Request for a bounded service listing.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ServiceListRequest {
15    service: Option<String>,
16    state: Option<String>,
17    deadline: Timestamp,
18}
19
20impl ServiceListRequest {
21    /// Creates an unfiltered service request.
22    #[must_use]
23    pub const fn new(deadline: Timestamp) -> Self {
24        Self {
25            service: None,
26            state: None,
27            deadline,
28        }
29    }
30    /// Filters by service-name substring.
31    pub fn with_service(mut self, value: impl Into<String>) -> InfraResult<Self> {
32        self.service = Some(validate_filter("service", value.into())?);
33        Ok(self)
34    }
35    /// Filters by active-state equality.
36    pub fn with_state(mut self, value: impl Into<String>) -> InfraResult<Self> {
37        self.state = Some(validate_filter("state", value.into())?);
38        Ok(self)
39    }
40    /// Returns the service filter.
41    #[must_use]
42    pub fn service(&self) -> Option<&str> {
43        self.service.as_deref()
44    }
45    /// Returns the state filter.
46    #[must_use]
47    pub fn state(&self) -> Option<&str> {
48        self.state.as_deref()
49    }
50    /// Returns the absolute deadline.
51    #[must_use]
52    pub const fn deadline(&self) -> Timestamp {
53        self.deadline
54    }
55}
56
57/// One system service row.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ServiceStatus {
60    /// Unit name.
61    pub unit: String,
62    /// Load state.
63    pub load: String,
64    /// Active state.
65    pub active: String,
66    /// Sub-state.
67    pub sub: String,
68    /// Description.
69    pub description: String,
70}
71
72/// One interface address.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct NetworkAddress {
75    /// Address family.
76    pub family: String,
77    /// Address text.
78    pub address: String,
79    /// Prefix length.
80    pub prefix_len: u8,
81}
82
83/// One network interface.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct NetworkInterface {
86    /// Interface index.
87    pub index: u64,
88    /// Interface name.
89    pub name: String,
90    /// Operational state.
91    pub state: Option<String>,
92    /// MTU.
93    pub mtu: Option<u64>,
94    /// Interface addresses.
95    pub addresses: Vec<NetworkAddress>,
96}
97
98/// One mounted filesystem.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct MountInfo {
101    /// Mount target.
102    pub target: String,
103    /// Source device or dataset.
104    pub source: Option<String>,
105    /// Filesystem type.
106    pub filesystem: Option<String>,
107    /// Mount options.
108    pub options: Option<String>,
109    /// Total bytes.
110    pub size_bytes: Option<u64>,
111    /// Used bytes.
112    pub used_bytes: Option<u64>,
113    /// Available bytes.
114    pub available_bytes: Option<u64>,
115}
116
117/// Supported listening-port protocols.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum PortProtocol {
121    /// TCP sockets.
122    Tcp,
123    /// UDP sockets.
124    Udp,
125}
126
127impl PortProtocol {
128    #[cfg(feature = "process-driver")]
129    pub(crate) const fn as_ss_filter(self) -> &'static str {
130        match self {
131            Self::Tcp => "-t",
132            Self::Udp => "-u",
133        }
134    }
135}
136
137/// Request for bounded listening-port inspection.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct PortListRequest {
140    protocol: Option<PortProtocol>,
141    offset: u32,
142    limit: u32,
143    deadline: Timestamp,
144}
145
146impl PortListRequest {
147    /// Creates a request returning up to 500 rows.
148    #[must_use]
149    pub const fn new(deadline: Timestamp) -> Self {
150        Self {
151            protocol: None,
152            offset: 0,
153            limit: 500,
154            deadline,
155        }
156    }
157    /// Restricts the protocol.
158    #[must_use]
159    pub const fn with_protocol(mut self, protocol: PortProtocol) -> Self {
160        self.protocol = Some(protocol);
161        self
162    }
163    /// Sets pagination bounds.
164    pub fn with_page(mut self, offset: u32, limit: u32) -> InfraResult<Self> {
165        if limit == 0 || limit > MAX_PORT_ROWS {
166            return Err(InfraError::InvalidRequest {
167                domain: "host",
168                message: format!("port limit must be 1-{MAX_PORT_ROWS}"),
169            });
170        }
171        self.offset = offset;
172        self.limit = limit;
173        Ok(self)
174    }
175    /// Returns the protocol filter.
176    #[must_use]
177    pub const fn protocol(&self) -> Option<PortProtocol> {
178        self.protocol
179    }
180    /// Returns the row offset.
181    #[must_use]
182    pub const fn offset(&self) -> u32 {
183        self.offset
184    }
185    /// Returns the row limit.
186    #[must_use]
187    pub const fn limit(&self) -> u32 {
188        self.limit
189    }
190    /// Returns the deadline.
191    #[must_use]
192    pub const fn deadline(&self) -> Timestamp {
193        self.deadline
194    }
195}
196
197/// One listening socket.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct PortInfo {
200    /// Protocol name.
201    pub protocol: String,
202    /// Socket state.
203    pub state: String,
204    /// Local address.
205    pub local_address: String,
206    /// Peer address.
207    pub peer_address: String,
208    /// Process annotation.
209    pub process: Option<String>,
210}
211
212/// Byte-precise filesystem usage.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct FilesystemUsage {
215    /// Source device or dataset.
216    pub source: String,
217    /// Filesystem type.
218    pub filesystem: String,
219    /// Total bytes.
220    pub size_bytes: u64,
221    /// Used bytes.
222    pub used_bytes: u64,
223    /// Available bytes.
224    pub available_bytes: u64,
225    /// Integer utilization percentage.
226    pub usage_percent: u8,
227    /// Mount target.
228    pub target: String,
229}
230
231/// One doctor check.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct DoctorCheck {
234    /// Stable check name.
235    pub name: String,
236    /// Whether the check passed.
237    pub ok: bool,
238    /// Human-readable summary.
239    pub summary: String,
240}
241
242/// Typed doctor report.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct DoctorReport {
245    /// Target host.
246    pub host: HostId,
247    /// Exact topology revision.
248    pub topology_revision: TopologyRevision,
249    /// Overall status.
250    pub overall: String,
251    /// Individual checks.
252    pub checks: Vec<DoctorCheck>,
253}
254
255/// Remaining product-neutral host-system reads.
256#[async_trait]
257pub trait HostSystemInspector: Send + Sync {
258    /// Lists services.
259    async fn services(
260        &self,
261        host: &HostRecord,
262        request: &ServiceListRequest,
263        cancellation: &CancellationToken,
264    ) -> InfraResult<Vec<ServiceStatus>>;
265    /// Reads network interfaces.
266    async fn network(
267        &self,
268        host: &HostRecord,
269        deadline: Timestamp,
270        cancellation: &CancellationToken,
271    ) -> InfraResult<Vec<NetworkInterface>>;
272    /// Lists mounted filesystems.
273    async fn mounts(
274        &self,
275        host: &HostRecord,
276        deadline: Timestamp,
277        cancellation: &CancellationToken,
278    ) -> InfraResult<Vec<MountInfo>>;
279    /// Lists listening ports.
280    async fn ports(
281        &self,
282        host: &HostRecord,
283        request: &PortListRequest,
284        cancellation: &CancellationToken,
285    ) -> InfraResult<Vec<PortInfo>>;
286    /// Reads byte-precise filesystem usage.
287    async fn filesystem_usage(
288        &self,
289        host: &HostRecord,
290        path: Option<&str>,
291        deadline: Timestamp,
292        cancellation: &CancellationToken,
293    ) -> InfraResult<FilesystemUsage>;
294    /// Runs deterministic read-only health checks.
295    async fn doctor(
296        &self,
297        host: &HostRecord,
298        deadline: Timestamp,
299        cancellation: &CancellationToken,
300    ) -> InfraResult<DoctorReport>;
301}
302
303fn validate_filter(field: &'static str, value: String) -> InfraResult<String> {
304    if value.is_empty()
305        || value.chars().count() > MAX_FILTER_CHARS
306        || value.chars().any(char::is_control)
307    {
308        Err(InfraError::InvalidRequest {
309            domain: "host",
310            message: format!("invalid {field} filter"),
311        })
312    } else {
313        Ok(value)
314    }
315}
316
317#[cfg(test)]
318#[path = "host_system_tests.rs"]
319mod tests;