use anyhow::{Context, Result};
use std::path::Path;
use std::time::Duration;
use tracing::{error, info, warn};
use super::{
cert::generate_certificates, config::generate_local_config, lock::ProcessLock,
process::LoreProcessManager, version::verify_lore_installation,
};
pub struct ServerManager {
nap_home: std::path::PathBuf,
http_port: u16,
health_check_timeout: Duration,
startup_timeout: Duration,
retry_interval: Duration,
max_retries: usize,
}
impl ServerManager {
pub fn new(nap_home: &Path) -> Self {
Self {
nap_home: nap_home.to_path_buf(),
http_port: 41339, health_check_timeout: Duration::from_secs(5),
startup_timeout: Duration::from_secs(30),
retry_interval: Duration::from_secs(1),
max_retries: 3,
}
}
pub fn with_http_port(mut self, port: u16) -> Self {
self.http_port = port;
self
}
pub fn ensure_installed(&self) -> Result<()> {
info!("Checking Lore installation");
let status =
verify_lore_installation().context("Failed to verify Lore installation status")?;
if !status.is_fully_compatible() {
let message = status.status_message();
error!(
installed = status.cli_installed,
cli_version = status.cli_version.as_ref().map(|v| v.raw.as_str()).unwrap_or("not detected"),
server_installed = status.server_installed,
server_version = status.server_version.as_ref().map(|v| v.raw.as_str()).unwrap_or("not detected"),
pinned = %status.pinned_version,
"Lore installation incompatible: {}",
message
);
anyhow::bail!(
"Lore installation is not compatible: {}. \
Required version: {}. \
Fix: run 'nap install lore' to install the correct version.",
message,
status.pinned_version
);
}
info!(
pinned_version = %status.pinned_version,
"Lore installation is compatible"
);
Ok(())
}
pub fn ensure_configured(&self) -> Result<()> {
info!(nap_home = %self.nap_home.display(), "Ensuring Lore configuration");
let config_files = generate_local_config(&self.nap_home).context(format!(
"Failed to generate Lore configuration at '{}'. \
Check directory permissions and disk space.",
self.nap_home.display()
))?;
tracing::debug!(config_path = %config_files.config_path.display(), "Lore config generated");
let cert_dir = self.nap_home.join("lore").join("certs");
let cert_files = generate_certificates(&cert_dir).context(format!(
"Failed to generate Lore certificates at '{}'. \
Check directory permissions.",
cert_dir.display()
))?;
tracing::debug!(
cert = %cert_files.cert_path.display(),
key = %cert_files.key_path.display(),
"Lore certificates generated"
);
info!("Lore configuration and certificates are ready");
Ok(())
}
pub async fn ensure_running(&self) -> Result<()> {
info!("Ensuring Lore server is running");
let mut lock = ProcessLock::new(&self.nap_home);
if !lock.try_acquire()? {
let daemon_pid = lock
.read_daemon_pid()
.unwrap_or(None)
.map(|p| p.to_string())
.unwrap_or_else(|| "unknown".to_string());
info!(
daemon_pid = %daemon_pid,
"Server lock already held, verifying daemon health"
);
if LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await? {
info!(daemon_pid = %daemon_pid, "Existing daemon is healthy and running");
return Ok(());
}
warn!(
daemon_pid = %daemon_pid,
"Server lock held but daemon health check failed — \
the daemon may have crashed. Remove the lock file at '{}' and retry.",
self.nap_home.join("lore").join("pid").display()
);
anyhow::bail!(
"Server lock is held by daemon PID {} but the server is not responding to health checks. \
The daemon may have crashed. Try: nap doctor, or manually remove '{}'.",
daemon_pid,
self.nap_home.join("lore").join("pid").display()
);
}
self.start_internal(&mut lock).await?;
info!("Lore server is running and healthy");
Ok(())
}
pub async fn start(&self) -> Result<()> {
info!("Starting Lore server");
let mut lock = ProcessLock::new(&self.nap_home);
if !lock.try_acquire()? {
anyhow::bail!("Server is already running (lock held)");
}
self.start_internal(&mut lock).await?;
info!("Lore server started successfully");
Ok(())
}
async fn start_internal(&self, lock: &mut ProcessLock) -> Result<()> {
let process_manager = LoreProcessManager::new(&self.nap_home);
let child = process_manager
.start()
.context("Failed to start Lore server process")?;
let daemon_pid = child.id();
lock.write_daemon_pid(daemon_pid)
.context("Failed to write daemon PID to lock file")?;
tracing::info!(daemon_pid, "Lore daemon spawned, waiting for health check");
let mut retries = 0;
while retries < self.max_retries {
match crate::server::process::LoreProcessManager::wait_for_healthy(
self.http_port,
self.startup_timeout,
self.retry_interval,
)
.await
{
Ok(_) => return Ok(()),
Err(e) => {
retries += 1;
warn!(
attempt = retries,
max_retries = self.max_retries,
error = %e,
"Health check attempt {} of {} failed",
retries, self.max_retries
);
if retries < self.max_retries {
tokio::time::sleep(self.retry_interval).await;
}
}
}
}
lock.release()?;
anyhow::bail!(
"Lore server failed to become healthy after {} retries. \
Check logs at '{}' for startup errors.",
self.max_retries,
self.nap_home
.join("lore")
.join("logs")
.join("loreserver.log")
.display()
);
}
pub fn stop(&self) -> Result<()> {
let lock_file = self.nap_home.join("lore").join("pid");
if !lock_file.exists() {
info!(
"No lock file found at '{}', server may not be running",
lock_file.display()
);
return Ok(());
}
let pid_str = std::fs::read_to_string(&lock_file).context(format!(
"Failed to read PID lock file at '{}'",
lock_file.display()
))?;
let pid: u32 = pid_str.trim().parse().context(format!(
"Failed to parse PID '{}' from lock file at '{}'",
pid_str.trim(),
lock_file.display()
))?;
info!(pid, "Stopping Lore server process");
LoreProcessManager::stop(pid).context(format!(
"Failed to stop Lore server process (PID {}). \
The process may have already exited.",
pid
))?;
std::fs::remove_file(&lock_file).context(format!(
"Failed to remove lock file at '{}'",
lock_file.display()
))?;
info!(pid, "Lore server stopped successfully");
Ok(())
}
pub async fn restart(&self) -> Result<()> {
info!("Restarting Lore server");
self.stop()?;
tokio::time::sleep(Duration::from_secs(2)).await; self.start().await?;
info!("Lore server restarted");
Ok(())
}
pub async fn status(&self) -> Result<ServerStatus> {
let lock_file = self.nap_home.join("lore").join("pid");
let running = if lock_file.exists() {
let pid_str =
std::fs::read_to_string(&lock_file).context("Failed to read lock file")?;
let pid: u32 = pid_str
.trim()
.parse()
.context("Failed to parse PID from lock file")?;
LoreProcessManager::is_running(pid)
} else {
false
};
let healthy = if running {
LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await?
} else {
false
};
let configured = self
.nap_home
.join("lore")
.join("config")
.join("local.toml")
.exists();
Ok(ServerStatus {
running,
healthy,
configured,
http_port: self.http_port,
})
}
pub async fn health_check(&self) -> Result<bool> {
LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await
}
pub fn upgrade(&self) -> Result<()> {
anyhow::bail!("Lore upgrade is not yet implemented");
}
}
#[derive(Debug, Clone)]
pub struct ServerStatus {
pub running: bool,
pub healthy: bool,
pub configured: bool,
pub http_port: u16,
}
impl ServerStatus {
pub fn is_ready(&self) -> bool {
self.running && self.healthy && self.configured
}
pub fn status_message(&self) -> String {
if self.is_ready() {
"Server is running and healthy".to_string()
} else if !self.configured {
"Server is not configured".to_string()
} else if !self.running {
"Server is not running".to_string()
} else {
"Server is running but not healthy".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_server_manager_creation() {
let temp_dir = TempDir::new().unwrap();
let manager = ServerManager::new(temp_dir.path());
assert_eq!(manager.http_port, 41339);
}
#[test]
fn test_server_manager_custom_port() {
let temp_dir = TempDir::new().unwrap();
let manager = ServerManager::new(temp_dir.path()).with_http_port(8080);
assert_eq!(manager.http_port, 8080);
}
#[test]
fn test_server_status_message() {
let status = ServerStatus {
running: true,
healthy: true,
configured: true,
http_port: 41339,
};
assert_eq!(status.status_message(), "Server is running and healthy");
let status = ServerStatus {
running: false,
healthy: false,
configured: false,
http_port: 41339,
};
assert_eq!(status.status_message(), "Server is not configured");
}
}