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