diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 7fd0b30f..499c0cdf 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -2008,6 +2008,99 @@ fn parse_volume_mapping(vol_str: &str) -> (String, String, bool) { } } +#[derive(Debug, Clone)] +struct NginxProxyManagerOverrides { + shared_ports: Vec, + volumes: Vec, + environment: Vec, +} + +fn parse_compose_port_mapping(port_str: &str) -> Option<(String, String)> { + // Remove protocol suffix like "/tcp", "/udp" + let port_no_proto = port_str.split('/').next().unwrap_or(port_str); + let parts: Vec<&str> = port_no_proto.split(':').collect(); + match parts.len() { + // "ip:host:container" + 3 => Some((parts[1].to_string(), parts[2].to_string())), + // "host:container" + 2 => Some((parts[0].to_string(), parts[1].to_string())), + // "container" + 1 => Some((parts[0].to_string(), parts[0].to_string())), + _ => None, + } +} + +fn resolve_compose_path(config: &StackerConfig) -> Option { + let path = config.deploy.compose_file.as_ref()?; + if path.is_absolute() { + Some(path.clone()) + } else { + std::env::current_dir().ok().map(|cwd| cwd.join(path)) + } +} + +fn nginx_proxy_manager_overrides_from_compose( + config: &StackerConfig, +) -> Option { + let compose_path = resolve_compose_path(config)?; + let compose_content = std::fs::read_to_string(&compose_path).ok()?; + let services = crate::helpers::project::builder::parse_compose_services(&compose_content).ok()?; + + let npm = services.into_iter().find(|svc| { + let name = svc.name.to_lowercase(); + let name_match = + matches!(name.as_str(), "nginx_proxy_manager" | "nginx-proxy-manager" | "npm"); + let image_match = svc + .image + .as_ref() + .map(|i| i.to_lowercase().contains("nginx-proxy-manager")) + .unwrap_or(false); + name_match || image_match + })?; + + let shared_ports: Vec = npm + .ports + .iter() + .filter_map(|p| parse_compose_port_mapping(p)) + .map(|(host, container)| { + serde_json::json!({ + "host_port": host, + "container_port": container, + }) + }) + .collect(); + + let volumes: Vec = npm + .volumes + .iter() + .map(|v| { + let (host, container, read_only) = parse_volume_mapping(v); + let container_path = if read_only { + format!("{}:ro", container) + } else { + container + }; + serde_json::json!({ + "host_path": host, + "container_path": container_path, + }) + }) + .collect(); + + let environment: Vec = npm + .environment + .iter() + .filter_map(|kv| kv.split_once('=')) + .map(|(k, v)| serde_json::json!({ "key": k, "value": v })) + .collect(); + + Some(NginxProxyManagerOverrides { + shared_ports, + volumes, + environment, + }) +} + /// Convert a `ServiceDefinition` from stacker.yml into the Stacker server's /// app JSON format (matching `forms::project::App` / `forms::project::Web`). fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde_json::Value { @@ -2220,7 +2313,9 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value { match config.proxy.proxy_type { crate::cli::config_parser::ProxyType::Nginx | crate::cli::config_parser::ProxyType::NginxProxyManager => { - features.push(serde_json::json!({ + let overrides = nginx_proxy_manager_overrides_from_compose(config); + + let mut npm = serde_json::json!({ "_id": generate_app_id(), "name": "Nginx Proxy Manager", "code": "nginx_proxy_manager", @@ -2236,7 +2331,29 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value { "dockerhub_user": "jc21", "dockerhub_name": "nginx-proxy-manager", "dockerhub_tag": "latest", - })); + }); + + if let Some(overrides) = overrides { + if let Some(obj) = npm.as_object_mut() { + if !overrides.shared_ports.is_empty() { + obj.insert( + "shared_ports".to_string(), + serde_json::json!(overrides.shared_ports), + ); + } + if !overrides.volumes.is_empty() { + obj.insert("volumes".to_string(), serde_json::json!(overrides.volumes)); + } + if !overrides.environment.is_empty() { + obj.insert( + "environment".to_string(), + serde_json::json!(overrides.environment), + ); + } + } + } + + features.push(npm); } _ => {} } @@ -2455,6 +2572,8 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; #[test] fn test_build_deploy_form_defaults() { @@ -2639,6 +2758,58 @@ mod tests { assert_eq!(ports.len(), 3); } + #[test] + fn test_build_project_body_nginx_proxy_manager_preserves_volumes_from_compose_file() { + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.prod.yml"); + fs::write( + &compose_path, + r#" +version: "3.8" +services: + nginx_proxy_manager: + image: jc21/nginx-proxy-manager:latest + ports: + - "80:80" + - "81:81" + - "443:443" + volumes: + - npm-data:/data + - npm-letsencrypt:/etc/letsencrypt +volumes: + npm-data: + npm-letsencrypt: +"#, + ) + .unwrap(); + + let mut config = crate::cli::config_parser::ConfigBuilder::new() + .name("myproject") + .proxy(crate::cli::config_parser::ProxyConfig { + proxy_type: crate::cli::config_parser::ProxyType::NginxProxyManager, + auto_detect: true, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + config.deploy.compose_file = Some(compose_path); + + let body = build_project_body(&config); + let features = body["custom"]["feature"].as_array().unwrap(); + let npm = features + .iter() + .find(|f| f["code"] == "nginx_proxy_manager") + .unwrap(); + + let volumes = npm["volumes"].as_array().unwrap(); + assert_eq!(volumes.len(), 2); + assert_eq!(volumes[0]["host_path"], "npm-data"); + assert_eq!(volumes[0]["container_path"], "/data"); + assert_eq!(volumes[1]["host_path"], "npm-letsencrypt"); + assert_eq!(volumes[1]["container_path"], "/etc/letsencrypt"); + } + #[test] fn test_build_project_body_with_status_panel() { let config = crate::cli::config_parser::ConfigBuilder::new() diff --git a/src/helpers/project/builder.rs b/src/helpers/project/builder.rs index 93d2d2c2..dde020da 100644 --- a/src/helpers/project/builder.rs +++ b/src/helpers/project/builder.rs @@ -212,6 +212,9 @@ impl DcBuilder { let serialized = serde_yaml::to_string(&compose_content) .map_err(|err| format!("Failed to serialize docker-compose file: {}", err))?; + if let Some(parent) = target_file.parent() { + std::fs::create_dir_all(parent).map_err(|err| format!("{}", err))?; + } std::fs::write(target_file, serialized.clone()).map_err(|err| format!("{}", err))?; Ok(serialized) @@ -391,3 +394,177 @@ pub fn generate_single_app_compose( serde_yaml::to_string(&compose) .map_err(|err| format!("Failed to serialize docker-compose: {}", err)) } + +#[cfg(test)] +mod tests { + use super::DcBuilder; + use crate::cli::config_parser::{ConfigBuilder, ProxyConfig, ProxyType}; + use crate::cli::stacker_client::build_project_body; + use crate::models::Project; + use docker_compose_types as dctypes; + use tempfile::TempDir; + + fn assert_npm_volumes_present(compose_yaml: &str) { + let compose: dctypes::Compose = serde_yaml::from_str(compose_yaml).unwrap(); + let npm = compose + .services + .0 + .get("nginx_proxy_manager") + .and_then(|svc| svc.as_ref()) + .expect("nginx_proxy_manager service missing from generated compose"); + + let mut found_data = false; + let mut found_letsencrypt = false; + + for vol in &npm.volumes { + match vol { + dctypes::Volumes::Simple(s) => { + if s.starts_with("npm-data:") && s.contains(":/data") { + found_data = true; + } + if s.starts_with("npm-letsencrypt:") && s.contains(":/etc/letsencrypt") { + found_letsencrypt = true; + } + } + dctypes::Volumes::Advanced(adv) => { + if adv.source.as_deref() == Some("npm-data") && adv.target == "/data" { + found_data = true; + } + if adv.source.as_deref() == Some("npm-letsencrypt") + && adv.target == "/etc/letsencrypt" + { + found_letsencrypt = true; + } + } + } + } + + assert!(found_data, "npm-data:/data volume missing"); + assert!(found_letsencrypt, "npm-letsencrypt:/etc/letsencrypt volume missing"); + + // Ensure the top-level named volumes are declared. + assert!(compose.volumes.0.contains_key("npm-data")); + assert!(compose.volumes.0.contains_key("npm-letsencrypt")); + } + + #[test] + fn regression_generated_compose_preserves_nginx_proxy_manager_named_volumes() { + // Ensure DcBuilder can write its debug output file. + std::fs::create_dir_all("files").unwrap(); + + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.prod.yml"); + std::fs::write( + &compose_path, + r#" +version: "3.8" +services: + nginx_proxy_manager: + image: jc21/nginx-proxy-manager:latest + ports: + - "80:80" + - "81:81" + - "443:443" + volumes: + - npm-data:/data + - npm-letsencrypt:/etc/letsencrypt +volumes: + npm-data: + npm-letsencrypt: +"#, + ) + .unwrap(); + + let mut config = ConfigBuilder::new() + .name("myproject") + .proxy(ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: true, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + config.deploy.compose_file = Some(compose_path); + + let metadata = build_project_body(&config); + let project = Project::new( + "user".to_string(), + "myproject".to_string(), + metadata, + serde_json::json!({}), + ); + + let compose_yaml = DcBuilder::new(project).build().unwrap(); + + assert_npm_volumes_present(&compose_yaml); + } + + #[test] + fn bdd_scenario_redeploy_keeps_nginx_proxy_manager_stateful_volumes() { + println!("\nScenario: Redeploying a stack with nginx-proxy-manager keeps its stateful volumes"); + println!(" Given a production compose that declares npm-data:/data and npm-letsencrypt:/etc/letsencrypt"); + + std::fs::create_dir_all("files").unwrap(); + + let dir = TempDir::new().unwrap(); + let compose_path = dir.path().join("docker-compose.prod.yml"); + std::fs::write( + &compose_path, + r#" +version: "3.8" +services: + nginx_proxy_manager: + image: jc21/nginx-proxy-manager:latest + ports: + - "80:80" + - "81:81" + - "443:443" + volumes: + - npm-data:/data + - npm-letsencrypt:/etc/letsencrypt +volumes: + npm-data: + npm-letsencrypt: +"#, + ) + .unwrap(); + + let mut config = ConfigBuilder::new() + .name("myproject") + .proxy(ProxyConfig { + proxy_type: ProxyType::NginxProxyManager, + auto_detect: true, + domains: vec![], + config: None, + }) + .build() + .unwrap(); + config.deploy.compose_file = Some(compose_path); + + println!(" When the project metadata is generated and deployed"); + let metadata_v1 = build_project_body(&config); + let project_v1 = Project::new( + "user".to_string(), + "myproject".to_string(), + metadata_v1, + serde_json::json!({}), + ); + let compose_v1 = DcBuilder::new(project_v1).build().unwrap(); + + println!(" And the same stack is redeployed (regenerating metadata/compose)"); + let metadata_v2 = build_project_body(&config); + let project_v2 = Project::new( + "user".to_string(), + "myproject".to_string(), + metadata_v2, + serde_json::json!({}), + ); + let compose_v2 = DcBuilder::new(project_v2).build().unwrap(); + + println!(" Then both deploys include the nginx-proxy-manager volumes"); + for compose_yaml in [compose_v1, compose_v2] { + assert_npm_volumes_present(&compose_yaml); + } + } +}