1use notify::{RecommendedWatcher, RecursiveMode, Watcher};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use tokio::sync::mpsc;
7use tokio::sync::Mutex;
8use url::Url;
9
10pub mod deployment;
11pub mod port_manager;
12
13pub use deployment::{DeploymentManager, DeploymentStatus};
14pub use port_manager::{PortAllocator, PortManager};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AppConfig {
18 pub name: String,
19 pub domain: String,
20 pub start_script: Option<String>,
21 pub stop_script: Option<String>,
22 pub health_check: Option<String>,
23 pub graceful_timeout: u32,
24 pub port_range_start: u16,
25 pub port_range_end: u16,
26 #[serde(default = "default_workers")]
27 pub workers: u16,
28 #[serde(default)]
29 pub user: Option<String>,
30 #[serde(default)]
31 pub group: Option<String>,
32}
33
34fn default_workers() -> u16 {
35 1
36}
37
38impl Default for AppConfig {
39 fn default() -> Self {
40 Self {
41 name: String::new(),
42 domain: String::new(),
43 start_script: None,
44 stop_script: None,
45 health_check: Some("/health".to_string()),
46 graceful_timeout: 30,
47 port_range_start: 9000,
48 port_range_end: 9999,
49 workers: 1,
50 user: None,
51 group: None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct AppInstance {
58 pub name: String,
59 pub slot: String,
60 pub port: u16,
61 pub pid: Option<u32>,
62 pub status: InstanceStatus,
63 pub last_started: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub enum InstanceStatus {
68 Stopped,
69 Starting,
70 Running,
71 Unhealthy,
72 Failed,
73}
74
75impl std::fmt::Display for InstanceStatus {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 InstanceStatus::Stopped => write!(f, "Stopped"),
79 InstanceStatus::Starting => write!(f, "Starting"),
80 InstanceStatus::Running => write!(f, "Running"),
81 InstanceStatus::Unhealthy => write!(f, "Unhealthy"),
82 InstanceStatus::Failed => write!(f, "Failed"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct AppInfo {
89 pub config: AppConfig,
90 pub path: PathBuf,
91 pub blue: AppInstance,
92 pub green: AppInstance,
93 pub current_slot: String,
94}
95
96impl AppInfo {
97 pub fn from_path(path: &std::path::Path) -> Result<Self, anyhow::Error> {
98 let app_infos_path = path.join("app.infos");
99
100 let mut config = if app_infos_path.exists() {
101 let content = std::fs::read_to_string(&app_infos_path)?;
102 toml::from_str(&content)?
103 } else {
104 AppConfig::default()
105 };
106
107 let app_name = path
108 .file_name()
109 .and_then(|n| n.to_str())
110 .unwrap_or_default()
111 .to_string();
112
113 if config.name.is_empty() {
115 config.name = app_name.clone();
116 }
117
118 if config.start_script.is_none() && path.join("luaonbeans.org").exists() {
120 config.start_script = Some("./luaonbeans.org -D . -p $PORT -s".to_string());
121 config.health_check = Some("/".to_string());
122 if config.domain.is_empty() {
123 config.domain = app_name.clone();
124 }
125 }
126
127 Ok(Self {
128 config,
129 path: path.to_path_buf(),
130 blue: AppInstance {
131 name: app_name.clone(),
132 slot: "blue".to_string(),
133 port: 0,
134 pid: None,
135 status: InstanceStatus::Stopped,
136 last_started: None,
137 },
138 green: AppInstance {
139 name: app_name.clone(),
140 slot: "green".to_string(),
141 port: 0,
142 pid: None,
143 status: InstanceStatus::Stopped,
144 last_started: None,
145 },
146 current_slot: "blue".to_string(),
147 })
148 }
149}
150
151#[derive(Clone)]
152pub struct AppManager {
153 sites_dir: PathBuf,
154 port_allocator: Arc<PortManager>,
155 apps: Arc<Mutex<HashMap<String, AppInfo>>>,
156 config_manager: Arc<dyn super::config::ConfigManagerTrait + Send + Sync>,
157 pub deployment_manager: Arc<DeploymentManager>,
158 watcher: Arc<Mutex<Option<RecommendedWatcher>>>,
159 acme_service: Arc<Mutex<Option<Arc<crate::acme::AcmeService>>>>,
160 dev_mode: bool,
161}
162
163fn dev_domain(domain: &str) -> Option<String> {
166 if domain.ends_with(".test") || domain.ends_with(".localhost") {
167 return None;
168 }
169 let dot = domain.rfind('.')?;
170 Some(format!("{}.test", &domain[..dot]))
171}
172
173fn is_acme_eligible(domain: &str) -> bool {
176 domain != "localhost"
177 && !domain.ends_with(".localhost")
178 && !domain.ends_with(".test")
179 && domain.parse::<std::net::IpAddr>().is_err()
180}
181
182fn affected_app_names(sites_dir: &Path, paths: &HashSet<PathBuf>) -> HashSet<String> {
185 const IGNORED_SEGMENTS: &[&str] = &["node_modules", ".git", "tmp", "target"];
186
187 let mut names = HashSet::new();
188 for path in paths {
189 let relative = match path.strip_prefix(sites_dir) {
190 Ok(r) => r,
191 Err(_) => continue,
192 };
193
194 let skip = relative.components().any(|c| {
196 if let std::path::Component::Normal(s) = c {
197 IGNORED_SEGMENTS
198 .iter()
199 .any(|ignored| s.to_str() == Some(*ignored))
200 } else {
201 false
202 }
203 });
204 if skip {
205 continue;
206 }
207
208 if relative.components().count() == 2 {
210 if let Some(filename) = relative.file_name() {
211 if filename == "app.infos" {
212 continue;
213 }
214 }
215 }
216
217 if let Some(std::path::Component::Normal(app_dir)) = relative.components().next() {
219 if let Some(name) = app_dir.to_str() {
220 names.insert(name.to_string());
221 }
222 }
223 }
224 names
225}
226
227impl AppManager {
228 pub fn new(
229 sites_dir: &str,
230 port_allocator: Arc<PortManager>,
231 config_manager: Arc<dyn super::config::ConfigManagerTrait + Send + Sync>,
232 dev_mode: bool,
233 ) -> Result<Self, anyhow::Error> {
234 let sites_path = PathBuf::from(sites_dir);
235 if !sites_path.exists() {
236 std::fs::create_dir_all(&sites_path)?;
237 }
238
239 let deployment_manager = Arc::new(DeploymentManager::new(dev_mode));
240
241 Ok(Self {
242 sites_dir: sites_path,
243 port_allocator,
244 apps: Arc::new(Mutex::new(HashMap::new())),
245 config_manager,
246 deployment_manager,
247 watcher: Arc::new(Mutex::new(None)),
248 acme_service: Arc::new(Mutex::new(None)),
249 dev_mode,
250 })
251 }
252
253 pub async fn set_acme_service(&self, service: Arc<crate::acme::AcmeService>) {
254 *self.acme_service.lock().await = Some(service);
255 }
256
257 pub async fn discover_apps(&self) -> Result<(), anyhow::Error> {
258 tracing::info!("Discovering apps in {}", self.sites_dir.display());
259 let mut apps_to_start: Vec<String> = Vec::new();
260
261 {
262 let mut apps = self.apps.lock().await;
263
264 let mut seen_names: HashSet<String> = HashSet::new();
266
267 for entry in std::fs::read_dir(&self.sites_dir)? {
268 let entry = entry?;
269 let path = entry.path();
270 let resolved_path = if path.is_symlink() {
271 match path.canonicalize() {
272 Ok(p) => p,
273 Err(_) => path.clone(),
274 }
275 } else {
276 path.clone()
277 };
278 if resolved_path.is_dir() {
279 match AppInfo::from_path(&resolved_path) {
280 Ok(mut app_info) => {
281 let name = app_info.config.name.clone();
282 seen_names.insert(name.clone());
283
284 if let Some(existing) = apps.get(&name) {
285 app_info.blue.port = existing.blue.port;
287 app_info.blue.pid = existing.blue.pid;
288 app_info.blue.status = existing.blue.status.clone();
289 app_info.blue.last_started = existing.blue.last_started.clone();
290 app_info.green.port = existing.green.port;
291 app_info.green.pid = existing.green.pid;
292 app_info.green.status = existing.green.status.clone();
293 app_info.green.last_started = existing.green.last_started.clone();
294 app_info.current_slot = existing.current_slot.clone();
295 tracing::debug!("Refreshed config for app: {}", name);
296 } else {
297 tracing::info!("Discovered new app: {}", name);
298 match self
300 .port_allocator
301 .allocate(&app_info.config.name, "blue")
302 .await
303 {
304 Ok(port) => app_info.blue.port = port,
305 Err(e) => tracing::error!(
306 "Failed to allocate blue port for {}: {}",
307 app_info.config.name,
308 e
309 ),
310 }
311 match self
312 .port_allocator
313 .allocate(&app_info.config.name, "green")
314 .await
315 {
316 Ok(port) => app_info.green.port = port,
317 Err(e) => tracing::error!(
318 "Failed to allocate green port for {}: {}",
319 app_info.config.name,
320 e
321 ),
322 }
323 if app_info.config.start_script.is_some()
324 && !self.deployment_manager.is_deploying().await
325 {
326 apps_to_start.push(name.clone());
327 }
328 }
329 apps.insert(name, app_info);
330 }
331 Err(e) => {
332 tracing::warn!("Failed to load app from {}: {}", path.display(), e);
333 }
334 }
335 }
336 }
337
338 apps.retain(|name, _| seen_names.contains(name));
340 }
341
342 if !apps_to_start.is_empty() {
344 let manager = self.clone();
345 tokio::spawn(async move {
346 for app_name in apps_to_start {
347 tracing::info!("Auto-starting app: {}", app_name);
348 if let Err(e) = manager.deploy(&app_name, "blue").await {
349 tracing::error!("Failed to auto-start {}: {}", app_name, e);
350 }
351 }
352 });
353 }
354
355 self.sync_routes().await;
356 Ok(())
357 }
358
359 async fn sync_routes(&self) {
363 let apps = self.apps.lock().await;
364 let cfg = self.config_manager.get_config();
365 let mut rules = cfg.rules.clone();
366 let global_scripts = cfg.global_scripts.clone();
367
368 let mut app_domains: HashMap<String, u16> = HashMap::new();
370 for app in apps.values() {
371 if !app.config.domain.is_empty() {
372 let port = if app.current_slot == "blue" {
373 app.blue.port
374 } else {
375 app.green.port
376 };
377 app_domains.insert(app.config.domain.clone(), port);
378 if self.dev_mode {
380 if let Some(dev) = dev_domain(&app.config.domain) {
381 app_domains.insert(dev, port);
382 }
383 }
384 }
385 }
386
387 let mut existing_domains: HashMap<String, usize> = HashMap::new();
389 for (i, rule) in rules.iter().enumerate() {
390 if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
391 existing_domains.insert(domain.clone(), i);
392 }
393 }
394
395 let mut changed = false;
396
397 for (domain, port) in &app_domains {
399 let target_url = format!("http://localhost:{}", port);
400 if let Some(&idx) = existing_domains.get(domain) {
401 let current_target = rules[idx].targets.first().map(|t| t.url.to_string());
403 let expected = format!("{}/", target_url);
404 if current_target.as_deref() != Some(&expected) {
405 if let Ok(url) = Url::parse(&target_url) {
406 rules[idx].targets = vec![super::config::Target { url, weight: 100 }];
407 changed = true;
408 tracing::info!("Updated route for domain {} -> {}", domain, target_url);
409 }
410 }
411 } else {
412 if let Ok(url) = Url::parse(&target_url) {
414 rules.push(super::config::ProxyRule {
415 matcher: super::config::RuleMatcher::Domain(domain.clone()),
416 targets: vec![super::config::Target { url, weight: 100 }],
417 headers: vec![],
418 scripts: vec![],
419 auth: vec![],
420 load_balancing: super::config::LoadBalancingStrategy::default(),
421 });
422 changed = true;
423 tracing::info!("Added route for domain {} -> {}", domain, target_url);
424 }
425 }
426 }
427
428 let mut indices_to_remove: Vec<usize> = Vec::new();
430 for (i, rule) in rules.iter().enumerate() {
431 if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
432 if !app_domains.contains_key(domain) {
433 let is_auto = rule
435 .targets
436 .iter()
437 .all(|t| t.url.host_str() == Some("localhost"));
438 if is_auto {
439 indices_to_remove.push(i);
440 tracing::info!("Removing orphaned route for domain {}", domain);
441 }
442 }
443 }
444 }
445
446 for idx in indices_to_remove.into_iter().rev() {
448 rules.remove(idx);
449 changed = true;
450 }
451
452 if changed {
453 if let Err(e) = self.config_manager.update_rules(rules, global_scripts) {
454 tracing::error!("Failed to sync routes: {}", e);
455 }
456 }
457
458 if let Some(ref acme) = *self.acme_service.lock().await {
460 for domain in app_domains.keys() {
461 if is_acme_eligible(domain) {
462 let acme = acme.clone();
463 let domain = domain.clone();
464 tokio::spawn(async move {
465 if let Err(e) = acme.ensure_certificate(&domain).await {
466 tracing::error!("Failed to issue cert for {}: {}", domain, e);
467 }
468 });
469 }
470 }
471 }
472 }
473
474 pub async fn start_watcher(&self) -> Result<(), anyhow::Error> {
475 let (tx, mut rx) = mpsc::channel(100);
476 let sites_dir = self.sites_dir.clone();
477 let manager = self.clone();
478
479 let watch_path = if sites_dir.is_symlink() {
480 sites_dir.canonicalize()?
481 } else {
482 sites_dir.clone()
483 };
484
485 let mut watcher = RecommendedWatcher::new(
486 move |res| {
487 let _ = tx.blocking_send(res);
488 },
489 notify::Config::default(),
490 )?;
491
492 watcher.watch(&watch_path, RecursiveMode::Recursive)?;
493
494 *self.watcher.lock().await = Some(watcher);
495
496 tokio::spawn(async move {
497 loop {
498 let mut changed_paths: HashSet<PathBuf> = HashSet::new();
500 let mut got_event = false;
501 while let Some(res) = rx.recv().await {
502 if let Ok(event) = res {
503 if event.kind.is_modify()
504 || event.kind.is_create()
505 || event.kind.is_remove()
506 {
507 changed_paths.extend(event.paths);
508 got_event = true;
509 break;
510 }
511 }
512 }
513 if !got_event {
514 break; }
516
517 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
519 while let Ok(res) = rx.try_recv() {
520 if let Ok(event) = res {
521 changed_paths.extend(event.paths);
522 }
523 }
524
525 tracing::info!("Apps directory changed, rediscovering...");
526 if let Err(e) = manager.discover_apps().await {
527 tracing::error!("Failed to rediscover apps: {}", e);
528 }
529
530 if manager.dev_mode {
532 let app_names = affected_app_names(&sites_dir, &changed_paths);
533 if !app_names.is_empty() {
534 let running_apps: Vec<String> = {
535 let apps = manager.apps.lock().await;
536 app_names
537 .into_iter()
538 .filter(|name| {
539 apps.get(name).is_some_and(|app| {
540 let instance = if app.current_slot == "blue" {
541 &app.blue
542 } else {
543 &app.green
544 };
545 instance.status == InstanceStatus::Running
546 })
547 })
548 .collect()
549 };
550 for app_name in running_apps {
551 tracing::info!(
552 "Dev mode: restarting app '{}' due to file changes",
553 app_name
554 );
555 if let Err(e) = manager.restart(&app_name).await {
556 tracing::error!("Failed to restart app '{}': {}", app_name, e);
557 }
558 }
559 }
560 }
561 }
562 });
563
564 Ok(())
565 }
566
567 pub async fn list_apps(&self) -> Vec<AppInfo> {
568 self.apps
569 .lock()
570 .await
571 .values()
572 .filter(|&a| a.config.name != "_admin")
573 .cloned()
574 .collect()
575 }
576
577 pub async fn get_app(&self, name: &str) -> Option<AppInfo> {
578 self.apps.lock().await.get(name).cloned()
579 }
580
581 pub async fn get_app_name(&self, port: u16) -> Option<String> {
582 self.port_allocator.get_app_name(port).await
583 }
584
585 pub async fn allocate_ports(&self, app_name: &str) -> Result<(u16, u16), anyhow::Error> {
586 let blue_port = self.port_allocator.allocate(app_name, "blue").await?;
587 let green_port = self.port_allocator.allocate(app_name, "green").await?;
588 Ok((blue_port, green_port))
589 }
590
591 pub async fn deploy(&self, app_name: &str, slot: &str) -> Result<(), anyhow::Error> {
592 tracing::info!("Starting deploy for {} to slot {}", app_name, slot);
593
594 let app = {
595 let apps = self.apps.lock().await;
596 match apps.get(app_name) {
597 Some(app) => {
598 tracing::debug!(
599 "Found app {}: blue={}:{}, green={}:{}",
600 app_name,
601 app.blue.status,
602 app.blue.port,
603 app.green.status,
604 app.green.port
605 );
606 app.clone()
607 }
608 None => {
609 tracing::error!("App not found: {}", app_name);
610 return Err(anyhow::anyhow!("App not found: {}", app_name));
611 }
612 }
613 };
614
615 tracing::info!("Deploying {} to slot {}", app.config.name, slot);
616 let pid = self.deployment_manager.deploy(&app, slot).await?;
617 tracing::info!("Deploy started, PID: {}", pid);
618
619 let old_slot_name;
621 let old_pid;
622 {
623 let apps = self.apps.lock().await;
624 match apps.get(app_name) {
625 Some(a) => {
626 old_slot_name = a.current_slot.clone();
627 old_pid = if old_slot_name == "blue" {
628 a.blue.pid
629 } else {
630 a.green.pid
631 };
632 tracing::info!(
633 "Current slot: {}, old_slot_name: {}, old_pid: {:?}",
634 app_name,
635 old_slot_name,
636 old_pid
637 );
638 }
639 None => {
640 old_slot_name = "unknown".to_string();
641 old_pid = None;
642 tracing::error!("App {} not found in apps map!", app_name);
643 }
644 }
645 }
646
647 {
649 let mut apps = self.apps.lock().await;
650 if let Some(app_info) = apps.get_mut(app_name) {
651 let instance = if slot == "blue" {
652 &mut app_info.blue
653 } else {
654 &mut app_info.green
655 };
656 instance.status = InstanceStatus::Running;
657 instance.pid = Some(pid);
658 instance.last_started = Some(chrono::Utc::now().to_rfc3339());
659
660 app_info.current_slot = slot.to_string();
662 tracing::info!("Switched traffic from {} to {}", old_slot_name, slot);
663 } else {
664 tracing::error!("App {} not found in map after deploy!", app_name);
665 }
666 }
667
668 tracing::info!(
670 "Checking if should stop old slot: old_slot_name={}, slot={}",
671 old_slot_name,
672 slot
673 );
674 if old_slot_name != "unknown" && old_slot_name != slot {
675 if let Some(pid) = old_pid {
676 tracing::info!("Stopping old slot {} (PID: {})", old_slot_name, pid);
677 self.deployment_manager
678 .stop_instance(&app, &old_slot_name)
679 .await?;
680 tracing::info!("Old slot {} stopped", old_slot_name);
681
682 let mut apps = self.apps.lock().await;
684 if let Some(app_info) = apps.get_mut(app_name) {
685 let old_instance = if old_slot_name == "blue" {
686 &mut app_info.blue
687 } else {
688 &mut app_info.green
689 };
690 old_instance.status = InstanceStatus::Stopped;
691 old_instance.pid = None;
692 }
693 } else {
694 tracing::warn!(
695 "No PID found for old slot {} (status may already be stopped)",
696 old_slot_name
697 );
698 }
699 }
700
701 self.sync_routes().await;
702 tracing::info!("Deploy completed for {} to slot {}", app_name, slot);
703 Ok(())
704 }
705
706 pub async fn restart(&self, app_name: &str) -> Result<(), anyhow::Error> {
707 let slot = {
708 let apps = self.apps.lock().await;
709 let app = apps
710 .get(app_name)
711 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?;
712 app.current_slot.clone()
713 };
714
715 self.stop(app_name).await?;
716 self.deploy(app_name, &slot).await
717 }
718
719 pub async fn rollback(&self, app_name: &str) -> Result<(), anyhow::Error> {
720 let (app, target_slot, old_slot) = {
721 let apps = self.apps.lock().await;
722 let app = apps
723 .get(app_name)
724 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
725 .clone();
726 let target_slot = if app.current_slot == "blue" {
727 "green"
728 } else {
729 "blue"
730 };
731 (
732 app.clone(),
733 target_slot.to_string(),
734 app.current_slot.clone(),
735 )
736 };
737
738 let pid = self.deployment_manager.deploy(&app, &target_slot).await?;
739
740 {
741 let mut apps = self.apps.lock().await;
742 if let Some(app_info) = apps.get_mut(app_name) {
743 app_info.current_slot = target_slot.clone();
744 let instance = if target_slot == "blue" {
745 &mut app_info.blue
746 } else {
747 &mut app_info.green
748 };
749 instance.status = InstanceStatus::Running;
750 instance.pid = Some(pid);
751 }
752 }
753
754 let old_pid = {
756 let apps = self.apps.lock().await;
757 apps.get(app_name).and_then(|a| {
758 if old_slot == "blue" {
759 a.blue.pid
760 } else {
761 a.green.pid
762 }
763 })
764 };
765 if let Some(pid) = old_pid {
766 tracing::info!(
767 "Stopping old slot {} (PID: {}) during rollback",
768 old_slot,
769 pid
770 );
771 self.deployment_manager
772 .stop_instance(&app, &old_slot)
773 .await?;
774 let mut apps = self.apps.lock().await;
776 if let Some(app_info) = apps.get_mut(app_name) {
777 let old_instance = if old_slot == "blue" {
778 &mut app_info.blue
779 } else {
780 &mut app_info.green
781 };
782 old_instance.status = InstanceStatus::Stopped;
783 old_instance.pid = None;
784 }
785 }
786
787 self.sync_routes().await;
788 Ok(())
789 }
790
791 pub async fn stop(&self, app_name: &str) -> Result<(), anyhow::Error> {
792 let (app, slot) = {
793 let apps = self.apps.lock().await;
794 let app = apps
795 .get(app_name)
796 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
797 .clone();
798 let slot = app.current_slot.clone();
799 (app, slot)
800 };
801
802 self.deployment_manager.stop_instance(&app, &slot).await?;
803
804 {
805 let mut apps = self.apps.lock().await;
806 if let Some(app_info) = apps.get_mut(app_name) {
807 let instance = if slot == "blue" {
808 &mut app_info.blue
809 } else {
810 &mut app_info.green
811 };
812 instance.status = InstanceStatus::Stopped;
813 instance.pid = None;
814 }
815 }
816
817 Ok(())
818 }
819
820 pub async fn stop_all(&self) {
821 let apps: Vec<String> = {
822 let apps_guard = self.apps.lock().await;
823 apps_guard.keys().cloned().collect()
824 };
825
826 for app_name in apps {
827 let app = {
829 let apps_guard = self.apps.lock().await;
830 apps_guard.get(&app_name).cloned()
831 };
832 if let Some(app) = app {
833 if app.blue.status == InstanceStatus::Running && app.blue.pid.is_some() {
835 if let Err(e) = self.deployment_manager.stop_instance(&app, "blue").await {
836 tracing::error!("Failed to stop blue slot for {}: {}", app_name, e);
837 }
838 }
839 if app.green.status == InstanceStatus::Running && app.green.pid.is_some() {
841 if let Err(e) = self.deployment_manager.stop_instance(&app, "green").await {
842 tracing::error!("Failed to stop green slot for {}: {}", app_name, e);
843 }
844 }
845 let mut apps_guard = self.apps.lock().await;
847 if let Some(app_info) = apps_guard.get_mut(&app_name) {
848 app_info.blue.status = InstanceStatus::Stopped;
849 app_info.blue.pid = None;
850 app_info.green.status = InstanceStatus::Stopped;
851 app_info.green.pid = None;
852 }
853 }
854 }
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861 use tempfile::TempDir;
862
863 #[tokio::test]
864 async fn test_app_info_parsing() {
865 let temp_dir = TempDir::new().unwrap();
866 let app_path = temp_dir.path().join("test.solisoft.net");
867 std::fs::create_dir_all(&app_path).unwrap();
868
869 let app_infos = r#"
870name = "test.solisoft.net"
871domain = "test.solisoft.net"
872start_script = "./start.sh"
873stop_script = "./stop.sh"
874health_check = "/health"
875graceful_timeout = 30
876port_range_start = 9000
877port_range_end = 9999
878"#;
879 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
880
881 let app_info = AppInfo::from_path(&app_path).unwrap();
882 assert_eq!(app_info.config.name, "test.solisoft.net");
883 assert_eq!(app_info.config.domain, "test.solisoft.net");
884 assert_eq!(app_info.config.start_script, Some("./start.sh".to_string()));
885 }
886
887 #[test]
888 fn test_dev_domain() {
889 assert_eq!(
890 dev_domain("soli.solisoft.net"),
891 Some("soli.solisoft.test".to_string())
892 );
893 assert_eq!(
894 dev_domain("app.example.com"),
895 Some("app.example.test".to_string())
896 );
897 assert_eq!(dev_domain("example.org"), Some("example.test".to_string()));
898 assert_eq!(dev_domain("app.example.test"), None);
900 assert_eq!(dev_domain("app.localhost"), None);
902 assert_eq!(dev_domain("localhost"), None);
904 }
905
906 #[test]
907 fn test_is_acme_eligible_excludes_dev() {
908 assert!(!is_acme_eligible("app.example.test"));
909 assert!(!is_acme_eligible("localhost"));
910 assert!(!is_acme_eligible("app.localhost"));
911 assert!(is_acme_eligible("app.example.com"));
912 }
913
914 #[test]
915 fn test_luaonbeans_auto_detected_no_app_infos() {
916 let temp_dir = TempDir::new().unwrap();
917 let app_path = temp_dir.path().join("myapp.example.com");
918 std::fs::create_dir_all(&app_path).unwrap();
919 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
920
921 let app_info = AppInfo::from_path(&app_path).unwrap();
922 assert_eq!(app_info.config.name, "myapp.example.com");
923 assert_eq!(app_info.config.domain, "myapp.example.com");
924 assert_eq!(
925 app_info.config.start_script,
926 Some("./luaonbeans.org -D . -p $PORT -s".to_string())
927 );
928 assert_eq!(app_info.config.health_check, Some("/".to_string()));
929 }
930
931 #[test]
932 fn test_luaonbeans_auto_detected_with_partial_app_infos() {
933 let temp_dir = TempDir::new().unwrap();
934 let app_path = temp_dir.path().join("myapp");
935 std::fs::create_dir_all(&app_path).unwrap();
936 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
937
938 let app_infos = r#"
939name = "myapp"
940domain = "custom.example.com"
941graceful_timeout = 30
942port_range_start = 9000
943port_range_end = 9999
944"#;
945 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
946
947 let app_info = AppInfo::from_path(&app_path).unwrap();
948 assert_eq!(app_info.config.name, "myapp");
949 assert_eq!(app_info.config.domain, "custom.example.com");
950 assert_eq!(
951 app_info.config.start_script,
952 Some("./luaonbeans.org -D . -p $PORT -s".to_string())
953 );
954 assert_eq!(app_info.config.health_check, Some("/".to_string()));
955 }
956
957 #[test]
958 fn test_no_override_when_start_script_set() {
959 let temp_dir = TempDir::new().unwrap();
960 let app_path = temp_dir.path().join("myapp");
961 std::fs::create_dir_all(&app_path).unwrap();
962 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
963
964 let app_infos = r#"
965name = "myapp"
966domain = "myapp.example.com"
967start_script = "./custom-start.sh"
968health_check = "/health"
969graceful_timeout = 30
970port_range_start = 9000
971port_range_end = 9999
972"#;
973 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
974
975 let app_info = AppInfo::from_path(&app_path).unwrap();
976 assert_eq!(
977 app_info.config.start_script,
978 Some("./custom-start.sh".to_string())
979 );
980 assert_eq!(app_info.config.health_check, Some("/health".to_string()));
981 }
982
983 #[test]
984 fn test_no_detection_without_luaonbeans_or_app_infos() {
985 let temp_dir = TempDir::new().unwrap();
986 let app_path = temp_dir.path().join("emptyapp");
987 std::fs::create_dir_all(&app_path).unwrap();
988
989 let app_info = AppInfo::from_path(&app_path).unwrap();
990 assert_eq!(app_info.config.name, "emptyapp");
991 assert!(app_info.config.start_script.is_none());
992 assert_eq!(app_info.config.health_check, Some("/health".to_string()));
993 }
994}