1use async_trait::async_trait;
32use std::io;
33use std::path::Path;
34use std::sync::Arc;
35
36use super::{DirEntry, DirEntryKind, Filesystem};
37use crate::scheduler::{JobId, JobManager};
38
39pub struct JobFs {
47 jobs: Arc<JobManager>,
48}
49
50impl JobFs {
51 pub fn new(jobs: Arc<JobManager>) -> Self {
53 Self { jobs }
54 }
55
56 fn parse_path(path: &Path) -> Option<(Option<JobId>, Option<&str>)> {
63 let path_str = path.to_str()?;
64 let path_str = path_str.trim_start_matches('/');
65
66 if path_str.is_empty() {
67 return Some((None, None)); }
69
70 let parts: Vec<&str> = path_str.split('/').collect();
71
72 match parts.as_slice() {
73 [id_str] => {
74 let id: u64 = id_str.parse().ok()?;
76 Some((Some(JobId(id)), None))
77 }
78 [id_str, file] => {
79 let id: u64 = id_str.parse().ok()?;
81 Some((Some(JobId(id)), Some(*file)))
82 }
83 _ => None,
84 }
85 }
86}
87
88impl std::fmt::Debug for JobFs {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("JobFs").finish()
91 }
92}
93
94#[async_trait]
95impl Filesystem for JobFs {
96 async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
97 let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
98 io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
99 })?;
100
101 let job_id = job_id.ok_or_else(|| {
102 io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
103 })?;
104
105 let file = file.ok_or_else(|| {
106 io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
107 })?;
108
109 if !self.jobs.exists(job_id).await {
111 return Err(io::Error::new(
112 io::ErrorKind::NotFound,
113 format!("job {} not found", job_id),
114 ));
115 }
116
117 match file {
118 "status" => {
119 let status = self
120 .jobs
121 .get_status_string(job_id)
122 .await
123 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
124 Ok(format!("{}\n", status).into_bytes())
125 }
126 "command" => {
127 let command = self
128 .jobs
129 .get_command(job_id)
130 .await
131 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
132 Ok(format!("{}\n", command).into_bytes())
133 }
134 "stdout" => {
135 self.jobs
139 .read_stdout(job_id)
140 .await
141 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))
142 }
143 "stderr" => {
144 self.jobs
145 .read_stderr(job_id)
146 .await
147 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))
148 }
149 _ => Err(io::Error::new(
150 io::ErrorKind::NotFound,
151 format!("unknown file: {}", file),
152 )),
153 }
154 }
155
156 async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
157 Err(io::Error::new(
158 io::ErrorKind::PermissionDenied,
159 "jobfs is read-only",
160 ))
161 }
162
163 async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
164 let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
165 io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
166 })?;
167
168 if file.is_some() {
170 return Err(io::Error::new(
171 io::ErrorKind::NotADirectory,
172 "not a directory",
173 ));
174 }
175
176 match job_id {
177 None => {
178 let job_ids = self.jobs.list_ids().await;
180 let entries = job_ids
181 .into_iter()
182 .map(|id| DirEntry {
183 name: id.0.to_string(),
184 kind: DirEntryKind::Directory,
185 modified: None,
186 permissions: None,
187 size: 0,
188 symlink_target: None,
189 })
190 .collect();
191 Ok(entries)
192 }
193 Some(id) => {
194 if !self.jobs.exists(id).await {
196 return Err(io::Error::new(
197 io::ErrorKind::NotFound,
198 format!("job {} not found", id),
199 ));
200 }
201
202 Ok(vec![
203 DirEntry {
204 name: "status".to_string(),
205 kind: DirEntryKind::File,
206 modified: None,
207 permissions: None,
208 size: 0,
209 symlink_target: None,
210 },
211 DirEntry {
212 name: "command".to_string(),
213 kind: DirEntryKind::File,
214 modified: None,
215 permissions: None,
216 size: 0,
217 symlink_target: None,
218 },
219 DirEntry {
220 name: "stdout".to_string(),
221 kind: DirEntryKind::File,
222 modified: None,
223 permissions: None,
224 size: 0,
228 symlink_target: None,
229 },
230 DirEntry {
231 name: "stderr".to_string(),
232 kind: DirEntryKind::File,
233 modified: None,
234 permissions: None,
235 size: 0,
236 symlink_target: None,
237 },
238 ])
239 }
240 }
241 }
242
243 async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
244 let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
245 io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
246 })?;
247
248 let name = path
249 .file_name()
250 .map(|n| n.to_string_lossy().into_owned())
251 .unwrap_or_else(|| "/".to_string());
252
253 match (job_id, file) {
254 (None, None) => {
255 Ok(DirEntry::directory(name))
257 }
258 (Some(id), None) => {
259 if !self.jobs.exists(id).await {
261 return Err(io::Error::new(
262 io::ErrorKind::NotFound,
263 format!("job {} not found", id),
264 ));
265 }
266 Ok(DirEntry::directory(name))
267 }
268 (Some(id), Some(file)) => {
269 if !self.jobs.exists(id).await {
271 return Err(io::Error::new(
272 io::ErrorKind::NotFound,
273 format!("job {} not found", id),
274 ));
275 }
276
277 if !["status", "command", "stdout", "stderr"].contains(&file) {
279 return Err(io::Error::new(
280 io::ErrorKind::NotFound,
281 format!("unknown file: {}", file),
282 ));
283 }
284
285 Ok(DirEntry::file(name, 0))
286 }
287 (None, Some(_)) => {
288 Err(io::Error::new(
290 io::ErrorKind::NotFound,
291 "invalid path",
292 ))
293 }
294 }
295 }
296
297 async fn mkdir(&self, _path: &Path) -> io::Result<()> {
298 Err(io::Error::new(
299 io::ErrorKind::PermissionDenied,
300 "jobfs is read-only",
301 ))
302 }
303
304 async fn remove(&self, _path: &Path) -> io::Result<()> {
305 Err(io::Error::new(
306 io::ErrorKind::PermissionDenied,
307 "jobfs is read-only",
308 ))
309 }
310
311 fn read_only(&self) -> bool {
312 true
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::interpreter::ExecResult;
320 use tokio::sync::oneshot;
321
322 async fn make_job_manager_with_job() -> (Arc<JobManager>, JobId) {
323 let manager = Arc::new(JobManager::new());
324
325 let (tx, rx) = oneshot::channel();
327
328 let id = manager.register("echo test".to_string(), rx).await;
330
331 let _ = tx.send(ExecResult::success("done"));
333
334 (manager, id)
335 }
336
337 #[tokio::test]
338 async fn test_list_root_empty() {
339 let manager = Arc::new(JobManager::new());
340 let fs = JobFs::new(manager);
341
342 let entries = fs.list(Path::new("")).await.unwrap();
343 assert!(entries.is_empty());
344 }
345
346 #[tokio::test]
347 async fn test_list_root_with_jobs() {
348 let (manager, id) = make_job_manager_with_job().await;
349 let fs = JobFs::new(manager);
350
351 let entries = fs.list(Path::new("")).await.unwrap();
352 assert_eq!(entries.len(), 1);
353 assert_eq!(entries[0].name, id.0.to_string());
354 assert_eq!(entries[0].kind, DirEntryKind::Directory);
355 }
356
357 #[tokio::test]
358 async fn test_list_job_directory() {
359 let (manager, id) = make_job_manager_with_job().await;
360 let fs = JobFs::new(manager);
361
362 let path = format!("{}", id);
363 let entries = fs.list(Path::new(&path)).await.unwrap();
364
365 let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
366 assert!(names.contains(&&"stdout".to_string()), "stdout node is back, and live");
367 assert!(names.contains(&&"stderr".to_string()), "stderr node is back, and live");
368 assert!(names.contains(&&"status".to_string()));
369 assert!(names.contains(&&"command".to_string()));
370 assert!(!names.contains(&&"approval".to_string()), "the approval node went with the ledger");
371 }
372
373 #[tokio::test]
374 async fn test_read_status_running() {
375 let manager = Arc::new(JobManager::new());
376
377 let (_tx, rx) = oneshot::channel();
379 let id = manager.register("sleep 100".to_string(), rx).await;
380
381 let fs = JobFs::new(manager);
382
383 let path = format!("{}/status", id);
384 let data = fs.read(Path::new(&path)).await.unwrap();
385 assert_eq!(String::from_utf8_lossy(&data), "running\n");
386 }
387
388 #[tokio::test]
389 async fn test_read_status_done() {
390 let (manager, id) = make_job_manager_with_job().await;
391
392 manager.wait(id).await;
394
395 let fs = JobFs::new(manager);
396
397 let path = format!("{}/status", id);
398 let data = fs.read(Path::new(&path)).await.unwrap();
399 assert_eq!(String::from_utf8_lossy(&data), "done:0\n");
400 }
401
402 #[tokio::test]
403 async fn test_read_command() {
404 let (manager, id) = make_job_manager_with_job().await;
405 let fs = JobFs::new(manager);
406
407 let path = format!("{}/command", id);
408 let data = fs.read(Path::new(&path)).await.unwrap();
409 assert_eq!(String::from_utf8_lossy(&data), "echo test\n");
410 }
411
412 #[tokio::test]
413 async fn test_stat_root() {
414 let manager = Arc::new(JobManager::new());
415 let fs = JobFs::new(manager);
416
417 let entry = fs.stat(Path::new("")).await.unwrap();
418 assert_eq!(entry.kind, DirEntryKind::Directory);
419 }
420
421 #[tokio::test]
422 async fn test_stat_job_dir() {
423 let (manager, id) = make_job_manager_with_job().await;
424 let fs = JobFs::new(manager);
425
426 let path = format!("{}", id);
427 let entry = fs.stat(Path::new(&path)).await.unwrap();
428 assert_eq!(entry.kind, DirEntryKind::Directory);
429 }
430
431 #[tokio::test]
432 async fn test_stat_file() {
433 let (manager, id) = make_job_manager_with_job().await;
434 let fs = JobFs::new(manager);
435
436 let path = format!("{}/status", id);
437 let entry = fs.stat(Path::new(&path)).await.unwrap();
438 assert_eq!(entry.kind, DirEntryKind::File);
439 }
440
441 #[tokio::test]
442 async fn test_stat_stdout() {
443 let (manager, id) = make_job_manager_with_job().await;
444 let fs = JobFs::new(manager);
445
446 let path = format!("{}/stdout", id);
447 let entry = fs.stat(Path::new(&path)).await.unwrap();
448 assert_eq!(entry.kind, DirEntryKind::File);
449 }
450
451 #[tokio::test]
454 async fn test_read_stdout_before_anything_is_written() {
455 let (manager, id) = make_job_manager_with_job().await;
456 let fs = JobFs::new(manager);
457
458 let path = format!("{}/stdout", id);
459 assert_eq!(fs.read(Path::new(&path)).await.unwrap(), Vec::<u8>::new());
460 }
461
462 #[tokio::test]
465 async fn test_read_stdout_reflects_the_live_stream() {
466 let (manager, id) = make_job_manager_with_job().await;
467 let streams = manager.streams(id).await.unwrap();
468 streams.stdout.write(b"partial").await;
469
470 let fs = JobFs::new(manager);
471 let path = format!("{}/stdout", id);
472 assert_eq!(fs.read(Path::new(&path)).await.unwrap(), b"partial".to_vec());
473 }
474
475 #[tokio::test]
476 async fn test_stat_unknown_file_is_still_not_found() {
477 let (manager, id) = make_job_manager_with_job().await;
478 let fs = JobFs::new(manager);
479
480 let path = format!("{}/nope", id);
481 let result = fs.stat(Path::new(&path)).await;
482 assert!(result.is_err());
483 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
484 }
485
486 #[tokio::test]
487 async fn test_read_only() {
488 let manager = Arc::new(JobManager::new());
489 let fs = JobFs::new(manager);
490
491 assert!(fs.read_only());
492
493 let write_result = fs.write(Path::new("1/status"), b"data").await;
494 assert!(write_result.is_err());
495
496 let mkdir_result = fs.mkdir(Path::new("1")).await;
497 assert!(mkdir_result.is_err());
498
499 let remove_result = fs.remove(Path::new("1")).await;
500 assert!(remove_result.is_err());
501 }
502
503 #[tokio::test]
504 async fn test_nonexistent_job() {
505 let manager = Arc::new(JobManager::new());
506 let fs = JobFs::new(manager);
507
508 let result = fs.read(Path::new("999/status")).await;
509 assert!(result.is_err());
510 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
511 }
512
513 #[tokio::test]
514 async fn test_unknown_file() {
515 let (manager, id) = make_job_manager_with_job().await;
516 let fs = JobFs::new(manager);
517
518 let path = format!("{}/unknown", id);
519 let result = fs.read(Path::new(&path)).await;
520 assert!(result.is_err());
521 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
522 }
523
524 #[tokio::test]
525 async fn test_read_directory_error() {
526 let (manager, id) = make_job_manager_with_job().await;
527 let fs = JobFs::new(manager);
528
529 let path = format!("{}", id);
530 let result = fs.read(Path::new(&path)).await;
531 assert!(result.is_err());
532 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::IsADirectory);
533 }
534}