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