Skip to main content

greentic_deployer/environment/
file_lock.rs

1//! Per-env exclusive file lock used by [`LocalFsStore`](super::LocalFsStore).
2//!
3//! Each environment has a sentinel file at `<env_root>/.lock`. The store opens
4//! it and takes a blocking exclusive `flock`/`LockFileEx` via the `fs4` crate.
5//! The lock is bound to the file descriptor — closing the file releases it,
6//! so [`EnvFlock`] is just an owning wrapper over [`std::fs::File`] and Drop
7//! does the right thing without any unsafe self-reference tricks.
8//!
9//! Locks coordinate concurrent writers in the same process and across
10//! processes on the same host. They do NOT replace generations/ETags for
11//! distributed scenarios (that's A8 in `plans/next-gen-deployment.md`).
12
13use std::fs::{File, OpenOptions};
14use std::path::{Path, PathBuf};
15
16use fs4::fs_std::FileExt;
17use thiserror::Error;
18
19#[derive(Debug, Error)]
20pub enum LockError {
21    #[error("could not open or create lock file at {path}: {source}")]
22    Open {
23        path: PathBuf,
24        #[source]
25        source: std::io::Error,
26    },
27    #[error("could not acquire exclusive lock at {path}: {source}")]
28    Acquire {
29        path: PathBuf,
30        #[source]
31        source: std::io::Error,
32    },
33}
34
35/// RAII exclusive lock on a single env's `.lock` file. Dropping the value
36/// closes the file descriptor, which releases the OS-level lock.
37#[derive(Debug)]
38pub struct EnvFlock {
39    _file: File,
40}
41
42impl EnvFlock {
43    /// Blocking exclusive acquire. Creates the lock file if missing.
44    pub fn acquire(lock_path: &Path) -> Result<Self, LockError> {
45        let file = open_lock_file(lock_path)?;
46        file.lock_exclusive().map_err(|source| LockError::Acquire {
47            path: lock_path.to_path_buf(),
48            source,
49        })?;
50        Ok(Self { _file: file })
51    }
52
53    /// Non-blocking exclusive acquire. Returns `Ok(None)` if the lock is held.
54    pub fn try_acquire(lock_path: &Path) -> Result<Option<Self>, LockError> {
55        let file = open_lock_file(lock_path)?;
56        match file.try_lock_exclusive() {
57            Ok(true) => Ok(Some(Self { _file: file })),
58            Ok(false) => Ok(None),
59            Err(source) => Err(LockError::Acquire {
60                path: lock_path.to_path_buf(),
61                source,
62            }),
63        }
64    }
65}
66
67fn open_lock_file(lock_path: &Path) -> Result<File, LockError> {
68    if let Some(parent) = lock_path.parent() {
69        std::fs::create_dir_all(parent).map_err(|source| LockError::Open {
70            path: parent.to_path_buf(),
71            source,
72        })?;
73    }
74    OpenOptions::new()
75        .read(true)
76        .write(true)
77        .create(true)
78        .truncate(false)
79        .open(lock_path)
80        .map_err(|source| LockError::Open {
81            path: lock_path.to_path_buf(),
82            source,
83        })
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use tempfile::TempDir;
90
91    #[test]
92    fn acquire_and_release() {
93        let tmp = TempDir::new().unwrap();
94        let lock_path = tmp.path().join(".lock");
95        {
96            let _guard = EnvFlock::acquire(&lock_path).unwrap();
97            assert!(lock_path.exists());
98        }
99        // After drop, we can re-acquire.
100        let _again = EnvFlock::acquire(&lock_path).unwrap();
101    }
102
103    #[test]
104    fn try_acquire_returns_none_when_held() {
105        let tmp = TempDir::new().unwrap();
106        let lock_path = tmp.path().join(".lock");
107        let _held = EnvFlock::acquire(&lock_path).unwrap();
108        let attempt = EnvFlock::try_acquire(&lock_path).unwrap();
109        assert!(attempt.is_none(), "expected try_acquire to fail while held");
110    }
111
112    #[test]
113    fn try_acquire_succeeds_after_drop() {
114        let tmp = TempDir::new().unwrap();
115        let lock_path = tmp.path().join(".lock");
116        {
117            let _held = EnvFlock::acquire(&lock_path).unwrap();
118        }
119        let attempt = EnvFlock::try_acquire(&lock_path).unwrap();
120        assert!(attempt.is_some());
121    }
122
123    #[test]
124    fn creates_parent_dir() {
125        let tmp = TempDir::new().unwrap();
126        let lock_path = tmp.path().join("nested/dir/.lock");
127        let _g = EnvFlock::acquire(&lock_path).unwrap();
128        assert!(lock_path.exists());
129    }
130}