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                        auth: vec![],
406                    });
407                    changed = true;
408                    tracing::info!("Added route for domain {} -> {}", domain, target_url);
409                }
410            }
411        }
412
413        // Remove orphaned Domain routes (domain not in any discovered app)
414        let mut indices_to_remove: Vec<usize> = Vec::new();
415        for (i, rule) in rules.iter().enumerate() {
416            if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
417                if !app_domains.contains_key(domain) {
418                    // Check if the target looks like an auto-registered localhost route
419                    let is_auto = rule
420                        .targets
421                        .iter()
422                        .all(|t| t.url.host_str() == Some("localhost"));
423                    if is_auto {
424                        indices_to_remove.push(i);
425                        tracing::info!("Removing orphaned route for domain {}", domain);
426                    }
427                }
428            }
429        }
430
431        // Remove in reverse order to preserve indices
432        for idx in indices_to_remove.into_iter().rev() {
433            rules.remove(idx);
434            changed = true;
435        }
436
437        if changed {
438            if let Err(e) = self.config_manager.update_rules(rules, global_scripts) {
439                tracing::error!("Failed to sync routes: {}", e);
440            }
441        }
442
443        // Trigger ACME cert issuance for ACME-eligible app domains
444        if let Some(ref acme) = *self.acme_service.lock().await {
445            for domain in app_domains.keys() {
446                if is_acme_eligible(domain) {
447                    let acme = acme.clone();
448                    let domain = domain.clone();
449                    tokio::spawn(async move {
450                        if let Err(e) = acme.ensure_certificate(&domain).await {
451                            tracing::error!("Failed to issue cert for {}: {}", domain, e);
452                        }
453                    });
454                }
455            }
456        }
457    }
458
459    pub async fn start_watcher(&self) -> Result<(), anyhow::Error> {
460        let (tx, mut rx) = mpsc::channel(100);
461        let sites_dir = self.sites_dir.clone();
462        let manager = self.clone();
463
464        let mut watcher = RecommendedWatcher::new(
465            move |res| {
466                let _ = tx.blocking_send(res);
467            },
468            notify::Config::default(),
469        )?;
470
471        watcher.watch(&sites_dir, RecursiveMode::Recursive)?;
472
473        *self.watcher.lock().await = Some(watcher);
474
475        tokio::spawn(async move {
476            loop {
477                // Wait for the first relevant event, collecting changed paths
478                let mut changed_paths: HashSet<PathBuf> = HashSet::new();
479                let mut got_event = false;
480                while let Some(res) = rx.recv().await {
481                    if let Ok(event) = res {
482                        if event.kind.is_modify()
483                            || event.kind.is_create()
484                            || event.kind.is_remove()
485                        {
486                            changed_paths.extend(event.paths);
487                            got_event = true;
488                            break;
489                        }
490                    }
491                }
492                if !got_event {
493                    break; // channel closed
494                }
495
496                // Debounce: drain any events arriving within 500ms, collecting paths
497                tokio::time::sleep(std::time::Duration::from_millis(500)).await;
498                while let Ok(res) = rx.try_recv() {
499                    if let Ok(event) = res {
500                        changed_paths.extend(event.paths);
501                    }
502                }
503
504                tracing::info!("Apps directory changed, rediscovering...");
505                if let Err(e) = manager.discover_apps().await {
506                    tracing::error!("Failed to rediscover apps: {}", e);
507                }
508
509                // In dev mode, restart affected apps that are currently running
510                if manager.dev_mode {
511                    let app_names = affected_app_names(&sites_dir, &changed_paths);
512                    if !app_names.is_empty() {
513                        let running_apps: Vec<String> = {
514                            let apps = manager.apps.lock().await;
515                            app_names
516                                .into_iter()
517                                .filter(|name| {
518                                    apps.get(name).is_some_and(|app| {
519                                        let instance = if app.current_slot == "blue" {
520                                            &app.blue
521                                        } else {
522                                            &app.green
523                                        };
524                                        instance.status == InstanceStatus::Running
525                                    })
526                                })
527                                .collect()
528                        };
529                        for app_name in running_apps {
530                            tracing::info!(
531                                "Dev mode: restarting app '{}' due to file changes",
532                                app_name
533                            );
534                            if let Err(e) = manager.restart(&app_name).await {
535                                tracing::error!("Failed to restart app '{}': {}", app_name, e);
536                            }
537                        }
538                    }
539                }
540            }
541        });
542
543        Ok(())
544    }
545
546    pub async fn list_apps(&self) -> Vec<AppInfo> {
547        self.apps
548            .lock()
549            .await
550            .values()
551            .filter(|&a| a.config.name != "_admin")
552            .cloned()
553            .collect()
554    }
555
556    pub async fn get_app(&self, name: &str) -> Option<AppInfo> {
557        self.apps.lock().await.get(name).cloned()
558    }
559
560    pub async fn get_app_name(&self, port: u16) -> Option<String> {
561        self.port_allocator.get_app_name(port).await
562    }
563
564    pub async fn allocate_ports(&self, app_name: &str) -> Result<(u16, u16), anyhow::Error> {
565        let blue_port = self.port_allocator.allocate(app_name, "blue").await?;
566        let green_port = self.port_allocator.allocate(app_name, "green").await?;
567        Ok((blue_port, green_port))
568    }
569
570    pub async fn deploy(&self, app_name: &str, slot: &str) -> Result<(), anyhow::Error> {
571        tracing::info!("Starting deploy for {} to slot {}", app_name, slot);
572
573        let app = {
574            let apps = self.apps.lock().await;
575            match apps.get(app_name) {
576                Some(app) => {
577                    tracing::debug!(
578                        "Found app {}: blue={}:{}, green={}:{}",
579                        app_name,
580                        app.blue.status,
581                        app.blue.port,
582                        app.green.status,
583                        app.green.port
584                    );
585                    app.clone()
586                }
587                None => {
588                    tracing::error!("App not found: {}", app_name);
589                    return Err(anyhow::anyhow!("App not found: {}", app_name));
590                }
591            }
592        };
593
594        tracing::info!("Deploying {} to slot {}", app.config.name, slot);
595        let pid = self.deployment_manager.deploy(&app, slot).await?;
596        tracing::info!("Deploy started, PID: {}", pid);
597
598        // Get the old slot name and PID before updating
599        let old_slot_name;
600        let old_pid;
601        {
602            let apps = self.apps.lock().await;
603            match apps.get(app_name) {
604                Some(a) => {
605                    old_slot_name = a.current_slot.clone();
606                    old_pid = if old_slot_name == "blue" {
607                        a.blue.pid
608                    } else {
609                        a.green.pid
610                    };
611                    tracing::info!(
612                        "Current slot: {}, old_slot_name: {}, old_pid: {:?}",
613                        app_name,
614                        old_slot_name,
615                        old_pid
616                    );
617                }
618                None => {
619                    old_slot_name = "unknown".to_string();
620                    old_pid = None;
621                    tracing::error!("App {} not found in apps map!", app_name);
622                }
623            }
624        }
625
626        // Update app info: mark new slot as running, store PID, and switch traffic
627        {
628            let mut apps = self.apps.lock().await;
629            if let Some(app_info) = apps.get_mut(app_name) {
630                let instance = if slot == "blue" {
631                    &mut app_info.blue
632                } else {
633                    &mut app_info.green
634                };
635                instance.status = InstanceStatus::Running;
636                instance.pid = Some(pid);
637                instance.last_started = Some(chrono::Utc::now().to_rfc3339());
638
639                // Switch traffic
640                app_info.current_slot = slot.to_string();
641                tracing::info!("Switched traffic from {} to {}", old_slot_name, slot);
642            } else {
643                tracing::error!("App {} not found in map after deploy!", app_name);
644            }
645        }
646
647        // Stop the old slot if it was running
648        tracing::info!(
649            "Checking if should stop old slot: old_slot_name={}, slot={}",
650            old_slot_name,
651            slot
652        );
653        if old_slot_name != "unknown" && old_slot_name != slot {
654            if let Some(pid) = old_pid {
655                tracing::info!("Stopping old slot {} (PID: {})", old_slot_name, pid);
656                self.deployment_manager
657                    .stop_instance(&app, &old_slot_name)
658                    .await?;
659                tracing::info!("Old slot {} stopped", old_slot_name);
660
661                // Update old slot status to Stopped
662                let mut apps = self.apps.lock().await;
663                if let Some(app_info) = apps.get_mut(app_name) {
664                    let old_instance = if old_slot_name == "blue" {
665                        &mut app_info.blue
666                    } else {
667                        &mut app_info.green
668                    };
669                    old_instance.status = InstanceStatus::Stopped;
670                    old_instance.pid = None;
671                }
672            } else {
673                tracing::warn!(
674                    "No PID found for old slot {} (status may already be stopped)",
675                    old_slot_name
676                );
677            }
678        }
679
680        self.sync_routes().await;
681        tracing::info!("Deploy completed for {} to slot {}", app_name, slot);
682        Ok(())
683    }
684
685    pub async fn restart(&self, app_name: &str) -> Result<(), anyhow::Error> {
686        let slot = {
687            let apps = self.apps.lock().await;
688            let app = apps
689                .get(app_name)
690                .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?;
691            app.current_slot.clone()
692        };
693
694        self.stop(app_name).await?;
695        self.deploy(app_name, &slot).await
696    }
697
698    pub async fn rollback(&self, app_name: &str) -> Result<(), anyhow::Error> {
699        let (app, target_slot, old_slot) = {
700            let apps = self.apps.lock().await;
701            let app = apps
702                .get(app_name)
703                .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
704                .clone();
705            let target_slot = if app.current_slot == "blue" {
706                "green"
707            } else {
708                "blue"
709            };
710            (
711                app.clone(),
712                target_slot.to_string(),
713                app.current_slot.clone(),
714            )
715        };
716
717        let pid = self.deployment_manager.deploy(&app, &target_slot).await?;
718
719        {
720            let mut apps = self.apps.lock().await;
721            if let Some(app_info) = apps.get_mut(app_name) {
722                app_info.current_slot = target_slot.clone();
723                let instance = if target_slot == "blue" {
724                    &mut app_info.blue
725                } else {
726                    &mut app_info.green
727                };
728                instance.status = InstanceStatus::Running;
729                instance.pid = Some(pid);
730            }
731        }
732
733        // Stop the old slot
734        let old_pid = {
735            let apps = self.apps.lock().await;
736            apps.get(app_name).and_then(|a| {
737                if old_slot == "blue" {
738                    a.blue.pid
739                } else {
740                    a.green.pid
741                }
742            })
743        };
744        if let Some(pid) = old_pid {
745            tracing::info!(
746                "Stopping old slot {} (PID: {}) during rollback",
747                old_slot,
748                pid
749            );
750            self.deployment_manager
751                .stop_instance(&app, &old_slot)
752                .await?;
753            // Update old slot status
754            let mut apps = self.apps.lock().await;
755            if let Some(app_info) = apps.get_mut(app_name) {
756                let old_instance = if old_slot == "blue" {
757                    &mut app_info.blue
758                } else {
759                    &mut app_info.green
760                };
761                old_instance.status = InstanceStatus::Stopped;
762                old_instance.pid = None;
763            }
764        }
765
766        self.sync_routes().await;
767        Ok(())
768    }
769
770    pub async fn stop(&self, app_name: &str) -> Result<(), anyhow::Error> {
771        let (app, slot) = {
772            let apps = self.apps.lock().await;
773            let app = apps
774                .get(app_name)
775                .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
776                .clone();
777            let slot = app.current_slot.clone();
778            (app, slot)
779        };
780
781        self.deployment_manager.stop_instance(&app, &slot).await?;
782
783        {
784            let mut apps = self.apps.lock().await;
785            if let Some(app_info) = apps.get_mut(app_name) {
786                let instance = if slot == "blue" {
787                    &mut app_info.blue
788                } else {
789                    &mut app_info.green
790                };
791                instance.status = InstanceStatus::Stopped;
792                instance.pid = None;
793            }
794        }
795
796        Ok(())
797    }
798
799    pub async fn stop_all(&self) {
800        let apps: Vec<String> = {
801            let apps_guard = self.apps.lock().await;
802            apps_guard.keys().cloned().collect()
803        };
804
805        for app_name in apps {
806            // Stop both blue and green slots
807            let app = {
808                let apps_guard = self.apps.lock().await;
809                apps_guard.get(&app_name).cloned()
810            };
811            if let Some(app) = app {
812                // Stop blue slot
813                if app.blue.status == InstanceStatus::Running && app.blue.pid.is_some() {
814                    if let Err(e) = self.deployment_manager.stop_instance(&app, "blue").await {
815                        tracing::error!("Failed to stop blue slot for {}: {}", app_name, e);
816                    }
817                }
818                // Stop green slot
819                if app.green.status == InstanceStatus::Running && app.green.pid.is_some() {
820                    if let Err(e) = self.deployment_manager.stop_instance(&app, "green").await {
821                        tracing::error!("Failed to stop green slot for {}: {}", app_name, e);
822                    }
823                }
824                // Update status in map
825                let mut apps_guard = self.apps.lock().await;
826                if let Some(app_info) = apps_guard.get_mut(&app_name) {
827                    app_info.blue.status = InstanceStatus::Stopped;
828                    app_info.blue.pid = None;
829                    app_info.green.status = InstanceStatus::Stopped;
830                    app_info.green.pid = None;
831                }
832            }
833        }
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use tempfile::TempDir;
841
842    #[tokio::test]
843    async fn test_app_info_parsing() {
844        let temp_dir = TempDir::new().unwrap();
845        let app_path = temp_dir.path().join("test.solisoft.net");
846        std::fs::create_dir_all(&app_path).unwrap();
847
848        let app_infos = r#"
849name = "test.solisoft.net"
850domain = "test.solisoft.net"
851start_script = "./start.sh"
852stop_script = "./stop.sh"
853health_check = "/health"
854graceful_timeout = 30
855port_range_start = 9000
856port_range_end = 9999
857"#;
858        std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
859
860        let app_info = AppInfo::from_path(&app_path).unwrap();
861        assert_eq!(app_info.config.name, "test.solisoft.net");
862        assert_eq!(app_info.config.domain, "test.solisoft.net");
863        assert_eq!(app_info.config.start_script, Some("./start.sh".to_string()));
864    }
865
866    #[test]
867    fn test_dev_domain() {
868        assert_eq!(
869            dev_domain("soli.solisoft.net"),
870            Some("soli.solisoft.test".to_string())
871        );
872        assert_eq!(
873            dev_domain("app.example.com"),
874            Some("app.example.test".to_string())
875        );
876        assert_eq!(dev_domain("example.org"), Some("example.test".to_string()));
877        // Already .test — skip
878        assert_eq!(dev_domain("app.example.test"), None);
879        // .localhost — skip
880        assert_eq!(dev_domain("app.localhost"), None);
881        // No dot at all
882        assert_eq!(dev_domain("localhost"), None);
883    }
884
885    #[test]
886    fn test_is_acme_eligible_excludes_dev() {
887        assert!(!is_acme_eligible("app.example.test"));
888        assert!(!is_acme_eligible("localhost"));
889        assert!(!is_acme_eligible("app.localhost"));
890        assert!(is_acme_eligible("app.example.com"));
891    }
892
893    #[test]
894    fn test_luaonbeans_auto_detected_no_app_infos() {
895        let temp_dir = TempDir::new().unwrap();
896        let app_path = temp_dir.path().join("myapp.example.com");
897        std::fs::create_dir_all(&app_path).unwrap();
898        std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
899
900        let app_info = AppInfo::from_path(&app_path).unwrap();
901        assert_eq!(app_info.config.name, "myapp.example.com");
902        assert_eq!(app_info.config.domain, "myapp.example.com");
903        assert_eq!(
904            app_info.config.start_script,
905            Some("./luaonbeans.org -D . -p $PORT -s".to_string())
906        );
907        assert_eq!(app_info.config.health_check, Some("/".to_string()));
908    }
909
910    #[test]
911    fn test_luaonbeans_auto_detected_with_partial_app_infos() {
912        let temp_dir = TempDir::new().unwrap();
913        let app_path = temp_dir.path().join("myapp");
914        std::fs::create_dir_all(&app_path).unwrap();
915        std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
916
917        let app_infos = r#"
918name = "myapp"
919domain = "custom.example.com"
920graceful_timeout = 30
921port_range_start = 9000
922port_range_end = 9999
923"#;
924        std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
925
926        let app_info = AppInfo::from_path(&app_path).unwrap();
927        assert_eq!(app_info.config.name, "myapp");
928        assert_eq!(app_info.config.domain, "custom.example.com");
929        assert_eq!(
930            app_info.config.start_script,
931            Some("./luaonbeans.org -D . -p $PORT -s".to_string())
932        );
933        assert_eq!(app_info.config.health_check, Some("/".to_string()));
934    }
935
936    #[test]
937    fn test_no_override_when_start_script_set() {
938        let temp_dir = TempDir::new().unwrap();
939        let app_path = temp_dir.path().join("myapp");
940        std::fs::create_dir_all(&app_path).unwrap();
941        std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
942
943        let app_infos = r#"
944name = "myapp"
945domain = "myapp.example.com"
946start_script = "./custom-start.sh"
947health_check = "/health"
948graceful_timeout = 30
949port_range_start = 9000
950port_range_end = 9999
951"#;
952        std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
953
954        let app_info = AppInfo::from_path(&app_path).unwrap();
955        assert_eq!(
956            app_info.config.start_script,
957            Some("./custom-start.sh".to_string())
958        );
959        assert_eq!(app_info.config.health_check, Some("/health".to_string()));
960    }
961
962    #[test]
963    fn test_no_detection_without_luaonbeans_or_app_infos() {
964        let temp_dir = TempDir::new().unwrap();
965        let app_path = temp_dir.path().join("emptyapp");
966        std::fs::create_dir_all(&app_path).unwrap();
967
968        let app_info = AppInfo::from_path(&app_path).unwrap();
969        assert_eq!(app_info.config.name, "emptyapp");
970        assert!(app_info.config.start_script.is_none());
971        assert_eq!(app_info.config.health_check, Some("/health".to_string()));
972    }
973}