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
26/// Server manager for Lore repository backend
27pub 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    /// Create a new server manager
38    pub fn new(nap_home: &Path) -> Self {
39        Self {
40            nap_home: nap_home.to_path_buf(),
41            http_port: 41339, // Default Lore HTTP port
42            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    /// Set custom HTTP port
50    pub fn with_http_port(mut self, port: u16) -> Self {
51        self.http_port = port;
52        self
53    }
54
55    /// Ensure Lore is installed
56    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.as_ref().map(|v| v.raw.as_str()).unwrap_or("not detected"),
67                server_installed = status.server_installed,
68                server_version = status.server_version.as_ref().map(|v| v.raw.as_str()).unwrap_or("not detected"),
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    /// Ensure Lore is configured
90    pub fn ensure_configured(&self) -> Result<()> {
91        info!(nap_home = %self.nap_home.display(), "Ensuring Lore configuration");
92
93        // Generate configuration
94        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        // Generate certificates
102        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    /// Ensure Lore server is running
119    pub async fn ensure_running(&self) -> Result<()> {
120        info!("Ensuring Lore server is running");
121
122        // Try to acquire lock
123        let mut lock = ProcessLock::new(&self.nap_home);
124        if !lock.try_acquire()? {
125            // Server lock is held - check if daemon is actually healthy
126            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        // Start server
157        self.start_internal(&mut lock).await?;
158
159        info!("Lore server is running and healthy");
160        Ok(())
161    }
162
163    /// Start Lore server
164    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    /// Internal start implementation
179    async fn start_internal(&self, lock: &mut ProcessLock) -> Result<()> {
180        let process_manager = LoreProcessManager::new(&self.nap_home);
181
182        // Start the process
183        let child = process_manager
184            .start()
185            .context("Failed to start Lore server process")?;
186
187        // Write the actual daemon PID to the lock file
188        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        // Wait for server to become healthy
195        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        // Release lock on failure
222        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    /// Stop Lore server
236    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        // Remove lock file
265        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    /// Restart Lore server
275    pub async fn restart(&self) -> Result<()> {
276        info!("Restarting Lore server");
277
278        self.stop()?;
279        tokio::time::sleep(Duration::from_secs(2)).await; // Give it time to stop
280        self.start().await?;
281
282        info!("Lore server restarted");
283        Ok(())
284    }
285
286    /// Get server status
287    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    /// Perform health check
325    pub async fn health_check(&self) -> Result<bool> {
326        LoreProcessManager::health_check(self.http_port, self.health_check_timeout).await
327    }
328
329    /// Upgrade Lore (not implemented yet)
330    pub fn upgrade(&self) -> Result<()> {
331        anyhow::bail!("Lore upgrade is not yet implemented");
332    }
333}
334
335/// Server status information
336#[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    /// Check if server is ready for use
346    pub fn is_ready(&self) -> bool {
347        self.running && self.healthy && self.configured
348    }
349
350    /// Get a human-readable status message
351    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}