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