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 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 if config.name.is_empty() {
123 config.name = app_name.clone();
124 }
125
126 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 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
191fn 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
201fn 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
210fn is_valid_domain(name: &str) -> bool {
212 !name.is_empty() && (!name.starts_with('.') && (name.contains('.') || name.starts_with('_')))
213}
214
215fn 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
225fn 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 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 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 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 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 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 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 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 apps.retain(|name, _| seen_names.contains(name));
454 }
455
456 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 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 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 let Some(non_www) = strip_www(&app.config.domain) {
501 app_domains.insert(non_www, port);
502 }
503 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 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 for (domain, port) in &app_domains {
524 let target_url = format!("http://localhost:{}", port);
525 if let Some(&idx) = existing_domains.get(domain) {
526 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 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 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 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 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 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 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; }
641
642 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 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 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 {
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 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 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 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 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 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 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 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 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 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 assert_eq!(dev_domain("app.example.test"), None);
1145 assert_eq!(dev_domain("app.localhost"), None);
1147 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}