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