1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{HostId, HostRecord, TopologyRevision};
6use soma_ops::Timestamp;
7use tokio_util::sync::CancellationToken;
8
9use crate::{InfraError, InfraResult};
10
11const MAX_TARGET_CHARS: usize = 256;
12const MAX_ROWS: u32 = 5000;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum ZfsDatasetType {
18 Filesystem,
20 Volume,
22 Snapshot,
24 Bookmark,
26 All,
28}
29
30impl ZfsDatasetType {
31 #[cfg(any(feature = "process-driver", test))]
32 pub(crate) const fn as_arg(self) -> &'static str {
33 match self {
34 Self::Filesystem => "filesystem",
35 Self::Volume => "volume",
36 Self::Snapshot => "snapshot",
37 Self::Bookmark => "bookmark",
38 Self::All => "all",
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ZfsPoolRequest {
46 pool: Option<String>,
47 deadline: Timestamp,
48}
49
50impl ZfsPoolRequest {
51 #[must_use]
53 pub const fn new(deadline: Timestamp) -> Self {
54 Self {
55 pool: None,
56 deadline,
57 }
58 }
59
60 pub fn with_pool(mut self, pool: impl Into<String>) -> InfraResult<Self> {
62 self.pool = Some(validate_target("pool", pool.into())?);
63 Ok(self)
64 }
65
66 #[must_use]
68 pub fn pool(&self) -> Option<&str> {
69 self.pool.as_deref()
70 }
71
72 #[must_use]
74 pub const fn deadline(&self) -> Timestamp {
75 self.deadline
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ZfsDatasetRequest {
82 pool: Option<String>,
83 dataset_type: Option<ZfsDatasetType>,
84 recursive: bool,
85 deadline: Timestamp,
86}
87
88impl ZfsDatasetRequest {
89 #[must_use]
91 pub const fn new(deadline: Timestamp) -> Self {
92 Self {
93 pool: None,
94 dataset_type: None,
95 recursive: false,
96 deadline,
97 }
98 }
99
100 pub fn with_pool(mut self, pool: impl Into<String>) -> InfraResult<Self> {
102 self.pool = Some(validate_target("pool", pool.into())?);
103 Ok(self)
104 }
105
106 #[must_use]
108 pub const fn with_type(mut self, dataset_type: ZfsDatasetType) -> Self {
109 self.dataset_type = Some(dataset_type);
110 self
111 }
112
113 #[must_use]
115 pub const fn recursive(mut self, recursive: bool) -> Self {
116 self.recursive = recursive;
117 self
118 }
119
120 #[must_use]
122 pub fn pool(&self) -> Option<&str> {
123 self.pool.as_deref()
124 }
125
126 #[must_use]
128 pub const fn dataset_type(&self) -> Option<ZfsDatasetType> {
129 self.dataset_type
130 }
131
132 #[must_use]
134 pub const fn is_recursive(&self) -> bool {
135 self.recursive
136 }
137
138 #[must_use]
140 pub const fn deadline(&self) -> Timestamp {
141 self.deadline
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct ZfsSnapshotRequest {
148 pool: Option<String>,
149 dataset: Option<String>,
150 limit: u32,
151 deadline: Timestamp,
152}
153
154impl ZfsSnapshotRequest {
155 #[must_use]
157 pub const fn new(deadline: Timestamp) -> Self {
158 Self {
159 pool: None,
160 dataset: None,
161 limit: 500,
162 deadline,
163 }
164 }
165
166 pub fn with_pool(mut self, pool: impl Into<String>) -> InfraResult<Self> {
168 self.pool = Some(validate_target("pool", pool.into())?);
169 Ok(self)
170 }
171
172 pub fn with_dataset(mut self, dataset: impl Into<String>) -> InfraResult<Self> {
174 self.dataset = Some(validate_target("dataset", dataset.into())?);
175 Ok(self)
176 }
177
178 pub fn with_limit(mut self, limit: u32) -> InfraResult<Self> {
180 if limit == 0 || limit > MAX_ROWS {
181 return Err(InfraError::InvalidRequest {
182 domain: "zfs",
183 message: format!("snapshot limit must be 1-{MAX_ROWS}"),
184 });
185 }
186 self.limit = limit;
187 Ok(self)
188 }
189
190 #[must_use]
192 pub fn pool(&self) -> Option<&str> {
193 self.pool.as_deref()
194 }
195
196 #[must_use]
198 pub fn dataset(&self) -> Option<&str> {
199 self.dataset.as_deref()
200 }
201
202 #[must_use]
204 pub const fn limit(&self) -> u32 {
205 self.limit
206 }
207
208 #[must_use]
210 pub const fn deadline(&self) -> Timestamp {
211 self.deadline
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct ZfsTable {
218 pub host: HostId,
220 pub topology_revision: TopologyRevision,
222 pub columns: Vec<String>,
224 pub rows: Vec<BTreeMap<String, String>>,
226 pub truncated: bool,
228}
229
230#[async_trait]
232pub trait ZfsInspector: Send + Sync {
233 async fn pools(
235 &self,
236 host: &HostRecord,
237 request: &ZfsPoolRequest,
238 cancellation: &CancellationToken,
239 ) -> InfraResult<ZfsTable>;
240
241 async fn datasets(
243 &self,
244 host: &HostRecord,
245 request: &ZfsDatasetRequest,
246 cancellation: &CancellationToken,
247 ) -> InfraResult<ZfsTable>;
248
249 async fn snapshots(
251 &self,
252 host: &HostRecord,
253 request: &ZfsSnapshotRequest,
254 cancellation: &CancellationToken,
255 ) -> InfraResult<ZfsTable>;
256}
257
258#[cfg(any(feature = "process-driver", test))]
259pub(crate) fn parse_zfs_table(
260 host: &HostRecord,
261 raw: &str,
262 limit: Option<u32>,
263) -> InfraResult<ZfsTable> {
264 let mut lines = raw.lines().filter(|line| !line.trim().is_empty());
265 let columns = lines
266 .next()
267 .ok_or_else(|| parse_error("ZFS output has no header"))?
268 .split_whitespace()
269 .map(str::to_owned)
270 .collect::<Vec<_>>();
271 if columns.is_empty() {
272 return Err(parse_error("ZFS output has an empty header"));
273 }
274 let mut rows = lines
275 .map(|line| parse_row(&columns, line))
276 .collect::<InfraResult<Vec<_>>>()?;
277 let truncated = limit.is_some_and(|limit| rows.len() > limit as usize);
278 if let Some(limit) = limit {
279 rows.truncate(limit as usize);
280 }
281 Ok(ZfsTable {
282 host: host.id().clone(),
283 topology_revision: host.revision().clone(),
284 columns,
285 rows,
286 truncated,
287 })
288}
289
290#[cfg(any(feature = "process-driver", test))]
291fn parse_row(columns: &[String], line: &str) -> InfraResult<BTreeMap<String, String>> {
292 let values = line.split_whitespace().collect::<Vec<_>>();
293 if values.len() < columns.len() {
294 return Err(parse_error(&format!(
295 "ZFS row has {} values for {} columns",
296 values.len(),
297 columns.len()
298 )));
299 }
300 let mut row = BTreeMap::new();
301 for (index, column) in columns.iter().enumerate() {
302 let value = if index + 1 == columns.len() {
303 values[index..].join(" ")
304 } else {
305 values[index].to_owned()
306 };
307 row.insert(column.clone(), value);
308 }
309 Ok(row)
310}
311
312fn validate_target(kind: &'static str, value: String) -> InfraResult<String> {
313 let count = value.chars().count();
314 if count == 0
315 || count > MAX_TARGET_CHARS
316 || value.starts_with('-')
317 || value.chars().any(|character| {
318 !(character.is_ascii_alphanumeric()
319 || matches!(character, '_' | '-' | '.' | ':' | '/' | '@'))
320 })
321 {
322 Err(InfraError::InvalidRequest {
323 domain: "zfs",
324 message: format!("invalid {kind} target: {value:?}"),
325 })
326 } else {
327 Ok(value)
328 }
329}
330
331#[cfg(any(feature = "process-driver", test))]
332fn parse_error(message: &str) -> InfraError {
333 InfraError::Parse {
334 domain: "zfs",
335 message: message.to_owned(),
336 }
337}
338
339#[cfg(test)]
340#[path = "zfs_tests.rs"]
341mod tests;