Skip to main content

soli_proxy/app/
mod.rs

1use notify::{RecommendedWatcher, RecursiveMode, Watcher};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use tokio::sync::mpsc;
7use tokio::sync::Mutex;
8use url::Url;
9
10pub mod deployment;
11pub mod port_manager;
12
13pub use deployment::{DeploymentManager, DeploymentStatus};
14pub use port_manager::{PortAllocator, PortManager};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AppConfig {
18    pub name: String,
19    pub domain: String,
20    pub start_script: Option<String>,
21    pub stop_script: Option<String>,
22    pub health_check: Option<String>,
23    pub graceful_timeout: u32,
24    pub port_range_start: u16,
25    pub port_range_end: u16,
26    #[serde(default = "default_workers")]
27    pub workers: u16,
28}
29
30fn default_workers() -> u16 {
31    1
32}
33
34impl Default for AppConfig {
35    fn default() -> Self {
36        Self {
37            name: String::new(),
38            domain: String::new(),
39            start_script: None,
40            stop_script: None,
41            health_check: Some("/health".to_string()),
42            graceful_timeout: 30,
43            port_range_start: 9000,
44            port_range_end: 9999,
45            workers: 1,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AppInstance {
52    pub name: String,
53    pub slot: String,
54    pub port: u16,
55    pub pid: Option<u32>,
56    pub status: InstanceStatus,
57    pub last_started: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub enum InstanceStatus {
62    Stopped,
63    Starting,
64    Running,
65    Unhealthy,
66    Failed,
67}
68
69impl std::fmt::Display for InstanceStatus {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            InstanceStatus::Stopped => write!(f, "Stopped"),
73            InstanceStatus::Starting => write!(f, "Starting"),
74            InstanceStatus::Running => write!(f, "Running"),
75            InstanceStatus::Unhealthy => write!(f, "Unhealthy"),
76            InstanceStatus::Failed => write!(f, "Failed"),
77        }
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AppInfo {
83    pub config: AppConfig,
84    pub path: PathBuf,
85    pub blue: AppInstance,
86    pub green: AppInstance,
87    pub current_slot: String,
88}
89
90impl AppInfo {
91    pub fn from_path(path: &std::path::Path) -> Result<Self, anyhow::Error> {
92        let app_infos_path = path.join("app.infos");
93
94        let mut config = if app_infos_path.exists() {
95            let content = std::fs::read_to_string(&app_infos_path)?;
96            toml::from_str(&content)?
97        } else {
98            AppConfig::default()
99        };
100
101        let app_name = path
102            .file_name()
103            .and_then(|n| n.to_str())
104            .unwrap_or_default()
105            .to_string();
106
107        // Name fallback: use directory name if not set in app.infos
108        if config.name.is_empty() {
109            config.name = app_name.clone();
110        }
111
112        // LuaOnBeans auto-detection: if no start_script and luaonbeans.org binary exists
113        if config.start_script.is_none() && path.join("luaonbeans.org").exists() {
114            config.start_script = Some("./luaonbeans.org -D . -p $PORT -s".to_string());
115            config.health_check = Some("/".to_string());
116            if config.domain.is_empty() {
117                config.domain = app_name.clone();
118            }
119        }
120
121        Ok(Self {
122            config,
123            path: path.to_path_buf(),
124            blue: AppInstance {
125                name: app_name.clone(),
126                slot: "blue".to_string(),
127                port: 0,
128                pid: None,
129                status: InstanceStatus::Stopped,
130                last_started: None,
131            },
132            green: AppInstance {
133                name: app_name.clone(),
134                slot: "green".to_string(),
135                port: 0,
136                pid: None,
137                status: InstanceStatus::Stopped,
138                last_started: None,
139            },
140            current_slot: "blue".to_string(),
141        })
142    }
143}
144
145#[derive(Clone)]
146pub struct AppManager {
147    sites_dir: PathBuf,
148    port_allocator: Arc<PortManager>,
149    apps: Arc<Mutex<HashMap<String, AppInfo>>>,
150    config_manager: Arc<dyn super::config::ConfigManagerTrait + Send + Sync>,
151    pub deployment_manager: Arc<DeploymentManager>,
152    watcher: Arc<Mutex<Option<RecommendedWatcher>>>,
153    acme_service: Arc<Mutex<Option<Arc<crate::acme::AcmeService>>>>,
154    dev_mode: bool,
155}
156
157/// Convert a domain to its `.test` alias by replacing the TLD.
158/// e.g. "soli.solisoft.net" → "soli.solisoft.test"
159fn dev_domain(domain: &str) -> Option<String> {
160    if domain.ends_with(".test") || domain.ends_with(".localhost") {
161        return None;
162    }
163    let dot = domain.rfind('.')?;
164    Some(format!("{}.test", &domain[..dot]))
165}
166
167/// Check if a domain is eligible for ACME cert issuance
168/// (not localhost, not an IP address).
169fn is_acme_eligible(domain: &str) -> bool {
170    domain != "localhost"
171        && !domain.ends_with(".localhost")
172        && !domain.ends_with(".test")
173        && domain.parse::<std::net::IpAddr>().is_err()
174}
175
176/// Extract app names from changed file paths, filtering out irrelevant directories.
177/// Each path is expected to be under `sites_dir/<app_name>/...`.
178fn affected_app_names(sites_dir: &Path, paths: &HashSet<PathBuf>) -> HashSet<String> {
179    const IGNORED_SEGMENTS: &[&str] = &["node_modules", ".git", "tmp", "target"];
180
181    let mut names = HashSet::new();
182    for path in paths {
183        let relative = match path.strip_prefix(sites_dir) {
184            Ok(r) => r,
185            Err(_) => continue,
186        };
187
188        // Skip paths in irrelevant directories
189        let skip = relative.components().any(|c| {
190            if let std::path::Component::Normal(s) = c {
191                IGNORED_SEGMENTS
192                    .iter()
193                    .any(|ignored| s.to_str() == Some(*ignored))
194            } else {
195                false
196            }
197        });
198        if skip {
199            continue;
200        }
201
202        // Skip if the only changed file is app.infos (handled by discover_apps)
203        if relative.components().count() == 2 {
204            if let Some(filename) = relative.file_name() {
205                if filename == "app.infos" {
206                    continue;
207                }
208            }
209        }
210
211        // First component is the app directory name
212        if let Some(std::path::Component::Normal(app_dir)) = relative.components().next() {
213            if let Some(name) = app_dir.to_str() {
214                names.insert(name.to_string());
215            }
216        }
217    }
218    names
219}
220
221impl AppManager {
222    pub fn new(
223        sites_dir: &str,
224        port_allocator: Arc<PortManager>,
225        config_manager: Arc<dyn super::config::ConfigManagerTrait + Send + Sync>,
226        dev_mode: bool,
227    ) -> Result<Self, anyhow::Error> {
228        let sites_path = PathBuf::from(sites_dir);
229        if !sites_path.exists() {
230            std::fs::create_dir_all(&sites_path)?;
231        }
232
233        let deployment_manager = Arc::new(DeploymentManager::new(dev_mode));
234
235        Ok(Self {
236            sites_dir: sites_path,
237            port_allocator,
238            apps: Arc::new(Mutex::new(HashMap::new())),
239            config_manager,
240            deployment_manager,
241            watcher: Arc::new(Mutex::new(None)),
242            acme_service: Arc::new(Mutex::new(None)),
243            dev_mode,
244        })
245    }
246
247    pub async fn set_acme_service(&self, service: Arc<crate::acme::AcmeService>) {
248        *self.acme_service.lock().await = Some(service);
249    }
250
251    pub async fn discover_apps(&self) -> Result<(), anyhow::Error> {
252        tracing::info!("Discovering apps in {}", self.sites_dir.display());
253        let mut apps_to_start: Vec<String> = Vec::new();
254
255        {
256            let mut apps = self.apps.lock().await;
257
258            // Track which apps still exist on disk
259            let mut seen_names: HashSet<String> = HashSet::new();
260
261            for entry in std::fs::read_dir(&self.sites_dir)? {
262                let entry = entry?;
263                let path = entry.path();
264                if path.is_dir() {
265                    match AppInfo::from_path(&path) {
266                        Ok(mut app_info) => {
267                            let name = app_info.config.name.clone();
268                            seen_names.insert(name.clone());
269
270                            if let Some(existing) = apps.get(&name) {
271                                // Preserve runtime state from existing entry
272                                app_info.blue.port = existing.blue.port;
273                                app_info.blue.pid = existing.blue.pid;
274                                app_info.blue.status = existing.blue.status.clone();
275                                app_info.blue.last_started = existing.blue.last_started.clone();
276                                app_info.green.port = existing.green.port;
277                                app_info.green.pid = existing.green.pid;
278                                app_info.green.status = existing.green.status.clone();
279                                app_info.green.last_started = existing.green.last_started.clone();
280                                app_info.current_slot = existing.current_slot.clone();
281                                tracing::debug!("Refreshed config for app: {}", name);
282                            } else {
283                                tracing::info!("Discovered new app: {}", name);
284                                // Allocate ports for new apps only
285                                match self
286                                    .port_allocator
287                                    .allocate(&app_info.config.name, "blue")
288                                    .await
289                                {
290                                    Ok(port) => app_info.blue.port = port,
291                                    Err(e) => tracing::error!(
292                                        "Failed to allocate blue port for {}: {}",
293                                        app_info.config.name,
294                                        e
295                                    ),
296                                }
297                                match self
298                                    .port_allocator
299                                    .allocate(&app_info.config.name, "green")
300                                    .await
301                                {
302                                    Ok(port) => app_info.green.port = port,
303                                    Err(e) => tracing::error!(
304                                        "Failed to allocate green port for {}: {}",
305                                        app_info.config.name,
306                                        e
307                                    ),
308                                }
309                                if app_info.config.start_script.is_some()
310                                    && !self.deployment_manager.is_deploying().await
311                                {
312                                    apps_to_start.push(name.clone());
313                                }
314                            }
315                            apps.insert(name, app_info);
316                        }
317                        Err(e) => {
318                            tracing::warn!("Failed to load app from {}: {}", path.display(), e);
319                        }
320                    }
321                }
322            }
323
324            // Remove apps that no longer exist on disk
325            apps.retain(|name, _| seen_names.contains(name));
326        }
327
328        // Auto-start discovered apps sequentially (deployment lock is global)
329        if !apps_to_start.is_empty() {
330            let manager = self.clone();
331            tokio::spawn(async move {
332                for app_name in apps_to_start {
333                    tracing::info!("Auto-starting app: {}", app_name);
334                    if let Err(e) = manager.deploy(&app_name, "blue").await {
335                        tracing::error!("Failed to auto-start {}: {}", app_name, e);
336                    }
337                }
338            });
339        }
340
341        self.sync_routes().await;
342        Ok(())
343    }
344
345    /// Synchronize proxy routes with discovered apps.
346    /// Adds Domain routes for apps that don't have one yet,
347    /// and removes orphaned auto-registered routes for apps that no longer exist.
348    async fn sync_routes(&self) {
349        let apps = self.apps.lock().await;
350        let cfg = self.config_manager.get_config();
351        let mut rules = cfg.rules.clone();
352        let global_scripts = cfg.global_scripts.clone();
353
354        // Collect domains from discovered apps
355        let mut app_domains: HashMap<String, u16> = HashMap::new();
356        for app in apps.values() {
357            if !app.config.domain.is_empty() {
358                let port = if app.current_slot == "blue" {
359                    app.blue.port
360                } else {
361                    app.green.port
362                };
363                app_domains.insert(app.config.domain.clone(), port);
364                // In dev mode, also register .dev alias
365                if self.dev_mode {
366                    if let Some(dev) = dev_domain(&app.config.domain) {
367                        app_domains.insert(dev, port);
368                    }
369                }
370            }
371        }
372
373        // Find existing Domain rules and their domains
374        let mut existing_domains: HashMap<String, usize> = HashMap::new();
375        for (i, rule) in rules.iter().enumerate() {
376            if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
377                existing_domains.insert(domain.clone(), i);
378            }
379        }
380
381        let mut changed = false;
382
383        // Add or update routes for discovered apps
384        for (domain, port) in &app_domains {
385            let target_url = format!("http://localhost:{}", port);
386            if let Some(&idx) = existing_domains.get(domain) {
387                // Route exists — update target if port changed
388                let current_target = rules[idx].targets.first().map(|t| t.url.to_string());
389                let expected = format!("{}/", target_url);
390                if current_target.as_deref() != Some(&expected) {
391                    if let Ok(url) = Url::parse(&target_url) {
392                        rules[idx].targets = vec![super::config::Target { url, weight: 100 }];
393                        changed = true;
394                        tracing::info!("Updated route for domain {} -> {}", domain, target_url);
395                    }
396                }
397            } else {
398                // No route for this domain — add one
399                if let Ok(url) = Url::parse(&target_url) {
400                    rules.push(super::config::ProxyRule {
401                        matcher: super::config::RuleMatcher::Domain(domain.clone()),
402                        targets: vec![super::config::Target { url, weight: 100 }],
403                        headers: vec![],
404                        scripts: vec![],
405                    });
406                    changed = true;
407                    tracing::info!("Added route for domain {} -> {}", domain, target_url);
408                }
409            }
410        }
411
412        // Remove orphaned Domain routes (domain not in any discovered app)
413        let mut indices_to_remove: Vec<usize> = Vec::new();
414        for (i, rule) in rules.iter().enumerate() {
415            if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
416                if !app_domains.contains_key(domain) {
417                    // Check if the target looks like an auto-registered localhost route
418                    let is_auto = rule
419                        .targets
420                        .iter()
421                        .all(|t| t.url.host_str() == Some("localhost"));
422                    if is_auto {
423                        indices_to_remove.push(i);
424                        tracing::info!("Removing orphaned route for domain {}", domain);
425                    }
426                }
427            }
428        }
429
430        // Remove in reverse order to preserve indices
431        for idx in indices_to_remove.into_iter().rev() {
432            rules.remove(idx);
433            changed = true;
434        }
435
436        if changed {
437            if let Err(e) = self.config_manager.update_rules(rules, global_scripts) {
438                tracing::error!("Failed to sync routes: {}", e);
439            }
440        }
441
442        // Trigger ACME cert issuance for ACME-eligible app domains
443        if let Some(ref acme) = *self.acme_service.lock().await {
444            for domain in app_domains.keys() {
445                if is_acme_eligible(domain) {
446                    let acme = acme.clone();
447                    let domain = domain.clone();
448                    tokio::spawn(async move {
449                        if let Err(e) = acme.ensure_certificate(&domain).await {
450                            tracing::error!("Failed to issue cert for {}: {}", domain, e);
451                        }
452                    });
453                }
454            }
455        }
456    }
457
458    pub async fn start_watcher(&self) -> Result<(), anyhow::Error> {
459        let (tx, mut rx) = mpsc::channel(100);
460        let sites_dir = self.sites_dir.clone();
461        let manager = self.clone();
462
463        let mut watcher = RecommendedWatcher::new(
464            move |res| {
465                let _ = tx.blocking_send(res);
466            },
467            notify::Config::default(),
468        )?;
469
470        watcher.watch(&sites_dir, RecursiveMode::Recursive)?;
471
472        *self.watcher.lock().await = Some(watcher);
473
474        tokio::spawn(async move {
475            loop {
476                // Wait for the first relevant event, collecting changed paths
477                let mut changed_paths: HashSet<PathBuf> = HashSet::new();
478                let mut got_event = false;
479                while let Some(res) = rx.recv().await {
480                    if let Ok(event) = res {
481                        if event.kind.is_modify()
482                            || event.kind.is_create()
483                            || event.kind.is_remove()
484                        {
485                            changed_paths.extend(event.paths);
486                            got_event = true;
487                            break;
488                        }
489                    }
490                }
491                if !got_event {
492                    break; // channel closed
493                }
494
495                // Debounce: drain any events arriving within 500ms, collecting paths
496                tokio::time::sleep(std::time::Duration::from_millis(500)).await;
497                while let Ok(res) = rx.try_recv() {
498                    if let Ok(event) = res {
499                        changed_paths.extend(event.paths);
500                    }
501                }
502
503                tracing::info!("Apps directory changed, rediscovering...");
504                if let Err(e) = manager.discover_apps().await {
505                    tracing::error!("Failed to rediscover apps: {}", e);
506                }
507
508                // In dev mode, restart affected apps that are currently running
509                if manager.dev_mode {
510                    let app_names = affected_app_names(&sites_dir, &changed_paths);
511                    if !app_names.is_empty() {
512                        let running_apps: Vec<String> = {
513                            let apps = manager.apps.lock().await;
514                            app_names
515                                .into_iter()
516                                .filter(|name| {
517                                    apps.get(name).is_some_and(|app| {
518                                        let instance = if app.current_slot == "blue" {
519                                            &app.blue
520                                        } else {
521                                            &app.green
522                                        };
523                                        instance.status == InstanceStatus::Running
524                                    })
525                                })
526                                .collect()
527                        };
528                        for app_name in running_apps {
529                            tracing::info!(
530                                "Dev mode: restarting app '{}' due to file changes",
531                                app_name
532                            );
533                            if let Err(e) = manager.restart(&app_name).await {
534                                tracing::error!("Failed to restart app '{}': {}", app_name, e);
535                            }
536                        }
537                    }
538                }
539            }
540        });
541
542        Ok(())
543    }
544
545    pub async fn list_apps(&self) -> Vec<AppInfo> {
546        self.apps
547            .lock()
548            .await
549            .values()
550            .filter(|&a| a.config.name != "_admin")
551            .cloned()
552            .collect()
553    }
554
555    pub async fn get_app(&self, name: &str) -> Option<AppInfo> {
556        self.apps.lock().await.get(name).cloned()
557    }
558
559    pub async fn get_app_name(&self, port: u16) -> Option<String> {
560        self.port_allocator.get_app_name(port).await
561    }
562
563    pub async fn allocate_ports(&self, app_name: &str) -> Result<(u16, u16), anyhow::Error> {
564        let blue_port = self.port_allocator.allocate(app_name, "blue").await?;
565        let green_port = self.port_allocator.allocate(app_name, "green").await?;
566        Ok((blue_port, green_port))
567    }
568
569    pub async fn deploy(&self, app_name: &str, slot: &str) -> Result<(), anyhow::Error> {
570        tracing::info!("Starting deploy for {} to slot {}", app_name, slot);
571
572        let app = {
573            let apps = self.apps.lock().await;
574            match apps.get(app_name) {
575                Some(app) => {
576                    tracing::debug!(
577                        "Found app {}: blue={}:{}, green={}:{}",
578                        app_name,
579                        app.blue.status,
580                        app.blue.port,
581                        app.green.status,
582                        app.green.port
583                    );
584                    app.clone()
585                }
586                None => {
587                    tracing::error!("App not found: {}", app_name);
588                    return Err(anyhow::anyhow!("App not found: {}", app_name));
589                }
590            }
591        };
592
593        tracing::info!("Deploying {} to slot {}", app.config.name, slot);
594        let pid = self.deployment_manager.deploy(&app, slot).await?;
595        tracing::info!("Deploy started, PID: {}", pid);
596
597        // Get the old slot name and PID before updating
598        let old_slot_name;
599        let old_pid;
600        {
601            let apps = self.apps.lock().await;
602            match apps.get(app_name) {
603                Some(a) => {
604                    old_slot_name = a.current_slot.clone();
605                    old_pid = if old_slot_name == "blue" {
606                        a.blue.pid
607                    } else {
608                        a.green.pid
609                    };
610                    tracing::info!(
611                        "Current slot: {}, old_slot_name: {}, old_pid: {:?}",
612                        app_name,
613                        old_slot_name,
614                        old_pid
615                    );
616                }
617                None => {
618                    old_slot_name = "unknown".to_string();
619                    old_pid = None;
620                    tracing::error!("App {} not found in apps map!", app_name);
621                }
622            }
623        }
624
625        // Update app info: mark new slot as running, store PID, and switch traffic
626        {
627            let mut apps = self.apps.lock().await;
628            if let Some(app_info) = apps.get_mut(app_name) {
629                let instance = if slot == "blue" {
630                    &mut app_info.blue
631                } else {
632                    &mut app_info.green
633                };
634                instance.status = InstanceStatus::Running;
635                instance.pid = Some(pid);
636                instance.last_started = Some(chrono::Utc::now().to_rfc3339());
637
638                // Switch traffic
639                app_info.current_slot = slot.to_string();
640                tracing::info!("Switched traffic from {} to {}", old_slot_name, slot);
641            } else {
642                tracing::error!("App {} not found in map after deploy!", app_name);
643            }
644        }
645
646        // Stop the old slot if it was running
647        tracing::info!(
648            "Checking if should stop old slot: old_slot_name={}, slot={}",
649            old_slot_name,
650            slot
651        );
652        if old_slot_name != "unknown" && old_slot_name != slot {
653            if let Some(pid) = old_pid {
654                tracing::info!("Stopping old slot {} (PID: {})", old_slot_name, pid);
655                self.deployment_manager
656                    .stop_instance(&app, &old_slot_name)
657                    .await?;
658                tracing::info!("Old slot {} stopped", old_slot_name);
659
660                // Update old slot status to Stopped
661                let mut apps = self.apps.lock().await;
662                if let Some(app_info) = apps.get_mut(app_name) {
663                    let old_instance = if old_slot_name == "blue" {
664                        &mut app_info.blue
665                    } else {
666                        &mut app_info.green
667                    };
668                    old_instance.status = InstanceStatus::Stopped;
669                    old_instance.pid = None;
670                }
671            } else {
672                tracing::warn!(
673                    "No PID found for old slot {} (status may already be stopped)",
674                    old_slot_name
675                );
676            }
677        }
678
679        self.sync_routes().await;
680        tracing::info!("Deploy completed for {} to slot {}", app_name, slot);
681        Ok(())
682    }
683
684    pub async fn restart(&self, app_name: &str) -> Result<(), anyhow::Error> {
685        let slot = {
686            let apps = self.apps.lock().await;
687            let app = apps
688                .get(app_name)
689                .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?;
690            app.current_slot.clone()
691        };
692
693        self.stop(app_name).await?;
694        self.deploy(app_name, &slot).await
695    }
696
697    pub async fn rollback(&self, app_name: &str) -> Result<(), anyhow::Error> {
698        let (app, target_slot, old_slot) = {
699            let apps = self.apps.lock().await;
700            let app = apps
701                .get(app_name)
702                .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
703                .clone();
704            let target_slot = if app.current_slot == "blue" {
705                "green"
706            } else {
707                "blue"
708            };
709            (
710                app.clone(),
711                target_slot.to_string(),
712                app.current_slot.clone(),
713            )
714        };
715
716        let pid = self.deployment_manager.deploy(&app, &target_slot).await?;
717
718        {
719            let mut apps = self.apps.lock().await;
720            if let Some(app_info) = apps.get_mut(app_name) {
721                app_info.current_slot = target_slot.clone();
722                let instance = if target_slot == "blue" {
723                    &mut app_info.blue
724                } else {
725                    &mut app_info.green
726                };
727                instance.status = InstanceStatus::Running;
728                instance.pid = Some(pid);
729            }
730        }
731
732        // Stop the old slot
733        let old_pid = {
734            let apps = self.apps.lock().await;
735            apps.get(app_name).and_then(|a| {
736                if old_slot == "blue" {
737                    a.blue.pid
738                } else {
739                    a.green.pid
740                }
741            })
742        };
743        if let Some(pid) = old_pid {
744            tracing::info!(
745                "Stopping old slot {} (PID: {}) during rollback",
746                old_slot,
747                pid
748            );
749            self.deployment_manager
750                .stop_instance(&app, &old_slot)
751                .await?;
752            // Update old slot status
753            let mut apps = self.apps.lock().await;
754            if let Some(app_info) = apps.get_mut(app_name) {
755                let old_instance = if old_slot == "blue" {
756                    &mut app_info.blue
757                } else {
758                    &mut app_info.green
759                };
760                old_instance.status = InstanceStatus::Stopped;
761                old_instance.pid = None;
762            }
763        }
764
765        self.sync_routes().await;
766        Ok(())
767    }
768
769    pub async fn stop(&self, app_name: &str) -> Result<(), anyhow::Error> {
770        let (app, slot) = {
771            let apps = self.apps.lock().await;
772            let app = apps
773                .get(app_name)
774                .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
775                .clone();
776            let slot = app.current_slot.clone();
777            (app, slot)
778        };
779
780        self.deployment_manager.stop_instance(&app, &slot).await?;
781
782        {
783            let mut apps = self.apps.lock().await;
784            if let Some(app_info) = apps.get_mut(app_name) {
785                let instance = if slot == "blue" {
786                    &mut app_info.blue
787                } else {
788                    &mut app_info.green
789                };
790                instance.status = InstanceStatus::Stopped;
791                instance.pid = None;
792            }
793        }
794
795        Ok(())
796    }
797
798    pub async fn stop_all(&self) {
799        let apps: Vec<String> = {
800            let apps_guard = self.apps.lock().await;
801            apps_guard.keys().cloned().collect()
802        };
803
804        for app_name in apps {
805            // Stop both blue and green slots
806            let app = {
807                let apps_guard = self.apps.lock().await;
808                apps_guard.get(&app_name).cloned()
809            };
810            if let Some(app) = app {
811                // Stop blue slot
812                if app.blue.status == InstanceStatus::Running && app.blue.pid.is_some() {
813                    if let Err(e) = self.deployment_manager.stop_instance(&app, "blue").await {
814                        tracing::error!("Failed to stop blue slot for {}: {}", app_name, e);
815                    }
816                }
817                // Stop green slot
818                if app.green.status == InstanceStatus::Running && app.green.pid.is_some() {
819                    if let Err(e) = self.deployment_manager.stop_instance(&app, "green").await {
820                        tracing::error!("Failed to stop green slot for {}: {}", app_name, e);
821                    }
822                }
823                // Update status in map
824                let mut apps_guard = self.apps.lock().await;
825                if let Some(app_info) = apps_guard.get_mut(&app_name) {
826                    app_info.blue.status = InstanceStatus::Stopped;
827                    app_info.blue.pid = None;
828                    app_info.green.status = InstanceStatus::Stopped;
829                    app_info.green.pid = None;
830                }
831            }
832        }
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use tempfile::TempDir;
840
841    #[tokio::test]
842    async fn test_app_info_parsing() {
843        let temp_dir = TempDir::new().unwrap();
844        let app_path = temp_dir.path().join("test.solisoft.net");
845        std::fs::create_dir_all(&app_path).unwrap();
846
847        let app_infos = r#"
848name = "test.solisoft.net"
849domain = "test.solisoft.net"
850start_script = "./start.sh"
851stop_script = "./stop.sh"
852health_check = "/health"
853graceful_timeout = 30
854port_range_start = 9000
855port_range_end = 9999
856"#;
857        std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
858
859        let app_info = AppInfo::from_path(&app_path).unwrap();
860        assert_eq!(app_info.config.name, "test.solisoft.net");
861        assert_eq!(app_info.config.domain, "test.solisoft.net");
862        assert_eq!(app_info.config.start_script, Some("./start.sh".to_string()));
863    }
864
865    #[test]
866    fn test_dev_domain() {
867        assert_eq!(
868            dev_domain("soli.solisoft.net"),
869            Some("soli.solisoft.test".to_string())
870        );
871        assert_eq!(
872            dev_domain("app.example.com"),
873            Some("app.example.test".to_string())
874        );
875        assert_eq!(dev_domain("example.org"), Some("example.test".to_string()));
876        // Already .test — skip
877        assert_eq!(dev_domain("app.example.test"), None);
878        // .localhost — skip
879        assert_eq!(dev_domain("app.localhost"), None);
880        // No dot at all
881        assert_eq!(dev_domain("localhost"), None);
882    }
883
884    #[test]
885    fn test_is_acme_eligible_excludes_dev() {
886        assert!(!is_acme_eligible("app.example.test"));
887        assert!(!is_acme_eligible("localhost"));
888        assert!(!is_acme_eligible("app.localhost"));
889        assert!(is_acme_eligible("app.example.com"));
890    }
891
892    #[test]
893    fn test_luaonbeans_auto_detected_no_app_infos() {
894        let temp_dir = TempDir::new().unwrap();
895        let app_path = temp_dir.path().join("myapp.example.com");
896        std::fs::create_dir_all(&app_path).unwrap();
897        std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
898
899        let app_info = AppInfo::from_path(&app_path).unwrap();
900        assert_eq!(app_info.config.name, "myapp.example.com");
901        assert_eq!(app_info.config.domain, "myapp.example.com");
902        assert_eq!(
903            app_info.config.start_script,
904            Some("./luaonbeans.org -D . -p $PORT -s".to_string())
905        );
906        assert_eq!(app_info.config.health_check, Some("/".to_string()));
907    }
908
909    #[test]
910    fn test_luaonbeans_auto_detected_with_partial_app_infos() {
911        let temp_dir = TempDir::new().unwrap();
912        let app_path = temp_dir.path().join("myapp");
913        std::fs::create_dir_all(&app_path).unwrap();
914        std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
915
916        let app_infos = r#"
917name = "myapp"
918domain = "custom.example.com"
919graceful_timeout = 30
920port_range_start = 9000
921port_range_end = 9999
922"#;
923        std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
924
925        let app_info = AppInfo::from_path(&app_path).unwrap();
926        assert_eq!(app_info.config.name, "myapp");
927        assert_eq!(app_info.config.domain, "custom.example.com");
928        assert_eq!(
929            app_info.config.start_script,
930            Some("./luaonbeans.org -D . -p $PORT -s".to_string())
931        );
932        assert_eq!(app_info.config.health_check, Some("/".to_string()));
933    }
934
935    #[test]
936    fn test_no_override_when_start_script_set() {
937        let temp_dir = TempDir::new().unwrap();
938        let app_path = temp_dir.path().join("myapp");
939        std::fs::create_dir_all(&app_path).unwrap();
940        std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
941
942        let app_infos = r#"
943name = "myapp"
944domain = "myapp.example.com"
945start_script = "./custom-start.sh"
946health_check = "/health"
947graceful_timeout = 30
948port_range_start = 9000
949port_range_end = 9999
950"#;
951        std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
952
953        let app_info = AppInfo::from_path(&app_path).unwrap();
954        assert_eq!(
955            app_info.config.start_script,
956            Some("./custom-start.sh".to_string())
957        );
958        assert_eq!(app_info.config.health_check, Some("/health".to_string()));
959    }
960
961    #[test]
962    fn test_no_detection_without_luaonbeans_or_app_infos() {
963        let temp_dir = TempDir::new().unwrap();
964        let app_path = temp_dir.path().join("emptyapp");
965        std::fs::create_dir_all(&app_path).unwrap();
966
967        let app_info = AppInfo::from_path(&app_path).unwrap();
968        assert_eq!(app_info.config.name, "emptyapp");
969        assert!(app_info.config.start_script.is_none());
970        assert_eq!(app_info.config.health_check, Some("/health".to_string()));
971    }
972}