Skip to main content

nap_core/server/
manager.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Server manager for Lore repository backend
4//!
5//! Provides complete lifecycle management for the Local provider:
6//! - ensureInstalled()
7//! - ensureConfigured()
8//! - ensureRunning()
9//! - start()
10//! - stop()
11//! - restart()
12//! - status()
13//! - healthCheck()
14//! - upgrade()
15
16use 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
29/// Server manager for Lore repository backend
30pub 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    /// Create a new server manager
41    pub fn new(nap_home: &Path) -> Self {
42        Self {
43            nap_home: nap_home.to_path_buf(),
44            http_port: 41339, // Default Lore HTTP port
45            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    /// Set custom HTTP port
53    pub fn with_http_port(mut self, port: u16) -> Self {
54        self.http_port = port;
55        self
56    }
57
58    /// Ensure Lore is installed
59    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 completely missing, try to install automatically
69            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                // Re-verify after install
78                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            // Incompatible but present
91            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    /// Ensure Lore is configured
118    pub fn ensure_configured(&self) -> Result<()> {
119        info!(nap_home = %self.nap_home.display(), "Ensuring Lore configuration");
120
121        // Generate configuration
122        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        // Generate certificates
131        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    /// Ensure Lore server is running
149    pub async fn ensure_running(&self) -> Result<()> {
150        info!("Ensuring Lore server is running");
151
152        // Try to acquire lock
153        let mut lock = ProcessLock::new(&self.nap_home);
154        if !lock.try_acquire()? {
155            // Server lock is held - check if daemon is actually healthy
156            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        // Start server
187        self.start_internal(&mut lock).await?;
188
189        info!("Lore server is running and healthy");
190        Ok(())
191    }
192
193    /// Start Lore server
194    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    /// Internal start implementation
209    async fn start_internal(&self, lock: &mut ProcessLock) -> Result<()> {
210        let process_manager = LoreProcessManager::new(&self.nap_home);
211
212        // Start the process in detached mode
213        let daemon_pid = process_manager
214            .start()
215            .context("Failed to start Lore server process")?;
216
217        // Write the actual daemon PID to the lock file
218        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        // Wait for server to become healthy
227        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        // Release lock on failure
254        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    /// Stop Lore server
269    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        // Remove lock file
298        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    /// Restart Lore server
308    pub async fn restart(&self) -> Result<()> {
309        info!("Restarting Lore server");
310
311        self.stop()?;
312        tokio::time::sleep(Duration::from_secs(2)).await; // Give it time to stop
313        self.start().await?;
314
315        info!("Lore server restarted");
316        Ok(())
317    }
318
319    /// Get server status
320    pub async fn status(&self) -> Result<ServerStatus> {
321        let lock_file = self.nap_home.join("lore").join("pid");
322
323        // Prioritize health check over PID tracking since the server may be re-parented
324        let healthy =
325            LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await?;
326
327        // Only check PID if health check fails, to provide better error messages
328        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    /// Perform health check
354    pub async fn health_check(&self) -> Result<bool> {
355        LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await
356    }
357
358    /// Upgrade Lore (not implemented yet)
359    pub fn upgrade(&self) -> Result<()> {
360        anyhow::bail!("Lore upgrade is not yet implemented");
361    }
362}
363
364/// Server status information
365#[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    /// Check if server is ready for use
375    pub fn is_ready(&self) -> bool {
376        self.running && self.healthy && self.configured
377    }
378
379    /// Get a human-readable status message
380    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}