1use anyhow::{Context, Result};
17use std::path::Path;
18use std::time::Duration;
19use tracing::{error, info, warn};
20
21use super::{
22 cert::generate_certificates, config::generate_local_config, lock::ProcessLock,
23 process::LoreProcessManager, version::verify_lore_installation,
24};
25
26use crate::LoreInstaller;
27use crate::server::error_ids;
28
29pub struct ServerManager {
31 nap_home: std::path::PathBuf,
32 http_port: u16,
33 health_check_timeout: Duration,
34 startup_timeout: Duration,
35 retry_interval: Duration,
36 max_retries: usize,
37}
38
39impl ServerManager {
40 pub fn new(nap_home: &Path) -> Self {
42 Self {
43 nap_home: nap_home.to_path_buf(),
44 http_port: 41339, health_check_timeout: Duration::from_secs(5),
46 startup_timeout: Duration::from_secs(10),
47 retry_interval: Duration::from_secs(1),
48 max_retries: 3,
49 }
50 }
51
52 pub fn with_http_port(mut self, port: u16) -> Self {
54 self.http_port = port;
55 self
56 }
57
58 pub fn ensure_installed(&self) -> Result<()> {
60 info!("Checking Lore installation");
61
62 let status =
63 verify_lore_installation().context("Failed to verify Lore installation status")?;
64
65 if !status.is_fully_compatible() {
66 let message = status.status_message();
67
68 if !status.cli_installed || !status.server_installed {
70 info!("Lore not detected, attempting automatic installation");
71 let installer = LoreInstaller::new(None);
72 installer.install_all().context(format!(
73 "[{}] Failed to automatically install Lore",
74 error_ids::ERR_LORE_INSTALL_FAILED
75 ))?;
76
77 let new_status = verify_lore_installation()?;
79 if !new_status.is_fully_compatible() {
80 anyhow::bail!(
81 "[{}] Lore installation failed or is incompatible: {}. \
82 Fix: run 'nap install lore' to manually install.",
83 error_ids::ERR_LORE_INCOMPATIBLE,
84 new_status.status_message()
85 );
86 }
87 return Ok(());
88 }
89
90 error!(
92 installed = status.cli_installed,
93 cli_version = status.cli_version.as_ref().map(|v| v.raw.as_str()).unwrap_or("not detected"),
94 server_installed = status.server_installed,
95 server_version = status.server_version.as_ref().map(|v| v.raw.as_str()).unwrap_or("not detected"),
96 pinned = %status.pinned_version,
97 "Lore installation incompatible: {}",
98 message
99 );
100 anyhow::bail!(
101 "[{}] Lore installation is incompatible: {}. \
102 Required version: {}. \
103 Fix: run 'nap install lore' to install the correct version.",
104 error_ids::ERR_LORE_INCOMPATIBLE,
105 message,
106 status.pinned_version
107 );
108 }
109
110 info!(
111 pinned_version = %status.pinned_version,
112 "Lore installation is compatible"
113 );
114 Ok(())
115 }
116
117 pub fn ensure_configured(&self) -> Result<()> {
119 info!(nap_home = %self.nap_home.display(), "Ensuring Lore configuration");
120
121 let config_files = generate_local_config(&self.nap_home).context(format!(
123 "[{}] Failed to generate Lore configuration at '{}'. \
124 Check directory permissions and disk space.",
125 error_ids::ERR_LORE_CONFIG_FAILED,
126 self.nap_home.display()
127 ))?;
128 tracing::debug!(config_path = %config_files.config_path.display(), "Lore config generated");
129
130 let cert_dir = self.nap_home.join("lore").join("certs");
132 let cert_files = generate_certificates(&cert_dir).context(format!(
133 "[{}] Failed to generate Lore certificates at '{}'. \
134 Check directory permissions.",
135 error_ids::ERR_LORE_CERT_FAILED,
136 cert_dir.display()
137 ))?;
138 tracing::debug!(
139 cert = %cert_files.cert_path.display(),
140 key = %cert_files.key_path.display(),
141 "Lore certificates generated"
142 );
143
144 info!("Lore configuration and certificates are ready");
145 Ok(())
146 }
147
148 pub async fn ensure_running(&self) -> Result<()> {
150 info!("Ensuring Lore server is running");
151
152 let mut lock = ProcessLock::new(&self.nap_home);
154 if !lock.try_acquire()? {
155 let daemon_pid = lock
157 .read_daemon_pid()
158 .unwrap_or(None)
159 .map(|p| p.to_string())
160 .unwrap_or_else(|| "unknown".to_string());
161
162 info!(
163 daemon_pid = %daemon_pid,
164 "Server lock already held, verifying daemon health"
165 );
166
167 if LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await? {
168 info!(daemon_pid = %daemon_pid, "Existing daemon is healthy and running");
169 return Ok(());
170 }
171
172 warn!(
173 daemon_pid = %daemon_pid,
174 "Server lock held but daemon health check failed — \
175 the daemon may have crashed. Remove the lock file at '{}' and retry.",
176 self.nap_home.join("lore").join("pid").display()
177 );
178 anyhow::bail!(
179 "Lore server (PID {}) is not responding. \
180 It may have crashed. Try: nap doctor, or manually remove '{}'.",
181 daemon_pid,
182 self.nap_home.join("lore").join("pid").display()
183 );
184 }
185
186 self.start_internal(&mut lock).await?;
188
189 info!("Lore server is running and healthy");
190 Ok(())
191 }
192
193 pub async fn start(&self) -> Result<()> {
195 info!("Starting Lore server");
196
197 let mut lock = ProcessLock::new(&self.nap_home);
198 if !lock.try_acquire()? {
199 anyhow::bail!("Server is already running (lock held)");
200 }
201
202 self.start_internal(&mut lock).await?;
203
204 info!("Lore server started successfully");
205 Ok(())
206 }
207
208 async fn start_internal(&self, lock: &mut ProcessLock) -> Result<()> {
210 let process_manager = LoreProcessManager::new(&self.nap_home);
211
212 let daemon_pid = process_manager
214 .start()
215 .context("Failed to start Lore server process")?;
216
217 lock.write_daemon_pid(daemon_pid)
219 .context("Failed to write server PID to lock file")?;
220
221 tracing::info!(
222 daemon_pid,
223 "Lore daemon spawned in detached mode, waiting for health check"
224 );
225
226 let mut retries = 0;
228 while retries < self.max_retries {
229 match crate::server::process::LoreProcessManager::wait_for_healthy(
230 self.http_port,
231 self.startup_timeout,
232 self.retry_interval,
233 )
234 .await
235 {
236 Ok(_) => return Ok(()),
237 Err(e) => {
238 retries += 1;
239 warn!(
240 attempt = retries,
241 max_retries = self.max_retries,
242 error = %e,
243 "Health check attempt {} of {} failed",
244 retries, self.max_retries
245 );
246 if retries < self.max_retries {
247 tokio::time::sleep(self.retry_interval).await;
248 }
249 }
250 }
251 }
252
253 lock.release()?;
255 anyhow::bail!(
256 "[{}] Lore server failed to become healthy after {} retries. \
257 Check logs at '{}' for startup errors.",
258 error_ids::ERR_LORE_STARTUP_FAILED,
259 self.max_retries,
260 self.nap_home
261 .join("lore")
262 .join("logs")
263 .join("loreserver.log")
264 .display()
265 );
266 }
267
268 pub fn stop(&self) -> Result<()> {
270 let lock_file = self.nap_home.join("lore").join("pid");
271 if !lock_file.exists() {
272 info!(
273 "No lock file found at '{}', server may not be running",
274 lock_file.display()
275 );
276 return Ok(());
277 }
278
279 let pid_str = std::fs::read_to_string(&lock_file).context(format!(
280 "Failed to read PID lock file at '{}'",
281 lock_file.display()
282 ))?;
283 let pid: u32 = pid_str.trim().parse().context(format!(
284 "Failed to parse PID '{}' from lock file at '{}'",
285 pid_str.trim(),
286 lock_file.display()
287 ))?;
288
289 info!(pid, "Stopping Lore server process");
290
291 LoreProcessManager::stop(pid).context(format!(
292 "Failed to stop Lore server process (PID {}). \
293 The process may have already exited.",
294 pid
295 ))?;
296
297 std::fs::remove_file(&lock_file).context(format!(
299 "Failed to remove lock file at '{}'",
300 lock_file.display()
301 ))?;
302
303 info!(pid, "Lore server stopped successfully");
304 Ok(())
305 }
306
307 pub async fn restart(&self) -> Result<()> {
309 info!("Restarting Lore server");
310
311 self.stop()?;
312 tokio::time::sleep(Duration::from_secs(2)).await; self.start().await?;
314
315 info!("Lore server restarted");
316 Ok(())
317 }
318
319 pub async fn status(&self) -> Result<ServerStatus> {
321 let lock_file = self.nap_home.join("lore").join("pid");
322
323 let healthy =
325 LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await?;
326
327 let running = if healthy {
329 true
330 } else if lock_file.exists() {
331 let pid_str =
332 std::fs::read_to_string(&lock_file).context("Failed to read lock file")?;
333 let pid: u32 = pid_str
334 .trim()
335 .parse()
336 .context("Failed to parse PID from lock file")?;
337
338 LoreProcessManager::is_running(pid)
339 } else {
340 false
341 };
342
343 let configured = self.nap_home.join("lore").join("config").exists();
344
345 Ok(ServerStatus {
346 running,
347 healthy,
348 configured,
349 http_port: self.http_port,
350 })
351 }
352
353 pub async fn health_check(&self) -> Result<bool> {
355 LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await
356 }
357
358 pub fn upgrade(&self) -> Result<()> {
360 anyhow::bail!("Lore upgrade is not yet implemented");
361 }
362}
363
364#[derive(Debug, Clone)]
366pub struct ServerStatus {
367 pub running: bool,
368 pub healthy: bool,
369 pub configured: bool,
370 pub http_port: u16,
371}
372
373impl ServerStatus {
374 pub fn is_ready(&self) -> bool {
376 self.running && self.healthy && self.configured
377 }
378
379 pub fn status_message(&self) -> String {
381 if self.is_ready() {
382 "Server is running and healthy".to_string()
383 } else if !self.configured {
384 "Server is not configured".to_string()
385 } else if !self.running {
386 "Server is not running".to_string()
387 } else {
388 "Server is running but not healthy".to_string()
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use tempfile::TempDir;
397
398 #[test]
399 fn test_server_manager_creation() {
400 let temp_dir = TempDir::new().unwrap();
401 let manager = ServerManager::new(temp_dir.path());
402 assert_eq!(manager.http_port, 41339);
403 }
404
405 #[test]
406 fn test_server_manager_custom_port() {
407 let temp_dir = TempDir::new().unwrap();
408 let manager = ServerManager::new(temp_dir.path()).with_http_port(8080);
409 assert_eq!(manager.http_port, 8080);
410 }
411
412 #[test]
413 fn test_server_status_message() {
414 let status = ServerStatus {
415 running: true,
416 healthy: true,
417 configured: true,
418 http_port: 41339,
419 };
420 assert_eq!(status.status_message(), "Server is running and healthy");
421
422 let status = ServerStatus {
423 running: false,
424 healthy: false,
425 configured: false,
426 http_port: 41339,
427 };
428 assert_eq!(status.status_message(), "Server is not configured");
429 }
430}