1use notify::{RecommendedWatcher, RecursiveMode, Watcher};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use tokio::sync::broadcast;
7use tokio::sync::mpsc;
8use tokio::sync::Mutex;
9use url::Url;
10
11pub mod deployment;
12pub mod port_manager;
13
14use crate::metrics::Metrics as AppMetrics;
15pub use deployment::{DeploymentManager, DeploymentStatus};
16pub use port_manager::{PortAllocator, PortManager};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default)]
20pub struct AppConfig {
21 pub name: String,
22 pub domain: String,
23 pub start_script: Option<String>,
24 pub stop_script: Option<String>,
25 pub health_check: Option<String>,
26 pub graceful_timeout: u32,
27 pub port_range_start: u16,
28 pub port_range_end: u16,
29 pub workers: u16,
30 pub user: Option<String>,
31 pub group: Option<String>,
32}
33
34impl Default for AppConfig {
35 fn default() -> Self {
36 Self {
37 name: String::new(),
38 domain: String::new(),
39 start_script: None,
40 stop_script: None,
41 health_check: Some("/health".to_string()),
42 graceful_timeout: 30,
43 port_range_start: 9000,
44 port_range_end: 9999,
45 workers: 1,
46 user: None,
47 group: None,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct AppInstance {
54 pub name: String,
55 pub slot: String,
56 pub port: u16,
57 pub pid: Option<u32>,
58 pub status: InstanceStatus,
59 pub last_started: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub enum InstanceStatus {
64 Stopped,
65 Starting,
66 Running,
67 Unhealthy,
68 Failed,
69}
70
71impl std::fmt::Display for InstanceStatus {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 InstanceStatus::Stopped => write!(f, "Stopped"),
75 InstanceStatus::Starting => write!(f, "Starting"),
76 InstanceStatus::Running => write!(f, "Running"),
77 InstanceStatus::Unhealthy => write!(f, "Unhealthy"),
78 InstanceStatus::Failed => write!(f, "Failed"),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct AppInfo {
85 pub config: AppConfig,
86 pub path: PathBuf,
87 pub blue: AppInstance,
88 pub green: AppInstance,
89 pub current_slot: String,
90}
91
92impl AppInfo {
93 pub fn from_path(path: &std::path::Path, dev_mode: bool) -> Result<Self, anyhow::Error> {
94 let folder_name = path
96 .file_name()
97 .and_then(|n| n.to_str())
98 .unwrap_or_default();
99 if !is_valid_domain(folder_name) {
100 return Err(anyhow::anyhow!(
101 "folder '{}' is not a valid domain (must contain at least one dot)",
102 folder_name
103 ));
104 }
105
106 let app_infos_path = path.join("app.infos");
107
108 let mut config = if app_infos_path.exists() {
109 let content = std::fs::read_to_string(&app_infos_path)?;
110 toml::from_str(&content)?
111 } else {
112 AppConfig::default()
113 };
114
115 let app_name = path
116 .file_name()
117 .and_then(|n| n.to_str())
118 .unwrap_or_default()
119 .to_string();
120
121 if config.name.is_empty() {
123 config.name = app_name.clone();
124 }
125
126 if config.start_script.is_none() && path.join("luaonbeans.org").exists() {
128 config.start_script = Some("./luaonbeans.org -D . -p $PORT -s".to_string());
129 config.health_check = Some("/".to_string());
130 if config.domain.is_empty() {
131 config.domain = app_name.clone();
132 }
133 }
134
135 if config.start_script.is_none()
137 && path.join("app").exists()
138 && path.join("app/models").exists()
139 {
140 let start_script = if dev_mode {
141 "soli serve . --dev --port $PORT --workers $WORKERS".to_string()
142 } else {
143 "soli serve . --port $PORT --workers $WORKERS".to_string()
144 };
145 config.start_script = Some(start_script);
146 config.health_check = Some("/".to_string());
147 if config.domain.is_empty() {
148 config.domain = app_name.clone();
149 }
150 }
151
152 Ok(Self {
153 config,
154 path: path.to_path_buf(),
155 blue: AppInstance {
156 name: app_name.clone(),
157 slot: "blue".to_string(),
158 port: 0,
159 pid: None,
160 status: InstanceStatus::Stopped,
161 last_started: None,
162 },
163 green: AppInstance {
164 name: app_name.clone(),
165 slot: "green".to_string(),
166 port: 0,
167 pid: None,
168 status: InstanceStatus::Stopped,
169 last_started: None,
170 },
171 current_slot: "blue".to_string(),
172 })
173 }
174}
175
176#[derive(Clone)]
177pub struct AppManager {
178 sites_dir: PathBuf,
179 port_allocator: Arc<PortManager>,
180 apps: Arc<Mutex<HashMap<String, AppInfo>>>,
181 config_manager: Arc<dyn super::config::ConfigManagerTrait + Send + Sync>,
182 pub deployment_manager: Arc<DeploymentManager>,
183 watcher: Arc<Mutex<Option<RecommendedWatcher>>>,
184 acme_service: Arc<Mutex<Option<Arc<crate::acme::AcmeService>>>>,
185 dev_mode: bool,
186 event_tx: broadcast::Sender<AppEvent>,
187}
188
189fn dev_domain(domain: &str) -> Option<String> {
192 if domain.ends_with(".test") || domain.ends_with(".localhost") {
193 return None;
194 }
195 let dot = domain.rfind('.')?;
196 Some(format!("{}.test", &domain[..dot]))
197}
198
199fn is_acme_eligible(domain: &str) -> bool {
202 domain != "localhost"
203 && !domain.ends_with(".localhost")
204 && !domain.ends_with(".test")
205 && domain.parse::<std::net::IpAddr>().is_err()
206}
207
208fn is_valid_domain(name: &str) -> bool {
210 !name.is_empty() && (!name.starts_with('.') && (name.contains('.') || name.starts_with('_')))
211}
212
213fn strip_www(domain: &str) -> Option<String> {
216 if domain.starts_with("www.") && domain.len() > 4 {
217 Some(domain[4..].to_string())
218 } else {
219 None
220 }
221}
222
223fn affected_app_names(sites_dir: &Path, paths: &HashSet<PathBuf>) -> HashSet<String> {
226 const IGNORED_SEGMENTS: &[&str] = &["node_modules", ".git", "tmp", "target"];
227
228 let mut names = HashSet::new();
229 for path in paths {
230 let relative = match path.strip_prefix(sites_dir) {
231 Ok(r) => r,
232 Err(_) => continue,
233 };
234
235 let skip = relative.components().any(|c| {
237 if let std::path::Component::Normal(s) = c {
238 IGNORED_SEGMENTS
239 .iter()
240 .any(|ignored| s.to_str() == Some(*ignored))
241 } else {
242 false
243 }
244 });
245 if skip {
246 continue;
247 }
248
249 if relative.components().count() == 2 {
251 if let Some(filename) = relative.file_name() {
252 if filename == "app.infos" {
253 continue;
254 }
255 }
256 }
257
258 if let Some(std::path::Component::Normal(app_dir)) = relative.components().next() {
260 if let Some(name) = app_dir.to_str() {
261 names.insert(name.to_string());
262 }
263 }
264 }
265 names
266}
267
268#[derive(Clone, Debug, Serialize)]
269#[serde(tag = "type")]
270pub enum AppEvent {
271 StatusChanged {
272 app_name: String,
273 slot: String,
274 status: String,
275 },
276 Deployed {
277 app_name: String,
278 slot: String,
279 },
280 Stopped {
281 app_name: String,
282 slot: String,
283 },
284 Restarted {
285 app_name: String,
286 },
287}
288
289impl AppManager {
290 pub fn new(
291 sites_dir: &str,
292 port_allocator: Arc<PortManager>,
293 config_manager: Arc<dyn super::config::ConfigManagerTrait + Send + Sync>,
294 dev_mode: bool,
295 ) -> Result<Self, anyhow::Error> {
296 let sites_path = PathBuf::from(sites_dir);
297 if !sites_path.exists() {
298 std::fs::create_dir_all(&sites_path)?;
299 }
300
301 let deployment_manager = Arc::new(DeploymentManager::new(dev_mode));
302 let (event_tx, _) = broadcast::channel(32);
303
304 Ok(Self {
305 sites_dir: sites_path,
306 port_allocator,
307 apps: Arc::new(Mutex::new(HashMap::new())),
308 config_manager,
309 deployment_manager,
310 watcher: Arc::new(Mutex::new(None)),
311 acme_service: Arc::new(Mutex::new(None)),
312 dev_mode,
313 event_tx,
314 })
315 }
316
317 pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
318 self.event_tx.subscribe()
319 }
320
321 fn emit_event(&self, event: AppEvent) {
322 let _ = self.event_tx.send(event);
323 }
324
325 pub async fn set_acme_service(&self, service: Arc<crate::acme::AcmeService>) {
326 *self.acme_service.lock().await = Some(service);
327 }
328
329 pub async fn discover_apps(&self) -> Result<(), anyhow::Error> {
330 tracing::info!("Discovering apps in {}", self.sites_dir.display());
331 let mut apps_to_start: Vec<String> = Vec::new();
332
333 {
334 let mut apps = self.apps.lock().await;
335
336 let mut seen_names: HashSet<String> = HashSet::new();
338
339 for entry in std::fs::read_dir(&self.sites_dir)? {
340 let entry = entry?;
341 let path = entry.path();
342
343 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
345 if name.starts_with('.') {
346 continue;
347 }
348 }
349
350 let resolved_path = if path.is_symlink() {
351 match path.canonicalize() {
352 Ok(p) => p,
353 Err(_) => path.clone(),
354 }
355 } else {
356 path.clone()
357 };
358 if resolved_path.is_dir() {
359 match AppInfo::from_path(&path, self.dev_mode) {
360 Ok(mut app_info) => {
361 let name = app_info.config.name.clone();
362 seen_names.insert(name.clone());
363
364 if let Some(existing) = apps.get(&name) {
365 app_info.blue.port = existing.blue.port;
367 app_info.blue.pid = existing.blue.pid;
368 app_info.blue.status = existing.blue.status.clone();
369 app_info.blue.last_started = existing.blue.last_started.clone();
370 app_info.green.port = existing.green.port;
371 app_info.green.pid = existing.green.pid;
372 app_info.green.status = existing.green.status.clone();
373 app_info.green.last_started = existing.green.last_started.clone();
374 app_info.current_slot = existing.current_slot.clone();
375 tracing::debug!("Refreshed config for app: {}", name);
376 } else {
377 tracing::info!("Discovered new app: {}", name);
378 let port_range_start = app_info.config.port_range_start;
380 let port_range_end = app_info.config.port_range_end;
381 match self
382 .port_allocator
383 .allocate_with_range(
384 &app_info.config.name,
385 "blue",
386 port_range_start,
387 port_range_end,
388 )
389 .await
390 {
391 Ok(port) => app_info.blue.port = port,
392 Err(e) => tracing::error!(
393 "Failed to allocate blue port for {}: {}",
394 app_info.config.name,
395 e
396 ),
397 }
398 match self
399 .port_allocator
400 .allocate_with_range(
401 &app_info.config.name,
402 "green",
403 port_range_start,
404 port_range_end,
405 )
406 .await
407 {
408 Ok(port) => app_info.green.port = port,
409 Err(e) => tracing::error!(
410 "Failed to allocate green port for {}: {}",
411 app_info.config.name,
412 e
413 ),
414 }
415 if app_info.config.start_script.is_some()
416 && !self.deployment_manager.is_deploying(&name)
417 {
418 apps_to_start.push(name.clone());
419 }
420 }
421 apps.insert(name, app_info);
422 }
423 Err(e) => {
424 tracing::warn!("Failed to load app from {}: {}", path.display(), e);
425 }
426 }
427 }
428 }
429
430 apps.retain(|name, _| seen_names.contains(name));
432 }
433
434 if !apps_to_start.is_empty() {
436 let manager = self.clone();
437 tokio::spawn(async move {
438 let mut handles = Vec::new();
439 for app_name in apps_to_start {
440 let mgr = manager.clone();
441 handles.push(tokio::spawn(async move {
442 tracing::info!("Auto-starting app: {}", app_name);
443 if let Err(e) = mgr.deploy(&app_name, "blue").await {
444 tracing::error!("Failed to auto-start {}: {}", app_name, e);
445 }
446 }));
447 }
448 for handle in handles {
449 let _ = handle.await;
450 }
451 });
452 }
453
454 self.sync_routes().await;
455 Ok(())
456 }
457
458 async fn sync_routes(&self) {
462 let apps = self.apps.lock().await;
463 let cfg = self.config_manager.get_config();
464 let mut rules = cfg.rules.clone();
465 let global_scripts = cfg.global_scripts.clone();
466
467 let mut app_domains: HashMap<String, u16> = HashMap::new();
469 for app in apps.values() {
470 if !app.config.domain.is_empty() {
471 let port = if app.current_slot == "blue" {
472 app.blue.port
473 } else {
474 app.green.port
475 };
476 app_domains.insert(app.config.domain.clone(), port);
477 if let Some(non_www) = strip_www(&app.config.domain) {
479 app_domains.insert(non_www, port);
480 }
481 if self.dev_mode {
483 if let Some(dev) = dev_domain(&app.config.domain) {
484 app_domains.insert(dev, port);
485 }
486 }
487 }
488 }
489
490 let mut existing_domains: HashMap<String, usize> = HashMap::new();
492 for (i, rule) in rules.iter().enumerate() {
493 if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
494 existing_domains.insert(domain.clone(), i);
495 }
496 }
497
498 let mut changed = false;
499
500 for (domain, port) in &app_domains {
502 let target_url = format!("http://localhost:{}", port);
503 if let Some(&idx) = existing_domains.get(domain) {
504 let current_target = rules[idx].targets.first().map(|t| t.url.to_string());
506 let expected = format!("{}/", target_url);
507 if current_target.as_deref() != Some(&expected) {
508 if let Ok(url) = Url::parse(&target_url) {
509 rules[idx].targets = vec![super::config::Target { url, weight: 100 }];
510 changed = true;
511 tracing::info!("Updated route for domain {} -> {}", domain, target_url);
512 }
513 }
514 } else {
515 if let Ok(url) = Url::parse(&target_url) {
517 rules.push(super::config::ProxyRule {
518 matcher: super::config::RuleMatcher::Domain(domain.clone()),
519 targets: vec![super::config::Target { url, weight: 100 }],
520 headers: vec![],
521 scripts: vec![],
522 auth: vec![],
523 load_balancing: super::config::LoadBalancingStrategy::default(),
524 });
525 changed = true;
526 tracing::info!("Added route for domain {} -> {}", domain, target_url);
527 }
528 }
529 }
530
531 let mut indices_to_remove: Vec<usize> = Vec::new();
533 for (i, rule) in rules.iter().enumerate() {
534 if let super::config::RuleMatcher::Domain(ref domain) = rule.matcher {
535 if !app_domains.contains_key(domain) {
536 let is_auto = rule
538 .targets
539 .iter()
540 .all(|t| t.url.host_str() == Some("localhost"));
541 if is_auto {
542 indices_to_remove.push(i);
543 tracing::info!("Removing orphaned route for domain {}", domain);
544 }
545 }
546 }
547 }
548
549 for idx in indices_to_remove.into_iter().rev() {
551 rules.remove(idx);
552 changed = true;
553 }
554
555 if changed {
556 if let Err(e) = self.config_manager.update_rules(rules, global_scripts) {
557 tracing::error!("Failed to sync routes: {}", e);
558 }
559 }
560
561 if let Some(ref acme) = *self.acme_service.lock().await {
563 for domain in app_domains.keys() {
564 if is_acme_eligible(domain) {
565 let acme = acme.clone();
566 let domain = domain.clone();
567 tokio::spawn(async move {
568 if let Err(e) = acme.ensure_certificate(&domain).await {
569 tracing::error!("Failed to issue cert for {}: {}", domain, e);
570 }
571 });
572 }
573 }
574 }
575 }
576
577 pub async fn start_watcher(&self) -> Result<(), anyhow::Error> {
578 let (tx, mut rx) = mpsc::channel(100);
579 let sites_dir = self.sites_dir.clone();
580 let manager = self.clone();
581
582 let watch_path = if sites_dir.is_symlink() {
583 sites_dir.canonicalize()?
584 } else {
585 sites_dir.clone()
586 };
587
588 let mut watcher = RecommendedWatcher::new(
589 move |res| {
590 let _ = tx.blocking_send(res);
591 },
592 notify::Config::default(),
593 )?;
594
595 watcher.watch(&watch_path, RecursiveMode::Recursive)?;
596
597 *self.watcher.lock().await = Some(watcher);
598
599 tokio::spawn(async move {
600 loop {
601 let mut changed_paths: HashSet<PathBuf> = HashSet::new();
603 let mut got_event = false;
604 while let Some(res) = rx.recv().await {
605 if let Ok(event) = res {
606 if event.kind.is_modify()
607 || event.kind.is_create()
608 || event.kind.is_remove()
609 {
610 changed_paths.extend(event.paths);
611 got_event = true;
612 break;
613 }
614 }
615 }
616 if !got_event {
617 break; }
619
620 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
622 while let Ok(res) = rx.try_recv() {
623 if let Ok(event) = res {
624 changed_paths.extend(event.paths);
625 }
626 }
627
628 tracing::info!("Apps directory changed, rediscovering...");
629 if let Err(e) = manager.discover_apps().await {
630 tracing::error!("Failed to rediscover apps: {}", e);
631 }
632
633 if manager.dev_mode {
635 let app_names = affected_app_names(&sites_dir, &changed_paths);
636 if !app_names.is_empty() {
637 let running_apps: Vec<String> = {
638 let apps = manager.apps.lock().await;
639 app_names
640 .into_iter()
641 .filter(|name| {
642 apps.get(name).is_some_and(|app| {
643 let instance = if app.current_slot == "blue" {
644 &app.blue
645 } else {
646 &app.green
647 };
648 instance.status == InstanceStatus::Running
649 })
650 })
651 .collect()
652 };
653 for app_name in running_apps {
654 tracing::info!(
655 "Dev mode: restarting app '{}' due to file changes",
656 app_name
657 );
658 if let Err(e) = manager.restart(&app_name).await {
659 tracing::error!("Failed to restart app '{}': {}", app_name, e);
660 }
661 }
662 }
663 }
664 }
665 });
666
667 Ok(())
668 }
669
670 pub async fn list_apps(&self) -> Vec<AppInfo> {
671 self.apps
672 .lock()
673 .await
674 .values()
675 .filter(|&a| a.config.name != "_admin")
676 .cloned()
677 .collect()
678 }
679
680 pub async fn get_app(&self, name: &str) -> Option<AppInfo> {
681 self.apps.lock().await.get(name).cloned()
682 }
683
684 pub async fn get_app_name(&self, port: u16) -> Option<String> {
685 self.port_allocator.get_app_name(port).await
686 }
687
688 pub async fn get_system_metrics(&self, metrics: &AppMetrics) -> serde_json::Value {
689 let apps = self.apps.lock().await;
690 let mut result = serde_json::Map::new();
691
692 for (name, app) in apps.iter() {
693 let mut app_metrics = serde_json::Map::new();
694
695 if let Some(pid) = app.blue.pid {
696 if let Some(stats) = metrics.get_process_stats(pid) {
697 app_metrics.insert(
698 "blue".to_string(),
699 serde_json::to_value(stats).unwrap_or_default(),
700 );
701 }
702 }
703
704 if let Some(pid) = app.green.pid {
705 if let Some(stats) = metrics.get_process_stats(pid) {
706 app_metrics.insert(
707 "green".to_string(),
708 serde_json::to_value(stats).unwrap_or_default(),
709 );
710 }
711 }
712
713 result.insert(name.clone(), serde_json::Value::Object(app_metrics));
714 }
715
716 serde_json::Value::Object(result)
717 }
718
719 pub async fn allocate_ports(&self, app_name: &str) -> Result<(u16, u16), anyhow::Error> {
720 let blue_port = self.port_allocator.allocate(app_name, "blue").await?;
721 let green_port = self.port_allocator.allocate(app_name, "green").await?;
722 Ok((blue_port, green_port))
723 }
724
725 pub async fn deploy(&self, app_name: &str, slot: &str) -> Result<(), anyhow::Error> {
726 tracing::info!("Starting deploy for {} to slot {}", app_name, slot);
727
728 let app = {
729 let apps = self.apps.lock().await;
730 match apps.get(app_name) {
731 Some(app) => {
732 tracing::debug!(
733 "Found app {}: blue={}:{}, green={}:{}",
734 app_name,
735 app.blue.status,
736 app.blue.port,
737 app.green.status,
738 app.green.port
739 );
740 app.clone()
741 }
742 None => {
743 tracing::error!("App not found: {}", app_name);
744 return Err(anyhow::anyhow!("App not found: {}", app_name));
745 }
746 }
747 };
748
749 tracing::info!("Deploying {} to slot {}", app.config.name, slot);
750 let pid = self.deployment_manager.deploy(&app, slot).await?;
751 tracing::info!("Deploy started, PID: {}", pid);
752
753 let old_slot_name;
755 let old_pid;
756 {
757 let apps = self.apps.lock().await;
758 match apps.get(app_name) {
759 Some(a) => {
760 old_slot_name = a.current_slot.clone();
761 old_pid = if old_slot_name == "blue" {
762 a.blue.pid
763 } else {
764 a.green.pid
765 };
766 tracing::info!(
767 "Current slot: {}, old_slot_name: {}, old_pid: {:?}",
768 app_name,
769 old_slot_name,
770 old_pid
771 );
772 }
773 None => {
774 old_slot_name = "unknown".to_string();
775 old_pid = None;
776 tracing::error!("App {} not found in apps map!", app_name);
777 }
778 }
779 }
780
781 {
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::Running;
791 instance.pid = Some(pid);
792 instance.last_started = Some(chrono::Utc::now().to_rfc3339());
793
794 app_info.current_slot = slot.to_string();
796 tracing::info!("Switched traffic from {} to {}", old_slot_name, slot);
797 } else {
798 tracing::error!("App {} not found in map after deploy!", app_name);
799 }
800 }
801
802 tracing::info!(
804 "Checking if should stop old slot: old_slot_name={}, slot={}",
805 old_slot_name,
806 slot
807 );
808 if old_slot_name != "unknown" && old_slot_name != slot {
809 if let Some(pid) = old_pid {
810 tracing::info!("Stopping old slot {} (PID: {})", old_slot_name, pid);
811 self.deployment_manager
812 .stop_instance(&app, &old_slot_name)
813 .await?;
814 tracing::info!("Old slot {} stopped", old_slot_name);
815
816 let mut apps = self.apps.lock().await;
818 if let Some(app_info) = apps.get_mut(app_name) {
819 let old_instance = if old_slot_name == "blue" {
820 &mut app_info.blue
821 } else {
822 &mut app_info.green
823 };
824 old_instance.status = InstanceStatus::Stopped;
825 old_instance.pid = None;
826 }
827 } else {
828 tracing::warn!(
829 "No PID found for old slot {} (status may already be stopped)",
830 old_slot_name
831 );
832 }
833 }
834
835 self.sync_routes().await;
836 tracing::info!("Deploy completed for {} to slot {}", app_name, slot);
837 self.emit_event(AppEvent::Deployed {
838 app_name: app_name.to_string(),
839 slot: slot.to_string(),
840 });
841 self.emit_event(AppEvent::StatusChanged {
842 app_name: app_name.to_string(),
843 slot: slot.to_string(),
844 status: "running".to_string(),
845 });
846 Ok(())
847 }
848
849 pub async fn restart(&self, app_name: &str) -> Result<(), anyhow::Error> {
850 let slot = {
851 let apps = self.apps.lock().await;
852 let app = apps
853 .get(app_name)
854 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?;
855 app.current_slot.clone()
856 };
857
858 self.stop(app_name).await?;
859 self.deploy(app_name, &slot).await
860 }
861
862 pub async fn rollback(&self, app_name: &str) -> Result<(), anyhow::Error> {
863 let (app, target_slot, old_slot) = {
864 let apps = self.apps.lock().await;
865 let app = apps
866 .get(app_name)
867 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
868 .clone();
869 let target_slot = if app.current_slot == "blue" {
870 "green"
871 } else {
872 "blue"
873 };
874 (
875 app.clone(),
876 target_slot.to_string(),
877 app.current_slot.clone(),
878 )
879 };
880
881 let pid = self.deployment_manager.deploy(&app, &target_slot).await?;
882
883 {
884 let mut apps = self.apps.lock().await;
885 if let Some(app_info) = apps.get_mut(app_name) {
886 app_info.current_slot = target_slot.clone();
887 let instance = if target_slot == "blue" {
888 &mut app_info.blue
889 } else {
890 &mut app_info.green
891 };
892 instance.status = InstanceStatus::Running;
893 instance.pid = Some(pid);
894 }
895 }
896
897 let old_pid = {
899 let apps = self.apps.lock().await;
900 apps.get(app_name).and_then(|a| {
901 if old_slot == "blue" {
902 a.blue.pid
903 } else {
904 a.green.pid
905 }
906 })
907 };
908 if let Some(pid) = old_pid {
909 tracing::info!(
910 "Stopping old slot {} (PID: {}) during rollback",
911 old_slot,
912 pid
913 );
914 self.deployment_manager
915 .stop_instance(&app, &old_slot)
916 .await?;
917 let mut apps = self.apps.lock().await;
919 if let Some(app_info) = apps.get_mut(app_name) {
920 let old_instance = if old_slot == "blue" {
921 &mut app_info.blue
922 } else {
923 &mut app_info.green
924 };
925 old_instance.status = InstanceStatus::Stopped;
926 old_instance.pid = None;
927 }
928 }
929
930 self.sync_routes().await;
931 self.emit_event(AppEvent::Deployed {
932 app_name: app_name.to_string(),
933 slot: target_slot,
934 });
935 Ok(())
936 }
937
938 pub async fn stop(&self, app_name: &str) -> Result<(), anyhow::Error> {
939 let (app, slot) = {
940 let apps = self.apps.lock().await;
941 let app = apps
942 .get(app_name)
943 .ok_or_else(|| anyhow::anyhow!("App not found: {}", app_name))?
944 .clone();
945 let slot = app.current_slot.clone();
946 (app, slot)
947 };
948
949 self.deployment_manager.stop_instance(&app, &slot).await?;
950
951 {
952 let mut apps = self.apps.lock().await;
953 if let Some(app_info) = apps.get_mut(app_name) {
954 let instance = if slot == "blue" {
955 &mut app_info.blue
956 } else {
957 &mut app_info.green
958 };
959 instance.status = InstanceStatus::Stopped;
960 instance.pid = None;
961 }
962 }
963
964 self.emit_event(AppEvent::Stopped {
965 app_name: app_name.to_string(),
966 slot: slot.clone(),
967 });
968 self.emit_event(AppEvent::StatusChanged {
969 app_name: app_name.to_string(),
970 slot,
971 status: "stopped".to_string(),
972 });
973
974 Ok(())
975 }
976
977 pub async fn stop_all(&self) {
978 let apps: Vec<String> = {
979 let apps_guard = self.apps.lock().await;
980 apps_guard.keys().cloned().collect()
981 };
982
983 for app_name in apps {
984 let app = {
986 let apps_guard = self.apps.lock().await;
987 apps_guard.get(&app_name).cloned()
988 };
989 if let Some(app) = app {
990 if app.blue.status == InstanceStatus::Running && app.blue.pid.is_some() {
992 if let Err(e) = self.deployment_manager.stop_instance(&app, "blue").await {
993 tracing::error!("Failed to stop blue slot for {}: {}", app_name, e);
994 }
995 }
996 if app.green.status == InstanceStatus::Running && app.green.pid.is_some() {
998 if let Err(e) = self.deployment_manager.stop_instance(&app, "green").await {
999 tracing::error!("Failed to stop green slot for {}: {}", app_name, e);
1000 }
1001 }
1002 let mut apps_guard = self.apps.lock().await;
1004 if let Some(app_info) = apps_guard.get_mut(&app_name) {
1005 app_info.blue.status = InstanceStatus::Stopped;
1006 app_info.blue.pid = None;
1007 app_info.green.status = InstanceStatus::Stopped;
1008 app_info.green.pid = None;
1009 }
1010 }
1011 }
1012 }
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018 use tempfile::TempDir;
1019
1020 #[tokio::test]
1021 async fn test_app_info_parsing() {
1022 let temp_dir = TempDir::new().unwrap();
1023 let app_path = temp_dir.path().join("test.solisoft.net");
1024 std::fs::create_dir_all(&app_path).unwrap();
1025
1026 let app_infos = r#"
1027name = "test.solisoft.net"
1028domain = "test.solisoft.net"
1029start_script = "./start.sh"
1030stop_script = "./stop.sh"
1031health_check = "/health"
1032graceful_timeout = 30
1033port_range_start = 9000
1034port_range_end = 9999
1035"#;
1036 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
1037
1038 let app_info = AppInfo::from_path(&app_path, false).unwrap();
1039 assert_eq!(app_info.config.name, "test.solisoft.net");
1040 assert_eq!(app_info.config.domain, "test.solisoft.net");
1041 assert_eq!(app_info.config.start_script, Some("./start.sh".to_string()));
1042 }
1043
1044 #[test]
1045 fn test_dev_domain() {
1046 assert_eq!(
1047 dev_domain("soli.solisoft.net"),
1048 Some("soli.solisoft.test".to_string())
1049 );
1050 assert_eq!(
1051 dev_domain("app.example.com"),
1052 Some("app.example.test".to_string())
1053 );
1054 assert_eq!(dev_domain("example.org"), Some("example.test".to_string()));
1055 assert_eq!(dev_domain("app.example.test"), None);
1057 assert_eq!(dev_domain("app.localhost"), None);
1059 assert_eq!(dev_domain("localhost"), None);
1061 }
1062
1063 #[test]
1064 fn test_is_valid_domain() {
1065 assert!(is_valid_domain("www.solisoft.net"));
1066 assert!(is_valid_domain("solisoft.net"));
1067 assert!(is_valid_domain("sub.example.com"));
1068 assert!(is_valid_domain("_admin"));
1069 assert!(!is_valid_domain(""));
1070 assert!(!is_valid_domain("myapp"));
1071 assert!(!is_valid_domain(".claude"));
1072 assert!(!is_valid_domain(".hidden"));
1073 }
1074
1075 #[test]
1076 fn test_strip_www() {
1077 assert_eq!(
1078 strip_www("www.solisoft.net"),
1079 Some("solisoft.net".to_string())
1080 );
1081 assert_eq!(
1082 strip_www("www.example.com"),
1083 Some("example.com".to_string())
1084 );
1085 assert_eq!(strip_www("solisoft.net"), None);
1086 assert_eq!(strip_www("www."), None);
1087 assert_eq!(strip_www("wwww.solisoft.net"), None);
1088 }
1089
1090 #[test]
1091 fn test_is_acme_eligible_excludes_dev() {
1092 assert!(!is_acme_eligible("app.example.test"));
1093 assert!(!is_acme_eligible("localhost"));
1094 assert!(!is_acme_eligible("app.localhost"));
1095 assert!(is_acme_eligible("app.example.com"));
1096 }
1097
1098 #[test]
1099 fn test_luaonbeans_auto_detected_no_app_infos() {
1100 let temp_dir = TempDir::new().unwrap();
1101 let app_path = temp_dir.path().join("myapp.example.com");
1102 std::fs::create_dir_all(&app_path).unwrap();
1103 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
1104
1105 let app_info = AppInfo::from_path(&app_path, false).unwrap();
1106 assert_eq!(app_info.config.name, "myapp.example.com");
1107 assert_eq!(app_info.config.domain, "myapp.example.com");
1108 assert_eq!(
1109 app_info.config.start_script,
1110 Some("./luaonbeans.org -D . -p $PORT -s".to_string())
1111 );
1112 assert_eq!(app_info.config.health_check, Some("/".to_string()));
1113 }
1114
1115 #[test]
1116 fn test_luaonbeans_auto_detected_with_partial_app_infos() {
1117 let temp_dir = TempDir::new().unwrap();
1118 let app_path = temp_dir.path().join("myapp.example.com");
1119 std::fs::create_dir_all(&app_path).unwrap();
1120 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
1121
1122 let app_infos = r#"
1123name = "myapp.example.com"
1124domain = "custom.example.com"
1125graceful_timeout = 30
1126port_range_start = 9000
1127port_range_end = 9999
1128"#;
1129 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
1130
1131 let app_info = AppInfo::from_path(&app_path, false).unwrap();
1132 assert_eq!(app_info.config.name, "myapp.example.com");
1133 assert_eq!(app_info.config.domain, "custom.example.com");
1134 assert_eq!(
1135 app_info.config.start_script,
1136 Some("./luaonbeans.org -D . -p $PORT -s".to_string())
1137 );
1138 assert_eq!(app_info.config.health_check, Some("/".to_string()));
1139 }
1140
1141 #[test]
1142 fn test_no_override_when_start_script_set() {
1143 let temp_dir = TempDir::new().unwrap();
1144 let app_path = temp_dir.path().join("myapp.example.com");
1145 std::fs::create_dir_all(&app_path).unwrap();
1146 std::fs::write(app_path.join("luaonbeans.org"), b"").unwrap();
1147
1148 let app_infos = r#"
1149name = "myapp.example.com"
1150domain = "myapp.example.com"
1151start_script = "./custom-start.sh"
1152health_check = "/health"
1153graceful_timeout = 30
1154port_range_start = 9000
1155port_range_end = 9999
1156"#;
1157 std::fs::write(app_path.join("app.infos"), app_infos).unwrap();
1158
1159 let app_info = AppInfo::from_path(&app_path, false).unwrap();
1160 assert_eq!(
1161 app_info.config.start_script,
1162 Some("./custom-start.sh".to_string())
1163 );
1164 assert_eq!(app_info.config.health_check, Some("/health".to_string()));
1165 }
1166
1167 #[test]
1168 fn test_no_detection_without_luaonbeans_or_app_infos() {
1169 let temp_dir = TempDir::new().unwrap();
1170 let app_path = temp_dir.path().join("emptyapp.example.com");
1171 std::fs::create_dir_all(&app_path).unwrap();
1172
1173 let app_info = AppInfo::from_path(&app_path, false).unwrap();
1174 assert_eq!(app_info.config.name, "emptyapp.example.com");
1175 assert!(app_info.config.start_script.is_none());
1176 assert_eq!(app_info.config.health_check, Some("/health".to_string()));
1177 }
1178}