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