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 });
406 changed = true;
407 tracing::info!("Added route for domain {} -> {}", domain, target_url);
408 }
409 }
410 }
411
412 let mut indices_to_remove: Vec<usize> = Vec::new();
414 for (i, rule) in rules.iter().enumerate() {
415 if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
416 if !app_domains.contains_key(domain) {
417 let is_auto = rule
419 .targets
420 .iter()
421 .all(|t| t.url.host_str() == Some("localhost"));
422 if is_auto {
423 indices_to_remove.push(i);
424 tracing::info!("Removing orphaned route for domain {}", domain);
425 }
426 }
427 }
428 }
429
430 for idx in indices_to_remove.into_iter().rev() {
432 rules.remove(idx);
433 changed = true;
434 }
435
436 if changed {
437 if let Err(e) = self.config_manager.update_rules(rules, global_scripts) {
438 tracing::error!("Failed to sync routes: {}", e);
439 }
440 }
441
442 if let Some(ref acme) = *self.acme_service.lock().await {
444 for domain in app_domains.keys() {
445 if is_acme_eligible(domain) {
446 let acme = acme.clone();
447 let domain = domain.clone();
448 tokio::spawn(async move {
449 if let Err(e) = acme.ensure_certificate(&domain).await {
450 tracing::error!("Failed to issue cert for {}: {}", domain, e);
451 }
452 });
453 }
454 }
455 }
456 }
457
458 pub async fn start_watcher(&self) -> Result<(), anyhow::Error> {
459 let (tx, mut rx) = mpsc::channel(100);
460 let sites_dir = self.sites_dir.clone();
461 let manager = self.clone();
462
463 let mut watcher = RecommendedWatcher::new(
464 move |res| {
465 let _ = tx.blocking_send(res);
466 },
467 notify::Config::default(),
468 )?;
469
470 watcher.watch(&sites_dir, RecursiveMode::Recursive)?;
471
472 *self.watcher.lock().await = Some(watcher);
473
474 tokio::spawn(async move {
475 loop {
476 let mut changed_paths: HashSet<PathBuf> = HashSet::new();
478 let mut got_event = false;
479 while let Some(res) = rx.recv().await {
480 if let Ok(event) = res {
481 if event.kind.is_modify()
482 || event.kind.is_create()
483 || event.kind.is_remove()
484 {
485 changed_paths.extend(event.paths);
486 got_event = true;
487 break;
488 }
489 }
490 }
491 if !got_event {
492 break; }
494
495 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
497 while let Ok(res) = rx.try_recv() {
498 if let Ok(event) = res {
499 changed_paths.extend(event.paths);
500 }
501 }
502
503 tracing::info!("Apps directory changed, rediscovering...");
504 if let Err(e) = manager.discover_apps().await {
505 tracing::error!("Failed to rediscover apps: {}", e);
506 }
507
508 if manager.dev_mode {
510 let app_names = affected_app_names(&sites_dir, &changed_paths);
511 if !app_names.is_empty() {
512 let running_apps: Vec<String> = {
513 let apps = manager.apps.lock().await;
514 app_names
515 .into_iter()
516 .filter(|name| {
517 apps.get(name).is_some_and(|app| {
518 let instance = if app.current_slot == "blue" {
519 &app.blue
520 } else {
521 &app.green
522 };
523 instance.status == InstanceStatus::Running
524 })
525 })
526 .collect()
527 };
528 for app_name in running_apps {
529 tracing::info!(
530 "Dev mode: restarting app '{}' due to file changes",
531 app_name
532 );
533 if let Err(e) = manager.restart(&app_name).await {
534 tracing::error!("Failed to restart app '{}': {}", app_name, e);
535 }
536 }
537 }
538 }
539 }
540 });
541
542 Ok(())
543 }
544
545 pub async fn list_apps(&self) -> Vec<AppInfo> {
546 self.apps
547 .lock()
548 .await
549 .values()
550 .filter(|&a| a.config.name != "_admin")
551 .cloned()
552 .collect()
553 }
554
555 pub async fn get_app(&self, name: &str) -> Option<AppInfo> {
556 self.apps.lock().await.get(name).cloned()
557 }
558
559 pub async fn get_app_name(&self, port: u16) -> Option<String> {
560 self.port_allocator.get_app_name(port).await
561 }
562
563 pub async fn allocate_ports(&self, app_name: &str) -> Result<(u16, u16), anyhow::Error> {
564 let blue_port = self.port_allocator.allocate(app_name, "blue").await?;
565 let green_port = self.port_allocator.allocate(app_name, "green").await?;
566 Ok((blue_port, green_port))
567 }
568
569 pub async fn deploy(&self, app_name: &str, slot: &str) -> Result<(), anyhow::Error> {
570 tracing::info!("Starting deploy for {} to slot {}", app_name, slot);
571
572 let app = {
573 let apps = self.apps.lock().await;
574 match apps.get(app_name) {
575 Some(app) => {
576 tracing::debug!(
577 "Found app {}: blue={}:{}, green={}:{}",
578 app_name,
579 app.blue.status,
580 app.blue.port,
581 app.green.status,
582 app.green.port
583 );
584 app.clone()
585 }
586 None => {
587 tracing::error!("App not found: {}", app_name);
588 return Err(anyhow::anyhow!("App not found: {}", app_name));
589 }
590 }
591 };
592
593 tracing::info!("Deploying {} to slot {}", app.config.name, slot);
594 let pid = self.deployment_manager.deploy(&app, slot).await?;
595 tracing::info!("Deploy started, PID: {}", pid);
596
597 let old_slot_name;
599 let old_pid;
600 {
601 let apps = self.apps.lock().await;
602 match apps.get(app_name) {
603 Some(a) => {
604 old_slot_name = a.current_slot.clone();
605 old_pid = if old_slot_name == "blue" {
606 a.blue.pid
607 } else {
608 a.green.pid
609 };
610 tracing::info!(
611 "Current slot: {}, old_slot_name: {}, old_pid: {:?}",
612 app_name,
613 old_slot_name,
614 old_pid
615 );
616 }
617 None => {
618 old_slot_name = "unknown".to_string();
619 old_pid = None;
620 tracing::error!("App {} not found in apps map!", app_name);
621 }
622 }
623 }
624
625 {
627 let mut apps = self.apps.lock().await;
628 if let Some(app_info) = apps.get_mut(app_name) {
629 let instance = if slot == "blue" {
630 &mut app_info.blue
631 } else {
632 &mut app_info.green
633 };
634 instance.status = InstanceStatus::Running;
635 instance.pid = Some(pid);
636 instance.last_started = Some(chrono::Utc::now().to_rfc3339());
637
638 app_info.current_slot = slot.to_string();
640 tracing::info!("Switched traffic from {} to {}", old_slot_name, slot);
641 } else {
642 tracing::error!("App {} not found in map after deploy!", app_name);
643 }
644 }
645
646 tracing::info!(
648 "Checking if should stop old slot: old_slot_name={}, slot={}",
649 old_slot_name,
650 slot
651 );
652 if old_slot_name != "unknown" && old_slot_name != slot {
653 if let Some(pid) = old_pid {
654 tracing::info!("Stopping old slot {} (PID: {})", old_slot_name, pid);
655 self.deployment_manager
656 .stop_instance(&app, &old_slot_name)
657 .await?;
658 tracing::info!("Old slot {} stopped", old_slot_name);
659
660 let mut apps = self.apps.lock().await;
662 if let Some(app_info) = apps.get_mut(app_name) {
663 let old_instance = if old_slot_name == "blue" {
664 &mut app_info.blue
665 } else {
666 &mut app_info.green
667 };
668 old_instance.status = InstanceStatus::Stopped;
669 old_instance.pid = None;
670 }
671 } else {
672 tracing::warn!(
673 "No PID found for old slot {} (status may already be stopped)",
674 old_slot_name
675 );
676 }
677 }
678
679 self.sync_routes().await;
680 tracing::info!("Deploy completed for {} to slot {}", app_name, slot);
681 Ok(())
682 }
683
684 pub async fn restart(&self, app_name: &str) -> Result<(), anyhow::Error> {
685 let slot = {
686 let apps = self.apps.lock().await;
687 let app = apps
688 .get(app_name)
689 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?;
690 app.current_slot.clone()
691 };
692
693 self.stop(app_name).await?;
694 self.deploy(app_name, &slot).await
695 }
696
697 pub async fn rollback(&self, app_name: &str) -> Result<(), anyhow::Error> {
698 let (app, target_slot, old_slot) = {
699 let apps = self.apps.lock().await;
700 let app = apps
701 .get(app_name)
702 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
703 .clone();
704 let target_slot = if app.current_slot == "blue" {
705 "green"
706 } else {
707 "blue"
708 };
709 (
710 app.clone(),
711 target_slot.to_string(),
712 app.current_slot.clone(),
713 )
714 };
715
716 let pid = self.deployment_manager.deploy(&app, &target_slot).await?;
717
718 {
719 let mut apps = self.apps.lock().await;
720 if let Some(app_info) = apps.get_mut(app_name) {
721 app_info.current_slot = target_slot.clone();
722 let instance = if target_slot == "blue" {
723 &mut app_info.blue
724 } else {
725 &mut app_info.green
726 };
727 instance.status = InstanceStatus::Running;
728 instance.pid = Some(pid);
729 }
730 }
731
732 let old_pid = {
734 let apps = self.apps.lock().await;
735 apps.get(app_name).and_then(|a| {
736 if old_slot == "blue" {
737 a.blue.pid
738 } else {
739 a.green.pid
740 }
741 })
742 };
743 if let Some(pid) = old_pid {
744 tracing::info!(
745 "Stopping old slot {} (PID: {}) during rollback",
746 old_slot,
747 pid
748 );
749 self.deployment_manager
750 .stop_instance(&app, &old_slot)
751 .await?;
752 let mut apps = self.apps.lock().await;
754 if let Some(app_info) = apps.get_mut(app_name) {
755 let old_instance = if old_slot == "blue" {
756 &mut app_info.blue
757 } else {
758 &mut app_info.green
759 };
760 old_instance.status = InstanceStatus::Stopped;
761 old_instance.pid = None;
762 }
763 }
764
765 self.sync_routes().await;
766 Ok(())
767 }
768
769 pub async fn stop(&self, app_name: &str) -> Result<(), anyhow::Error> {
770 let (app, slot) = {
771 let apps = self.apps.lock().await;
772 let app = apps
773 .get(app_name)
774 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
775 .clone();
776 let slot = app.current_slot.clone();
777 (app, slot)
778 };
779
780 self.deployment_manager.stop_instance(&app, &slot).await?;
781
782 {
783 let mut apps = self.apps.lock().await;
784 if let Some(app_info) = apps.get_mut(app_name) {
785 let instance = if slot == "blue" {
786 &mut app_info.blue
787 } else {
788 &mut app_info.green
789 };
790 instance.status = InstanceStatus::Stopped;
791 instance.pid = None;
792 }
793 }
794
795 Ok(())
796 }
797
798 pub async fn stop_all(&self) {
799 let apps: Vec<String> = {
800 let apps_guard = self.apps.lock().await;
801 apps_guard.keys().cloned().collect()
802 };
803
804 for app_name in apps {
805 let app = {
807 let apps_guard = self.apps.lock().await;
808 apps_guard.get(&app_name).cloned()
809 };
810 if let Some(app) = app {
811 if app.blue.status == InstanceStatus::Running && app.blue.pid.is_some() {
813 if let Err(e) = self.deployment_manager.stop_instance(&app, "blue").await {
814 tracing::error!("Failed to stop blue slot for {}: {}", app_name, e);
815 }
816 }
817 if app.green.status == InstanceStatus::Running && app.green.pid.is_some() {
819 if let Err(e) = self.deployment_manager.stop_instance(&app, "green").await {
820 tracing::error!("Failed to stop green slot for {}: {}", app_name, e);
821 }
822 }
823 let mut apps_guard = self.apps.lock().await;
825 if let Some(app_info) = apps_guard.get_mut(&app_name) {
826 app_info.blue.status = InstanceStatus::Stopped;
827 app_info.blue.pid = None;
828 app_info.green.status = InstanceStatus::Stopped;
829 app_info.green.pid = None;
830 }
831 }
832 }
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use tempfile::TempDir;
840
841 #[tokio::test]
842 async fn test_app_info_parsing() {
843 let temp_dir = TempDir::new().unwrap();
844 let app_path = temp_dir.path().join("test.solisoft.net");
845 std::fs::create_dir_all(&app_path).unwrap();
846
847 let app_infos = r#"
848name = "test.solisoft.net"
849domain = "test.solisoft.net"
850start_script = "./start.sh"
851stop_script = "./stop.sh"
852health_check = "/health"
853graceful_timeout = 30
854port_range_start = 9000
855port_range_end = 9999
856"#;
857 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
858
859 let app_info = AppInfo::from_path(&app_path).unwrap();
860 assert_eq!(app_info.config.name, "test.solisoft.net");
861 assert_eq!(app_info.config.domain, "test.solisoft.net");
862 assert_eq!(app_info.config.start_script, Some("./start.sh".to_string()));
863 }
864
865 #[test]
866 fn test_dev_domain() {
867 assert_eq!(
868 dev_domain("soli.solisoft.net"),
869 Some("soli.solisoft.test".to_string())
870 );
871 assert_eq!(
872 dev_domain("app.example.com"),
873 Some("app.example.test".to_string())
874 );
875 assert_eq!(dev_domain("example.org"), Some("example.test".to_string()));
876 assert_eq!(dev_domain("app.example.test"), None);
878 assert_eq!(dev_domain("app.localhost"), None);
880 assert_eq!(dev_domain("localhost"), None);
882 }
883
884 #[test]
885 fn test_is_acme_eligible_excludes_dev() {
886 assert!(!is_acme_eligible("app.example.test"));
887 assert!(!is_acme_eligible("localhost"));
888 assert!(!is_acme_eligible("app.localhost"));
889 assert!(is_acme_eligible("app.example.com"));
890 }
891
892 #[test]
893 fn test_luaonbeans_auto_detected_no_app_infos() {
894 let temp_dir = TempDir::new().unwrap();
895 let app_path = temp_dir.path().join("myapp.example.com");
896 std::fs::create_dir_all(&app_path).unwrap();
897 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
898
899 let app_info = AppInfo::from_path(&app_path).unwrap();
900 assert_eq!(app_info.config.name, "myapp.example.com");
901 assert_eq!(app_info.config.domain, "myapp.example.com");
902 assert_eq!(
903 app_info.config.start_script,
904 Some("./luaonbeans.org -D . -p $PORT -s".to_string())
905 );
906 assert_eq!(app_info.config.health_check, Some("/".to_string()));
907 }
908
909 #[test]
910 fn test_luaonbeans_auto_detected_with_partial_app_infos() {
911 let temp_dir = TempDir::new().unwrap();
912 let app_path = temp_dir.path().join("myapp");
913 std::fs::create_dir_all(&app_path).unwrap();
914 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
915
916 let app_infos = r#"
917name = "myapp"
918domain = "custom.example.com"
919graceful_timeout = 30
920port_range_start = 9000
921port_range_end = 9999
922"#;
923 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
924
925 let app_info = AppInfo::from_path(&app_path).unwrap();
926 assert_eq!(app_info.config.name, "myapp");
927 assert_eq!(app_info.config.domain, "custom.example.com");
928 assert_eq!(
929 app_info.config.start_script,
930 Some("./luaonbeans.org -D . -p $PORT -s".to_string())
931 );
932 assert_eq!(app_info.config.health_check, Some("/".to_string()));
933 }
934
935 #[test]
936 fn test_no_override_when_start_script_set() {
937 let temp_dir = TempDir::new().unwrap();
938 let app_path = temp_dir.path().join("myapp");
939 std::fs::create_dir_all(&app_path).unwrap();
940 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
941
942 let app_infos = r#"
943name = "myapp"
944domain = "myapp.example.com"
945start_script = "./custom-start.sh"
946health_check = "/health"
947graceful_timeout = 30
948port_range_start = 9000
949port_range_end = 9999
950"#;
951 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
952
953 let app_info = AppInfo::from_path(&app_path).unwrap();
954 assert_eq!(
955 app_info.config.start_script,
956 Some("./custom-start.sh".to_string())
957 );
958 assert_eq!(app_info.config.health_check, Some("/health".to_string()));
959 }
960
961 #[test]
962 fn test_no_detection_without_luaonbeans_or_app_infos() {
963 let temp_dir = TempDir::new().unwrap();
964 let app_path = temp_dir.path().join("emptyapp");
965 std::fs::create_dir_all(&app_path).unwrap();
966
967 let app_info = AppInfo::from_path(&app_path).unwrap();
968 assert_eq!(app_info.config.name, "emptyapp");
969 assert!(app_info.config.start_script.is_none());
970 assert_eq!(app_info.config.health_check, Some("/health".to_string()));
971 }
972}