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