308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
//! Server lock file management for CLI-server automation.
|
|
//!
|
|
//! This module handles the `.databuild/${graph_label}/server.lock` file which:
|
|
//! 1. Acts as a file lock to prevent multiple servers for the same graph
|
|
//! 2. Stores runtime state (PID, port, start time, config hash)
|
|
|
|
use crate::util::DatabuildError;
|
|
use fs2::FileExt;
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Runtime state stored in the server.lock file
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct ServerLockState {
|
|
/// Server process ID
|
|
pub pid: u32,
|
|
/// HTTP port the server is listening on
|
|
pub port: u16,
|
|
/// Unix timestamp (milliseconds) when server started
|
|
pub started_at: u64,
|
|
/// SHA256 hash of config file contents (for detecting config changes)
|
|
pub config_hash: String,
|
|
}
|
|
|
|
/// Manages the server lock file and .databuild directory structure.
|
|
pub struct ServerLock {
|
|
/// Path to the .databuild/${graph_label}/ directory
|
|
graph_dir: PathBuf,
|
|
/// Path to the server.lock file
|
|
lock_path: PathBuf,
|
|
/// Open file handle (holds the lock while alive)
|
|
lock_file: Option<File>,
|
|
}
|
|
|
|
impl ServerLock {
|
|
/// Create a ServerLock for the given graph label, using the current directory.
|
|
/// Creates the .databuild/${graph_label}/ directory if it doesn't exist.
|
|
pub fn new(graph_label: &str) -> Result<Self, DatabuildError> {
|
|
Self::new_in_dir(Path::new("."), graph_label)
|
|
}
|
|
|
|
/// Create a ServerLock for the given graph label in the specified base directory.
|
|
/// Creates the .databuild/${graph_label}/ directory if it doesn't exist.
|
|
pub fn new_in_dir(base_dir: &Path, graph_label: &str) -> Result<Self, DatabuildError> {
|
|
let graph_dir = base_dir.join(".databuild").join(graph_label);
|
|
fs::create_dir_all(&graph_dir).map_err(|e| {
|
|
DatabuildError::from(format!("Failed to create graph directory: {}", e))
|
|
})?;
|
|
|
|
let lock_path = graph_dir.join("server.lock");
|
|
|
|
Ok(Self {
|
|
graph_dir,
|
|
lock_path,
|
|
lock_file: None,
|
|
})
|
|
}
|
|
|
|
/// Path to the graph directory (`.databuild/${graph_label}/`)
|
|
pub fn graph_dir(&self) -> &Path {
|
|
&self.graph_dir
|
|
}
|
|
|
|
/// Path to the server log file
|
|
pub fn log_path(&self) -> PathBuf {
|
|
self.graph_dir.join("server.log")
|
|
}
|
|
|
|
/// Try to acquire an exclusive lock on the server.lock file.
|
|
/// Returns Ok(true) if lock acquired (server not running).
|
|
/// Returns Ok(false) if lock is held by another process (server running).
|
|
pub fn try_lock(&mut self) -> Result<bool, DatabuildError> {
|
|
let file = OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(&self.lock_path)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to open lock file: {}", e)))?;
|
|
|
|
match file.try_lock_exclusive() {
|
|
Ok(()) => {
|
|
self.lock_file = Some(file);
|
|
Ok(true)
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
// Lock is held by another process
|
|
Ok(false)
|
|
}
|
|
Err(e) => Err(DatabuildError::from(format!(
|
|
"Failed to acquire lock: {}",
|
|
e
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Write the server state to the lock file.
|
|
/// Must have acquired the lock first via try_lock().
|
|
pub fn write_state(&mut self, state: &ServerLockState) -> Result<(), DatabuildError> {
|
|
let file = self
|
|
.lock_file
|
|
.as_mut()
|
|
.ok_or_else(|| DatabuildError::from("Lock not acquired"))?;
|
|
|
|
// Truncate and write new content
|
|
file.set_len(0)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to truncate lock file: {}", e)))?;
|
|
|
|
let content = serde_json::to_string_pretty(state)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to serialize state: {}", e)))?;
|
|
|
|
// Seek to beginning
|
|
use std::io::Seek;
|
|
file.seek(std::io::SeekFrom::Start(0))
|
|
.map_err(|e| DatabuildError::from(format!("Failed to seek: {}", e)))?;
|
|
|
|
file.write_all(content.as_bytes())
|
|
.map_err(|e| DatabuildError::from(format!("Failed to write state: {}", e)))?;
|
|
|
|
file.flush()
|
|
.map_err(|e| DatabuildError::from(format!("Failed to flush: {}", e)))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the server state from the lock file.
|
|
/// Can be called without holding the lock (to read port from running server).
|
|
pub fn read_state(&self) -> Result<Option<ServerLockState>, DatabuildError> {
|
|
if !self.lock_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut file = File::open(&self.lock_path)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to open lock file: {}", e)))?;
|
|
|
|
let mut content = String::new();
|
|
file.read_to_string(&mut content)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to read lock file: {}", e)))?;
|
|
|
|
if content.trim().is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let state: ServerLockState = serde_json::from_str(&content)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to parse lock file: {}", e)))?;
|
|
|
|
Ok(Some(state))
|
|
}
|
|
|
|
/// Check if the process with the given PID is still running.
|
|
pub fn is_process_running(pid: u32) -> bool {
|
|
// On Unix, sending signal 0 checks if process exists
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
unsafe { libc::kill(pid as i32, 0) == 0 }
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
// On non-Unix, assume process is running if we can't check
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Delete a stale lock file (when process has crashed).
|
|
pub fn remove_stale_lock(&self) -> Result<(), DatabuildError> {
|
|
if self.lock_path.exists() {
|
|
fs::remove_file(&self.lock_path)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to remove stale lock: {}", e)))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Compute SHA256 hash of config file contents.
|
|
pub fn hash_config(config_path: &Path) -> Result<String, DatabuildError> {
|
|
let content = fs::read_to_string(config_path)
|
|
.map_err(|e| DatabuildError::from(format!("Failed to read config: {}", e)))?;
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(content.as_bytes());
|
|
let result = hasher.finalize();
|
|
|
|
Ok(format!("sha256:{:x}", result))
|
|
}
|
|
|
|
/// Get current timestamp in milliseconds since Unix epoch.
|
|
pub fn now_millis() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64
|
|
}
|
|
}
|
|
|
|
impl Drop for ServerLock {
|
|
fn drop(&mut self) {
|
|
// Lock is automatically released when file is closed
|
|
if let Some(file) = self.lock_file.take() {
|
|
let _ = file.unlock();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn test_server_lock_acquire() {
|
|
let temp = tempdir().unwrap();
|
|
let mut lock = ServerLock::new_in_dir(temp.path(), "test_graph").unwrap();
|
|
assert!(lock.try_lock().unwrap(), "Lock should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_lock_state_read_write() {
|
|
let temp = tempdir().unwrap();
|
|
let mut lock = ServerLock::new_in_dir(temp.path(), "test_graph").unwrap();
|
|
assert!(lock.try_lock().unwrap());
|
|
|
|
let state = ServerLockState {
|
|
pid: 12345,
|
|
port: 8080,
|
|
started_at: 1701234567890,
|
|
config_hash: "sha256:abc123".to_string(),
|
|
};
|
|
|
|
lock.write_state(&state).unwrap();
|
|
|
|
// Read it back (via a fresh ServerLock to test read without lock)
|
|
let lock2 = ServerLock::new_in_dir(temp.path(), "test_graph").unwrap();
|
|
let read_state = lock2.read_state().unwrap().unwrap();
|
|
assert_eq!(read_state.pid, 12345);
|
|
assert_eq!(read_state.port, 8080);
|
|
assert_eq!(read_state.started_at, 1701234567890);
|
|
assert_eq!(read_state.config_hash, "sha256:abc123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_lock_graph_dir() {
|
|
let temp = tempdir().unwrap();
|
|
let lock = ServerLock::new_in_dir(temp.path(), "my_graph").unwrap();
|
|
assert!(lock.graph_dir().ends_with(".databuild/my_graph"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_lock_log_path() {
|
|
let temp = tempdir().unwrap();
|
|
let lock = ServerLock::new_in_dir(temp.path(), "my_graph").unwrap();
|
|
assert!(lock.log_path().ends_with(".databuild/my_graph/server.log"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_hash_config() {
|
|
let temp = tempdir().unwrap();
|
|
let config_path = temp.path().join("config.json");
|
|
fs::write(&config_path, r#"{"graph_label": "test"}"#).unwrap();
|
|
|
|
let hash = ServerLock::hash_config(&config_path).unwrap();
|
|
assert!(hash.starts_with("sha256:"));
|
|
assert!(hash.len() > 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hash_config_deterministic() {
|
|
let temp = tempdir().unwrap();
|
|
let config_path = temp.path().join("config.json");
|
|
fs::write(&config_path, r#"{"graph_label": "test"}"#).unwrap();
|
|
|
|
let hash1 = ServerLock::hash_config(&config_path).unwrap();
|
|
let hash2 = ServerLock::hash_config(&config_path).unwrap();
|
|
assert_eq!(hash1, hash2, "Same content should produce same hash");
|
|
}
|
|
|
|
#[test]
|
|
fn test_read_state_nonexistent() {
|
|
let temp = tempdir().unwrap();
|
|
let lock = ServerLock::new_in_dir(temp.path(), "nonexistent_graph").unwrap();
|
|
let state = lock.read_state().unwrap();
|
|
assert!(
|
|
state.is_none(),
|
|
"Reading nonexistent lock file should return None"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_now_millis() {
|
|
let before = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
|
|
let now = ServerLock::now_millis();
|
|
|
|
let after = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
|
|
assert!(
|
|
now >= before && now <= after,
|
|
"now_millis should be between before and after"
|
|
);
|
|
}
|
|
}
|