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