From 80d8f975b437fa17d33841bc68fc32f51feb909d Mon Sep 17 00:00:00 2001 From: Mitch Hayes Date: Mon, 22 Jun 2026 06:46:37 +1000 Subject: [PATCH] feat(deploy): add multi-route support [SLIP-74] --- crates/slip-core/src/api.rs | 15 +- crates/slip-core/src/caddy.rs | 385 +++++++++++++++++++++++----- crates/slip-core/src/config.rs | 161 +++++++++++- crates/slip-core/src/deploy.rs | 98 ++++++- crates/slip-core/src/error.rs | 4 + crates/slip-core/src/merge.rs | 201 ++++++++++++++- crates/slip-core/src/preview.rs | 46 +++- crates/slip-core/src/repo_config.rs | 127 +++++++++ crates/slip-core/src/state.rs | 90 +++++-- crates/slip-core/src/validate.rs | 106 ++++++++ 10 files changed, 1132 insertions(+), 101 deletions(-) diff --git a/crates/slip-core/src/api.rs b/crates/slip-core/src/api.rs index 1ff0718..3f28274 100644 --- a/crates/slip-core/src/api.rs +++ b/crates/slip-core/src/api.rs @@ -569,6 +569,7 @@ async fn handle_create_app( routing: crate::config::RoutingConfig { domain: Some(req.domain), port: Some(req.port), + routes: vec![], }, health: req.health.unwrap_or_default(), deploy: req.deploy.unwrap_or_default(), @@ -724,9 +725,16 @@ async fn handle_delete_app( } } - // Remove Caddy route - if let Err(e) = state.caddy.remove_route(&name).await { - warn!(app = %name, error = %e, "failed to remove Caddy route during app deletion"); + // Remove Caddy routes + let route_count = state + .app_states + .read() + .await + .get(&name) + .map(|s| s.current_routes.len()) + .unwrap_or(1); + if let Err(e) = state.caddy.remove_routes(&name, route_count).await { + warn!(app = %name, error = %e, "failed to remove Caddy routes during app deletion"); } // Remove deploy lock @@ -1539,6 +1547,7 @@ mod tests { routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig::default(), deploy: DeployConfig::default(), diff --git a/crates/slip-core/src/caddy.rs b/crates/slip-core/src/caddy.rs index 6efab53..3b335fb 100644 --- a/crates/slip-core/src/caddy.rs +++ b/crates/slip-core/src/caddy.rs @@ -4,6 +4,15 @@ use crate::config::CaddyTlsConfig; use crate::error::CaddyError; use serde_json::json; +// ─── Types ───────────────────────────────────────────────────────────────────── + +/// A single route to be registered with the reverse proxy. +#[derive(Debug, Clone)] +pub struct Route { + pub hostname: String, + pub port: u16, +} + // ─── Trait ──────────────────────────────────────────────────────────────────── /// Abstraction over reverse-proxy route management used by the deploy @@ -24,6 +33,25 @@ pub trait ReverseProxy: Send + Sync { &'a self, app_name: &'a str, ) -> std::pin::Pin> + Send + 'a>>; + + /// Create or update multiple routes for an app. + /// + /// Each route gets a unique `@id = "slip-{app_name}-{index}"`. + fn set_routes<'a>( + &'a self, + app_name: &'a str, + routes: &'a [Route], + ) -> std::pin::Pin> + Send + 'a>>; + + /// Remove all routes for an app. + /// + /// `route_count` specifies how many `@id`s to delete (0..route_count). + /// A 404 for any individual route is treated as success (idempotent). + fn remove_routes<'a>( + &'a self, + app_name: &'a str, + route_count: usize, + ) -> std::pin::Pin> + Send + 'a>>; } impl ReverseProxy for CaddyClient { @@ -34,12 +62,15 @@ impl ReverseProxy for CaddyClient { upstream_port: u16, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(CaddyClient::set_route( - self, - app_name, - domain, - upstream_port, - )) + let app_name = app_name.to_string(); + let domain = domain.to_string(); + Box::pin(async move { + let routes = vec![Route { + hostname: domain, + port: upstream_port, + }]; + CaddyClient::set_routes(self, &app_name, &routes).await + }) } fn remove_route<'a>( @@ -47,7 +78,29 @@ impl ReverseProxy for CaddyClient { app_name: &'a str, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(CaddyClient::remove_route(self, app_name)) + let app_name = app_name.to_string(); + Box::pin(async move { CaddyClient::remove_routes(self, &app_name, 1).await }) + } + + fn set_routes<'a>( + &'a self, + app_name: &'a str, + routes: &'a [Route], + ) -> std::pin::Pin> + Send + 'a>> + { + let app_name = app_name.to_string(); + let routes = routes.to_vec(); + Box::pin(async move { CaddyClient::set_routes(self, &app_name, &routes).await }) + } + + fn remove_routes<'a>( + &'a self, + app_name: &'a str, + route_count: usize, + ) -> std::pin::Pin> + Send + 'a>> + { + let app_name = app_name.to_string(); + Box::pin(async move { CaddyClient::remove_routes(self, &app_name, route_count).await }) } } @@ -125,69 +178,104 @@ impl CaddyClient { domain: &str, upstream_port: u16, ) -> Result<(), CaddyError> { - let route_id = format!("slip-{app_name}"); - let route = json!({ - "@id": route_id, - "match": [{"host": [domain]}], - "handle": [{ - "handler": "subroute", - "routes": [{ - "handle": [{ - "handler": "reverse_proxy", - "upstreams": [{"dial": format!("localhost:{upstream_port}")}] - }] - }] - }], - "terminal": true - }); + let routes = vec![Route { + hostname: domain.to_string(), + port: upstream_port, + }]; + self.set_routes(app_name, &routes).await + } - // Try to update an existing route via @id. - let patch_url = format!("{}/id/{route_id}", self.base_url); - let patch_resp = self.client.patch(&patch_url).json(&route).send().await?; - if patch_resp.status().is_success() { - return Ok(()); - } + /// Create or update multiple routes for an app. + /// + /// Each route gets a unique `@id = "slip-{app_name}-{index}"`. + pub async fn set_routes(&self, app_name: &str, routes: &[Route]) -> Result<(), CaddyError> { + for (i, route) in routes.iter().enumerate() { + let route_id = format!("slip-{app_name}-{i}"); + let route_body = json!({ + "@id": route_id, + "match": [{"host": [route.hostname]}], + "handle": [{ + "handler": "subroute", + "routes": [{ + "handle": [{ + "handler": "reverse_proxy", + "upstreams": [{"dial": format!("localhost:{}", route.port)}] + }] + }] + }], + "terminal": true + }); + + // Try to update an existing route via @id. + let patch_url = format!("{}/id/{route_id}", self.base_url); + let patch_resp = self + .client + .patch(&patch_url) + .json(&route_body) + .send() + .await?; + if patch_resp.status().is_success() { + continue; + } - // Route didn't exist — append it. - let post_url = format!("{}/config/apps/http/servers/slip/routes", self.base_url); - let post_resp = self.client.post(&post_url).json(&route).send().await?; - if post_resp.status().is_success() { - Ok(()) - } else { + // Route didn't exist — append it. + let post_url = format!("{}/config/apps/http/servers/slip/routes", self.base_url); + let post_resp = self.client.post(&post_url).json(&route_body).send().await?; + if post_resp.status().is_success() { + continue; + } let status = post_resp.status(); let text = post_resp.text().await.unwrap_or_default(); - Err(CaddyError::RouteUpdateFailed(format!( + return Err(CaddyError::RouteUpdateFailed(format!( "POST {post_url} returned {status}: {text}" - ))) + ))); } + Ok(()) } /// Remove the reverse-proxy route for an app. /// /// A 404 response is treated as success (route already gone). pub async fn remove_route(&self, app_name: &str) -> Result<(), CaddyError> { - let route_id = format!("slip-{app_name}"); - let url = format!("{}/id/{route_id}", self.base_url); - let resp = self.client.delete(&url).send().await?; + self.remove_routes(app_name, 1).await + } - if resp.status().is_success() || resp.status() == reqwest::StatusCode::NOT_FOUND { - Ok(()) - } else { + /// Remove all routes for an app. + /// + /// Iterates from `0..route_count` and DELETE `/id/slip-{app_name}-{index}`. + /// A 404 for any individual route is treated as success (idempotent). + pub async fn remove_routes( + &self, + app_name: &str, + route_count: usize, + ) -> Result<(), CaddyError> { + for i in 0..route_count { + let route_id = format!("slip-{app_name}-{i}"); + let url = format!("{}/id/{route_id}", self.base_url); + let resp = self.client.delete(&url).send().await?; + + if resp.status().is_success() || resp.status() == reqwest::StatusCode::NOT_FOUND { + continue; + } let status = resp.status(); let text = resp.text().await.unwrap_or_default(); - Err(CaddyError::RouteUpdateFailed(format!( + return Err(CaddyError::RouteUpdateFailed(format!( "DELETE {url} returned {status}: {text}" - ))) + ))); } + Ok(()) } /// Reconcile all routes from a slice of `RouteInfo`. /// - /// Calls `set_route` for every entry. Returns the first error encountered. + /// Calls `set_routes` for every entry. Returns the first error encountered. pub async fn reconcile(&self, routes: &[RouteInfo]) -> Result<(), CaddyError> { for route in routes { - self.set_route(&route.app_name, &route.domain, route.port) - .await?; + let r = vec![Route { + hostname: route.domain.clone(), + port: route.port, + }]; + self.set_routes(&route.app_name, &r).await?; } Ok(()) } @@ -549,11 +637,11 @@ mod tests { let map = state.lock().await; assert!( - map.contains_key("slip-walden-api"), + map.contains_key("slip-walden-api-0"), "route should have been stored" ); assert_eq!( - map["slip-walden-api"]["@id"], "slip-walden-api", + map["slip-walden-api-0"]["@id"], "slip-walden-api-0", "@id field should match" ); } @@ -565,8 +653,8 @@ mod tests { // Pre-populate a route so PATCH will succeed. state.lock().await.insert( - "slip-myapp".to_string(), - json!({"@id": "slip-myapp", "port": 9000}), + "slip-myapp-0".to_string(), + json!({"@id": "slip-myapp-0", "port": 9000}), ); client @@ -576,7 +664,7 @@ mod tests { let map = state.lock().await; // The route should now reflect the new upstream port. - let route = &map["slip-myapp"]; + let route = &map["slip-myapp-0"]; let dial = route["handle"][0]["routes"][0]["handle"][0]["upstreams"][0]["dial"] .as_str() .unwrap_or(""); @@ -588,10 +676,10 @@ mod tests { let (port, state) = start_mock_caddy().await; let client = CaddyClient::new(format!("http://127.0.0.1:{port}")); - state - .lock() - .await - .insert("slip-todelete".to_string(), json!({"@id": "slip-todelete"})); + state.lock().await.insert( + "slip-todelete-0".to_string(), + json!({"@id": "slip-todelete-0"}), + ); client .remove_route("todelete") @@ -599,7 +687,7 @@ mod tests { .expect("remove_route should succeed"); assert!( - !state.lock().await.contains_key("slip-todelete"), + !state.lock().await.contains_key("slip-todelete-0"), "route should have been removed" ); } @@ -646,19 +734,196 @@ mod tests { let map = state.lock().await; assert!( - map.contains_key("slip-app-one"), + map.contains_key("slip-app-one-0"), "app-one should be registered" ); assert!( - map.contains_key("slip-app-two"), + map.contains_key("slip-app-two-0"), "app-two should be registered" ); assert!( - map.contains_key("slip-app-three"), + map.contains_key("slip-app-three-0"), "app-three should be registered" ); } + // ----------------------------------------------------------------------- + // Multi-route tests + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_set_routes_creates_multiple_routes() { + let (port, state) = start_mock_caddy().await; + let client = CaddyClient::new(format!("http://127.0.0.1:{port}")); + + let routes = vec![ + Route { + hostname: "api.example.com".to_string(), + port: 3000, + }, + Route { + hostname: "admin.example.com".to_string(), + port: 3001, + }, + ]; + + client + .set_routes("myapp", &routes) + .await + .expect("set_routes should succeed"); + + let map = state.lock().await; + assert!(map.contains_key("slip-myapp-0"), "route 0 should exist"); + assert!(map.contains_key("slip-myapp-1"), "route 1 should exist"); + assert_eq!( + map["slip-myapp-0"]["@id"], "slip-myapp-0", + "route 0 @id should match" + ); + assert_eq!( + map["slip-myapp-1"]["@id"], "slip-myapp-1", + "route 1 @id should match" + ); + let dial0 = + map["slip-myapp-0"]["handle"][0]["routes"][0]["handle"][0]["upstreams"][0]["dial"] + .as_str() + .unwrap_or(""); + assert_eq!(dial0, "localhost:3000", "route 0 dial should be correct"); + let dial1 = + map["slip-myapp-1"]["handle"][0]["routes"][0]["handle"][0]["upstreams"][0]["dial"] + .as_str() + .unwrap_or(""); + assert_eq!(dial1, "localhost:3001", "route 1 dial should be correct"); + } + + #[tokio::test] + async fn test_set_routes_updates_existing_routes() { + let (port, state) = start_mock_caddy().await; + let client = CaddyClient::new(format!("http://127.0.0.1:{port}")); + + // Pre-populate routes so PATCH will succeed. + state.lock().await.insert( + "slip-myapp-0".to_string(), + json!({"@id": "slip-myapp-0", "port": 9000}), + ); + state.lock().await.insert( + "slip-myapp-1".to_string(), + json!({"@id": "slip-myapp-1", "port": 9001}), + ); + + let routes = vec![ + Route { + hostname: "api.example.com".to_string(), + port: 3000, + }, + Route { + hostname: "admin.example.com".to_string(), + port: 3001, + }, + ]; + + client + .set_routes("myapp", &routes) + .await + .expect("set_routes should succeed"); + + let map = state.lock().await; + let dial0 = + map["slip-myapp-0"]["handle"][0]["routes"][0]["handle"][0]["upstreams"][0]["dial"] + .as_str() + .unwrap_or(""); + assert_eq!(dial0, "localhost:3000", "route 0 dial should be updated"); + let dial1 = + map["slip-myapp-1"]["handle"][0]["routes"][0]["handle"][0]["upstreams"][0]["dial"] + .as_str() + .unwrap_or(""); + assert_eq!(dial1, "localhost:3001", "route 1 dial should be updated"); + } + + #[tokio::test] + async fn test_remove_routes_removes_all() { + let (port, state) = start_mock_caddy().await; + let client = CaddyClient::new(format!("http://127.0.0.1:{port}")); + + state + .lock() + .await + .insert("slip-myapp-0".to_string(), json!({"@id": "slip-myapp-0"})); + state + .lock() + .await + .insert("slip-myapp-1".to_string(), json!({"@id": "slip-myapp-1"})); + state + .lock() + .await + .insert("slip-myapp-2".to_string(), json!({"@id": "slip-myapp-2"})); + + client + .remove_routes("myapp", 3) + .await + .expect("remove_routes should succeed"); + + let map = state.lock().await; + assert!( + !map.contains_key("slip-myapp-0"), + "route 0 should be removed" + ); + assert!( + !map.contains_key("slip-myapp-1"), + "route 1 should be removed" + ); + assert!( + !map.contains_key("slip-myapp-2"), + "route 2 should be removed" + ); + } + + #[tokio::test] + async fn test_remove_routes_ignores_not_found() { + let (port, _state) = start_mock_caddy().await; + let client = CaddyClient::new(format!("http://127.0.0.1:{port}")); + + // No routes exist — should be OK. + client + .remove_routes("nonexistent", 3) + .await + .expect("remove_routes on nonexistent routes should succeed"); + } + + #[tokio::test] + async fn test_reconcile_with_multi_route_apps() { + let (port, state) = start_mock_caddy().await; + let client = CaddyClient::new(format!("http://127.0.0.1:{port}")); + + // RouteInfo uses one entry per app. Each app gets routes starting at index 0. + let routes = vec![ + RouteInfo { + app_name: "app-one".to_string(), + domain: "one.example.com".to_string(), + port: 8001, + }, + RouteInfo { + app_name: "app-two".to_string(), + domain: "two.example.com".to_string(), + port: 8002, + }, + ]; + + client + .reconcile(&routes) + .await + .expect("reconcile should succeed"); + + let map = state.lock().await; + assert!( + map.contains_key("slip-app-one-0"), + "app-one route should exist" + ); + assert!( + map.contains_key("slip-app-two-0"), + "app-two route should exist" + ); + } + // ----------------------------------------------------------------------- // configure_tls tests // ----------------------------------------------------------------------- diff --git a/crates/slip-core/src/config.rs b/crates/slip-core/src/config.rs index c191bea..6d7244b 100644 --- a/crates/slip-core/src/config.rs +++ b/crates/slip-core/src/config.rs @@ -344,16 +344,57 @@ pub struct AppInfo { pub secret: Option, } +/// A single route entry in the server-side routing config. +/// +/// The server provides the `hostname` (domain). The `port` is optional +/// because the repo config may provide it instead. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RouteEntry { + /// Hostname/domain for this route (e.g. "api.example.com"). + pub hostname: String, + /// Port to route to. If `None`, the port comes from the repo config. + #[serde(default)] + pub port: Option, +} + /// HTTP routing configuration. /// -/// For HTTP apps (kind = "container" or "pod"), both `domain` and `port` -/// must be `Some`. For worker apps (kind = "worker"), both are `None`. +/// For HTTP apps (kind = "container" or "pod"), either `domain`/`port` (single +/// route, backward compat) or `routes` (multi-route) must be configured. +/// For worker apps (kind = "worker"), all fields are absent. +/// +/// When `routes` is non-empty, it takes precedence over `domain`/`port`. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RoutingConfig { #[serde(default)] pub domain: Option, #[serde(default)] pub port: Option, + /// Multiple routes for this app. Each entry has a hostname and optional port. + /// When non-empty, takes precedence over `domain`/`port`. + #[serde(default)] + pub routes: Vec, +} + +impl RoutingConfig { + /// Returns the effective routes for this config. + /// + /// If `routes` is non-empty, returns it directly. + /// Otherwise, if `domain` is set, returns a single `RouteEntry` from `domain`/`port`. + /// Otherwise returns an empty vec (worker app). + pub fn effective_routes(&self) -> Vec { + if !self.routes.is_empty() { + return self.routes.clone(); + } + if let Some(ref domain) = self.domain { + vec![RouteEntry { + hostname: domain.clone(), + port: self.port, + }] + } else { + vec![] + } + } } /// Container health-check configuration. @@ -1158,4 +1199,120 @@ port = 3000 assert_eq!(cfg.routing.domain.as_deref(), Some("webapp.example.com")); assert_eq!(cfg.routing.port, Some(3000)); } + + // ── Multi-route deserialization ────────────────────────────────────────── + + #[test] + fn routing_config_with_multi_route() { + let toml = r#" +[app] +name = "multiapp" +image = "web:latest" + +[[routing.routes]] +hostname = "api.example.com" +port = 3000 + +[[routing.routes]] +hostname = "admin.example.com" +port = 3001 + +[health] + +[deploy] +"#; + let cfg: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.routing.routes.len(), 2); + assert_eq!(cfg.routing.routes[0].hostname, "api.example.com"); + assert_eq!(cfg.routing.routes[0].port, Some(3000)); + assert_eq!(cfg.routing.routes[1].hostname, "admin.example.com"); + assert_eq!(cfg.routing.routes[1].port, Some(3001)); + } + + #[test] + fn routing_config_multi_route_takes_precedence() { + // When routes is non-empty, effective_routes returns routes, not domain/port + let toml = r#" +[app] +name = "multiapp" +image = "web:latest" + +[routing] +domain = "old.example.com" +port = 8080 + +[[routing.routes]] +hostname = "new.example.com" +port = 3000 + +[health] + +[deploy] +"#; + let cfg: AppConfig = toml::from_str(toml).unwrap(); + let effective = cfg.routing.effective_routes(); + assert_eq!(effective.len(), 1); + assert_eq!(effective[0].hostname, "new.example.com"); + assert_eq!(effective[0].port, Some(3000)); + } + + #[test] + fn routing_config_effective_routes_single() { + let toml = r#" +[app] +name = "webapp" +image = "web:latest" + +[routing] +domain = "myapp.example.com" +port = 3000 + +[health] + +[deploy] +"#; + let cfg: AppConfig = toml::from_str(toml).unwrap(); + let effective = cfg.routing.effective_routes(); + assert_eq!(effective.len(), 1); + assert_eq!(effective[0].hostname, "myapp.example.com"); + assert_eq!(effective[0].port, Some(3000)); + } + + #[test] + fn routing_config_effective_routes_worker() { + let toml = r#" +[app] +name = "workerapp" +image = "worker:latest" + +[routing] + +[health] + +[deploy] +"#; + let cfg: AppConfig = toml::from_str(toml).unwrap(); + let effective = cfg.routing.effective_routes(); + assert!(effective.is_empty()); + } + + #[test] + fn routing_config_route_entry_port_defaults_to_none() { + let toml = r#" +[app] +name = "multiapp" +image = "web:latest" + +[[routing.routes]] +hostname = "api.example.com" + +[health] + +[deploy] +"#; + let cfg: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.routing.routes.len(), 1); + assert_eq!(cfg.routing.routes[0].hostname, "api.example.com"); + assert!(cfg.routing.routes[0].port.is_none()); + } } diff --git a/crates/slip-core/src/deploy.rs b/crates/slip-core/src/deploy.rs index 252759e..47c215e 100644 --- a/crates/slip-core/src/deploy.rs +++ b/crates/slip-core/src/deploy.rs @@ -144,6 +144,13 @@ impl DeployContext { // ─── App runtime state ──────────────────────────────────────────────────────── +/// Runtime state for a single route of a deployed app. +#[derive(Debug, Clone)] +pub struct RouteState { + pub hostname: String, + pub port: u16, +} + /// Runtime state for a single deployed app (current/previous container, port, etc.). #[derive(Debug, Clone, Default)] pub struct AppRuntimeState { @@ -153,6 +160,8 @@ pub struct AppRuntimeState { pub current_container_id: Option, pub previous_container_id: Option, pub current_port: Option, + /// All current routes for this app (multi-route support). + pub current_routes: Vec, pub deployed_at: Option>, pub deploy_id: Option, /// Current pod name (for pod-mode deploys). @@ -507,10 +516,16 @@ pub(crate) async fn execute_deploy_inner( if !is_worker && let Err(e) = caddy - .set_route( + .set_routes( &app_name, - effective_config.routing.domain.as_deref().unwrap_or(""), - host_port, + &merged_cfg + .routes + .iter() + .map(|r| crate::caddy::Route { + hostname: r.hostname.clone(), + port: host_port, + }) + .collect::>(), ) .await { @@ -533,6 +548,14 @@ pub(crate) async fn execute_deploy_inner( app_state.current_pod_name = Some(pod_name.clone()); app_state.current_manifest_path = Some(manifest_path.clone()); app_state.current_port = if is_worker { None } else { Some(host_port) }; + app_state.current_routes = merged_cfg + .routes + .iter() + .map(|r| RouteState { + hostname: r.hostname.clone(), + port: host_port, + }) + .collect(); app_state.deployed_at = Some(Utc::now()); app_state.deploy_id = Some(ctx.id.clone()); app_state.status = AppStatus::Running; @@ -683,10 +706,29 @@ pub(crate) async fn execute_deploy_inner( if !is_worker && let Err(e) = caddy - .set_route( + .set_routes( &app_name, - effective_config.routing.domain.as_deref().unwrap_or(""), - new_port, + &merged + .as_ref() + .map(|m| { + m.routes + .iter() + .map(|r| crate::caddy::Route { + hostname: r.hostname.clone(), + port: new_port, + }) + .collect::>() + }) + .unwrap_or_else(|| { + vec![crate::caddy::Route { + hostname: effective_config + .routing + .domain + .clone() + .unwrap_or_default(), + port: new_port, + }] + }), ) .await { @@ -709,6 +751,18 @@ pub(crate) async fn execute_deploy_inner( app_state.current_tag = Some(ctx.tag.clone()); app_state.current_container_id = ctx.new_container_id.clone(); app_state.current_port = if is_worker { None } else { ctx.new_port }; + app_state.current_routes = merged + .as_ref() + .map(|m| { + m.routes + .iter() + .map(|r| RouteState { + hostname: r.hostname.clone(), + port: new_port, + }) + .collect() + }) + .unwrap_or_default(); app_state.deployed_at = Some(Utc::now()); app_state.deploy_id = Some(ctx.id.clone()); app_state.status = AppStatus::Running; @@ -865,7 +919,7 @@ mod tests { use tokio::sync::RwLock; use super::*; - use crate::caddy::ReverseProxy; + use crate::caddy::{ReverseProxy, Route}; use crate::config::{ AppConfig, AppInfo, AuthConfig, CaddyConfig, DeployConfig, HealthConfig, RegistryConfig, ResourceConfig, RoutingConfig, ServerConfig, SlipConfig, StorageConfig, @@ -1279,6 +1333,31 @@ mod tests { { Box::pin(async { Ok(()) }) } + + fn set_routes<'a>( + &'a self, + _app_name: &'a str, + _routes: &'a [Route], + ) -> std::pin::Pin> + Send + 'a>> + { + let result = if self.ok { + Ok(()) + } else { + Err(CaddyError::RouteUpdateFailed( + "mock caddy failure".to_string(), + )) + }; + Box::pin(async move { result }) + } + + fn remove_routes<'a>( + &'a self, + _app_name: &'a str, + _route_count: usize, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async { Ok(()) }) + } } // ── Mock: HealthCheck ───────────────────────────────────────────────────── @@ -1342,6 +1421,7 @@ mod tests { routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig { // No health path — check always passes without any HTTP call. @@ -2354,6 +2434,7 @@ container = "web" routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig::default(), deploy: DeployConfig::default(), @@ -2392,6 +2473,7 @@ container = "web" routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig::default(), deploy: DeployConfig::default(), @@ -2426,6 +2508,7 @@ container = "web" routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig::default(), deploy: DeployConfig::default(), @@ -2455,6 +2538,7 @@ container = "web" routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig::default(), deploy: DeployConfig::default(), diff --git a/crates/slip-core/src/error.rs b/crates/slip-core/src/error.rs index 75132c2..9fbfe59 100644 --- a/crates/slip-core/src/error.rs +++ b/crates/slip-core/src/error.rs @@ -47,6 +47,10 @@ pub enum ConfigError { "volume mount '{mount_path}' declared in repo config has no corresponding host_path in server config" )] VolumeMissingHostPath { mount_path: String }, + + /// A merge error occurred (e.g., route count mismatch). + #[error("merge error: {0}")] + Merge(String), } /// Errors that can occur during container health checking. diff --git a/crates/slip-core/src/merge.rs b/crates/slip-core/src/merge.rs index 649480d..126c1f3 100644 --- a/crates/slip-core/src/merge.rs +++ b/crates/slip-core/src/merge.rs @@ -19,6 +19,21 @@ use crate::config::AppConfig; use crate::error::ConfigError; use crate::repo_config::{PreviewConfig, RepoConfig}; +/// A fully-resolved route after merging repo + server config. +/// +/// The server provides the `hostname`, the repo provides the `port` and +/// `container` (pod mode). If the repo doesn't specify a port, the server's +/// port is used. +#[derive(Debug, Clone)] +pub struct MergedRoute { + /// Hostname/domain for this route (from server config). + pub hostname: String, + /// Port to route to (from repo config, falling back to server config). + pub port: u16, + /// Which container to route to (pod mode only, from repo config). + pub container: Option, +} + /// A fully-resolved volume after merging repo + server config. /// /// Contains the `host_path` from the server config and the `mount_path` / @@ -113,12 +128,60 @@ pub fn merge_config(server: &AppConfig, repo: &RepoConfig) -> Result, /// Which container to route to (pod mode only). pub routing_container: Option, + /// Merged routes (server hostname + repo port/container). + pub routes: Vec, /// Preview environment configuration from the repo. pub preview: Option, /// Fully-resolved volume mounts (merged from repo + server config). @@ -161,7 +226,7 @@ mod tests { }; use crate::repo_config::{ RepoAppInfo, RepoConfig, RepoDefaults, RepoHealthConfig, RepoResourceConfig, - RepoRoutingConfig, + RepoRouteEntry, RepoRoutingConfig, }; fn base_server_config() -> AppConfig { @@ -174,6 +239,7 @@ mod tests { routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig { path: None, @@ -498,4 +564,137 @@ mod tests { assert_eq!(merged.volumes[1].host_path, "/data/two"); assert!(merged.volumes[1].read_only); // server wins } + + // ── Multi-route merge tests ────────────────────────────────────────────── + + #[test] + fn merge_multi_route_single_route_backward_compat() { + // Single-route backward compat: server has domain/port, repo has port/container + let server = base_server_config(); + let mut repo = minimal_repo_config("testapp"); + repo.routing.port = Some(8080); + repo.routing.container = Some("web".to_string()); + + let merged = merge_config(&server, &repo).unwrap(); + + assert_eq!(merged.routes.len(), 1); + assert_eq!(merged.routes[0].hostname, "testapp.example.com"); + assert_eq!(merged.routes[0].port, 8080); + assert_eq!(merged.routes[0].container.as_deref(), Some("web")); + } + + #[test] + fn merge_multi_route_server_port_fallback() { + // When repo doesn't specify port, fall back to server port + let server = base_server_config(); + let mut repo = minimal_repo_config("testapp"); + repo.routing.port = None; // repo has no port + + let merged = merge_config(&server, &repo).unwrap(); + + assert_eq!(merged.routes.len(), 1); + assert_eq!(merged.routes[0].hostname, "testapp.example.com"); + assert_eq!(merged.routes[0].port, 3000); // falls back to server port + assert!(merged.routes[0].container.is_none()); + } + + #[test] + fn merge_multi_route_full_multi_route() { + let mut server = base_server_config(); + server.routing.routes = vec![ + crate::config::RouteEntry { + hostname: "api.example.com".to_string(), + port: None, + }, + crate::config::RouteEntry { + hostname: "admin.example.com".to_string(), + port: None, + }, + ]; + + let mut repo = minimal_repo_config("testapp"); + repo.routing.routes = vec![ + RepoRouteEntry { + port: Some(3000), + container: Some("web".to_string()), + }, + RepoRouteEntry { + port: Some(3001), + container: Some("admin".to_string()), + }, + ]; + + let merged = merge_config(&server, &repo).unwrap(); + + assert_eq!(merged.routes.len(), 2); + assert_eq!(merged.routes[0].hostname, "api.example.com"); + assert_eq!(merged.routes[0].port, 3000); + assert_eq!(merged.routes[0].container.as_deref(), Some("web")); + assert_eq!(merged.routes[1].hostname, "admin.example.com"); + assert_eq!(merged.routes[1].port, 3001); + assert_eq!(merged.routes[1].container.as_deref(), Some("admin")); + } + + #[test] + fn merge_multi_route_length_mismatch_error() { + let mut server = base_server_config(); + server.routing.routes = vec![crate::config::RouteEntry { + hostname: "api.example.com".to_string(), + port: None, + }]; + + let mut repo = minimal_repo_config("testapp"); + repo.routing.routes = vec![ + RepoRouteEntry { + port: Some(3000), + container: None, + }, + RepoRouteEntry { + port: Some(3001), + container: None, + }, + ]; + + let err = merge_config(&server, &repo).unwrap_err(); + match err { + ConfigError::Merge(msg) => { + assert!(msg.contains("route count mismatch")); + } + _ => panic!("expected Merge error, got: {err}"), + } + } + + #[test] + fn merge_multi_route_repo_only_error() { + // Server has no routes at all (worker-like), but repo declares routes + let mut server = base_server_config(); + server.routing.domain = None; + server.routing.port = None; + let mut repo = minimal_repo_config("testapp"); + repo.routing.routes = vec![RepoRouteEntry { + port: Some(3000), + container: None, + }]; + + let err = merge_config(&server, &repo).unwrap_err(); + match err { + ConfigError::Merge(msg) => { + assert!(msg.contains("repo declares")); + } + _ => panic!("expected Merge error, got: {err}"), + } + } + + #[test] + fn merge_multi_route_worker_no_routes() { + // Worker app: no routes in either config + let mut server = base_server_config(); + server.routing.domain = None; + server.routing.port = None; + + let repo = minimal_repo_config("testapp"); + + let merged = merge_config(&server, &repo).unwrap(); + assert!(merged.routes.is_empty()); + } } diff --git a/crates/slip-core/src/preview.rs b/crates/slip-core/src/preview.rs index 22d457e..7e7272f 100644 --- a/crates/slip-core/src/preview.rs +++ b/crates/slip-core/src/preview.rs @@ -12,7 +12,7 @@ use chrono::{DateTime, Duration as ChronoDuration, Utc}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; -use crate::caddy::ReverseProxy; +use crate::caddy::{ReverseProxy, Route}; use crate::config::{AppConfig, AppPreviewConfig, ResourceConfig, ServerPreviewConfig, SlipConfig}; use crate::deploy::{AppStatus, DeployStatus}; use crate::docker::parse_memory_limit; @@ -825,7 +825,7 @@ pub(crate) async fn execute_preview_deploy_inner( "failed to teardown existing preview pod (non-fatal)" ); } - if let Err(e) = caddy.remove_route(&preview_app_name).await { + if let Err(e) = caddy.remove_routes(&preview_app_name, 1).await { tracing::warn!( app = %app_name, preview_id = %preview_id, @@ -1126,7 +1126,15 @@ pub(crate) async fn execute_preview_deploy_inner( // Workers have no domain — skip Caddy route creation. if !is_worker && let Some(ref domain) = resolved_domain - && let Err(e) = caddy.set_route(&preview_app_name, domain, host_port).await + && let Err(e) = caddy + .set_routes( + &preview_app_name, + &[Route { + hostname: domain.clone(), + port: host_port, + }], + ) + .await { tracing::error!( app = %app_name, @@ -1251,7 +1259,7 @@ pub async fn teardown_preview( // Remove Caddy route (workers have no route to remove). if !is_worker { tracing::info!(app = %app_name, preview_id = %preview_id, "removing preview Caddy route"); - if let Err(e) = caddy.remove_route(&preview_app_name).await { + if let Err(e) = caddy.remove_routes(&preview_app_name, 1).await { tracing::warn!( app = %app_name, preview_id = %preview_id, @@ -1357,7 +1365,7 @@ mod tests { use tempfile::TempDir; use super::*; - use crate::caddy::ReverseProxy; + use crate::caddy::{ReverseProxy, Route}; use crate::config::{ AppConfig, AppInfo, AppPreviewConfig, AuthConfig, CaddyConfig, DeployConfig, HealthConfig, RegistryConfig, ResourceConfig, RoutingConfig, RuntimeConfig, ServerConfig, @@ -1744,6 +1752,33 @@ mod tests { self.remove_route_count.fetch_add(1, Ordering::SeqCst); Box::pin(async { Ok(()) }) } + + fn set_routes<'a>( + &'a self, + _app_name: &'a str, + _routes: &'a [Route], + ) -> std::pin::Pin> + Send + 'a>> + { + self.set_route_count.fetch_add(1, Ordering::SeqCst); + let result = if self.ok { + Ok(()) + } else { + Err(CaddyError::RouteUpdateFailed( + "mock caddy failure".to_string(), + )) + }; + Box::pin(async move { result }) + } + + fn remove_routes<'a>( + &'a self, + _app_name: &'a str, + _route_count: usize, + ) -> std::pin::Pin> + Send + 'a>> + { + self.remove_route_count.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } } // ── Mock: HealthCheck ───────────────────────────────────────────────────── @@ -1815,6 +1850,7 @@ mod tests { routing: RoutingConfig { domain: Some("testapp.example.com".to_string()), port: Some(3000), + routes: vec![], }, health: HealthConfig { path: None, diff --git a/crates/slip-core/src/repo_config.rs b/crates/slip-core/src/repo_config.rs index 0005f00..af6bde2 100644 --- a/crates/slip-core/src/repo_config.rs +++ b/crates/slip-core/src/repo_config.rs @@ -69,12 +69,51 @@ pub struct RepoHealthConfig { pub start_period: Option, } +/// A single route entry in the repo-side routing config. +/// +/// The repo declares which `port` and/or `container` to route to. +/// The server provides the `hostname` (domain). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct RepoRouteEntry { + /// Port to route to. + #[serde(default)] + pub port: Option, + /// Which container to route to (pod mode only). + #[serde(default)] + pub container: Option, +} + /// Routing configuration from the repo config. #[derive(Debug, Clone, Default, Deserialize)] pub struct RepoRoutingConfig { pub port: Option, /// Which container to route to (pod mode only). pub container: Option, + /// Multiple routes for this app. Each entry has a port and optional container. + /// When non-empty, takes precedence over `port`/`container`. + #[serde(default)] + pub routes: Vec, +} + +impl RepoRoutingConfig { + /// Returns the effective routes for this config. + /// + /// If `routes` is non-empty, returns it directly. + /// Otherwise, if `port` is set, returns a single `RepoRouteEntry` from `port`/`container`. + /// Otherwise returns an empty vec. + pub fn effective_routes(&self) -> Vec { + if !self.routes.is_empty() { + return self.routes.clone(); + } + if self.port.is_some() { + vec![RepoRouteEntry { + port: self.port, + container: self.container.clone(), + }] + } else { + vec![] + } + } } /// Default resource configuration from the repo config. @@ -451,4 +490,92 @@ name = "myapp" let cfg = parse_repo_config(toml.as_bytes()).unwrap(); assert!(cfg.volumes.is_empty()); } + + // ── Multi-route repo config ────────────────────────────────────────────── + + #[test] + fn parse_repo_config_multi_route() { + let toml = r#" +[app] +name = "multiapp" + +[[routing.routes]] +port = 3000 +container = "web" + +[[routing.routes]] +port = 3001 +container = "admin" +"#; + let cfg = parse_repo_config(toml.as_bytes()).unwrap(); + assert_eq!(cfg.routing.routes.len(), 2); + assert_eq!(cfg.routing.routes[0].port, Some(3000)); + assert_eq!(cfg.routing.routes[0].container.as_deref(), Some("web")); + assert_eq!(cfg.routing.routes[1].port, Some(3001)); + assert_eq!(cfg.routing.routes[1].container.as_deref(), Some("admin")); + } + + #[test] + fn parse_repo_config_multi_route_takes_precedence() { + let toml = r#" +[app] +name = "multiapp" + +[routing] +port = 8080 +container = "old" + +[[routing.routes]] +port = 3000 +container = "new" +"#; + let cfg = parse_repo_config(toml.as_bytes()).unwrap(); + let effective = cfg.routing.effective_routes(); + assert_eq!(effective.len(), 1); + assert_eq!(effective[0].port, Some(3000)); + assert_eq!(effective[0].container.as_deref(), Some("new")); + } + + #[test] + fn parse_repo_config_effective_routes_single() { + let toml = r#" +[app] +name = "webapp" + +[routing] +port = 8080 +container = "web" +"#; + let cfg = parse_repo_config(toml.as_bytes()).unwrap(); + let effective = cfg.routing.effective_routes(); + assert_eq!(effective.len(), 1); + assert_eq!(effective[0].port, Some(8080)); + assert_eq!(effective[0].container.as_deref(), Some("web")); + } + + #[test] + fn parse_repo_config_effective_routes_empty() { + let toml = r#" +[app] +name = "workerapp" +"#; + let cfg = parse_repo_config(toml.as_bytes()).unwrap(); + let effective = cfg.routing.effective_routes(); + assert!(effective.is_empty()); + } + + #[test] + fn parse_repo_config_route_entry_defaults() { + let toml = r#" +[app] +name = "multiapp" + +[[routing.routes]] +port = 3000 +"#; + let cfg = parse_repo_config(toml.as_bytes()).unwrap(); + assert_eq!(cfg.routing.routes.len(), 1); + assert_eq!(cfg.routing.routes[0].port, Some(3000)); + assert!(cfg.routing.routes[0].container.is_none()); + } } diff --git a/crates/slip-core/src/state.rs b/crates/slip-core/src/state.rs index d3bf1f8..bd5a073 100644 --- a/crates/slip-core/src/state.rs +++ b/crates/slip-core/src/state.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::caddy::{CaddyClient, RouteInfo}; use crate::config::AppConfig; -use crate::deploy::{AppRuntimeState, AppStatus}; +use crate::deploy::{AppRuntimeState, AppStatus, RouteState}; use crate::error::CaddyError; use crate::preview::{PersistedPreviewState, PreviewState}; use crate::runtime::RuntimeBackend; @@ -26,6 +26,9 @@ pub struct PersistedAppState { pub previous_tag: Option, pub current_container_id: Option, pub current_port: Option, + /// All current routes for this app (multi-route support). + #[serde(default)] + pub current_routes: Vec, pub deployed_at: Option>, /// Pod name for pod-mode apps (persisted for teardown on next deploy). #[serde(default)] @@ -38,6 +41,13 @@ pub struct PersistedAppState { pub kind: Option, } +/// Persisted route state for a single route. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedRouteState { + pub hostname: String, + pub port: u16, +} + impl From<&AppRuntimeState> for PersistedAppState { fn from(s: &AppRuntimeState) -> Self { Self { @@ -45,6 +55,14 @@ impl From<&AppRuntimeState> for PersistedAppState { previous_tag: s.previous_tag.clone(), current_container_id: s.current_container_id.clone(), current_port: s.current_port, + current_routes: s + .current_routes + .iter() + .map(|r| PersistedRouteState { + hostname: r.hostname.clone(), + port: r.port, + }) + .collect(), deployed_at: s.deployed_at, current_pod_name: s.current_pod_name.clone(), current_manifest_path: s.current_manifest_path.clone(), @@ -68,6 +86,14 @@ impl From for AppRuntimeState { current_container_id: p.current_container_id, previous_container_id: None, current_port: p.current_port, + current_routes: p + .current_routes + .into_iter() + .map(|r| RouteState { + hostname: r.hostname, + port: r.port, + }) + .collect(), deployed_at: p.deployed_at, deploy_id: None, current_pod_name: p.current_pod_name, @@ -227,20 +253,36 @@ pub async fn reconcile_routes( if state.status != AppStatus::Running { return None; } - let port = state.current_port?; // Workers have current_port = None, so they're implicitly skipped - let config = match app_configs.get(app_name) { - Some(c) => c, - None => { - tracing::warn!(app = %app_name, "no config found for running app, skipping route reconciliation"); - return None; - } + // Use current_routes if available, otherwise fall back to single port + let route_infos: Vec = if !state.current_routes.is_empty() { + state + .current_routes + .iter() + .map(|r| RouteInfo { + app_name: app_name.clone(), + domain: r.hostname.clone(), + port: r.port, + }) + .collect() + } else if let Some(port) = state.current_port { + let config = match app_configs.get(app_name) { + Some(c) => c, + None => { + tracing::warn!(app = %app_name, "no config found for running app, skipping route reconciliation"); + return None; + } + }; + vec![RouteInfo { + app_name: app_name.clone(), + domain: config.routing.domain.clone().unwrap_or_default(), + port, + }] + } else { + return None; }; - Some(RouteInfo { - app_name: app_name.clone(), - domain: config.routing.domain.clone().unwrap_or_default(), - port, - }) + Some(route_infos) }) + .flatten() .collect(); if routes.is_empty() { @@ -463,6 +505,7 @@ mod tests { current_container_id: Some("abc123def456".to_string()), previous_container_id: Some("oldcontainer".to_string()), current_port: Some(54321), + current_routes: vec![], deployed_at: Some(Utc::now()), deploy_id: Some("dep_01abc".to_string()), current_pod_name: None, @@ -676,6 +719,7 @@ mod tests { routing: crate::config::RoutingConfig { domain: Some("app1.example.com".to_string()), port: Some(80), + routes: vec![], }, health: crate::config::HealthConfig::default(), deploy: crate::config::DeployConfig::default(), @@ -688,7 +732,6 @@ mod tests { }, ); - // Create states with mixed statuses let mut states = HashMap::new(); states.insert( "app1".to_string(), @@ -725,13 +768,13 @@ mod tests { // Verify only app1 route was created let map = state.lock().await; - assert!(map.contains_key("slip-app1"), "app1 route should exist"); + assert!(map.contains_key("slip-app1-0"), "app1 route should exist"); assert!( - !map.contains_key("slip-app2"), + !map.contains_key("slip-app2-0"), "app2 (Failed) should not have route" ); assert!( - !map.contains_key("slip-app3"), + !map.contains_key("slip-app3-0"), "app3 (Deploying) should not have route" ); } @@ -757,6 +800,7 @@ mod tests { routing: crate::config::RoutingConfig { domain: Some("app1.example.com".to_string()), port: Some(80), + routes: vec![], }, health: crate::config::HealthConfig::default(), deploy: crate::config::DeployConfig::default(), @@ -797,9 +841,9 @@ mod tests { // Verify only app1 route was created let map = state.lock().await; - assert!(map.contains_key("slip-app1"), "app1 route should exist"); + assert!(map.contains_key("slip-app1-0"), "app1 route should exist"); assert!( - !map.contains_key("slip-app2"), + !map.contains_key("slip-app2-0"), "app2 (no config) should not have route" ); } @@ -967,11 +1011,11 @@ mod tests { let map = caddy_state.lock().await; assert!( - map.contains_key("slip-myapp-preview-pr-1"), + map.contains_key("slip-myapp-preview-pr-1-0"), "route for pr-1 should be reconciled" ); assert!( - map.contains_key("slip-myapp-preview-pr-2"), + map.contains_key("slip-myapp-preview-pr-2-0"), "route for pr-2 should be reconciled" ); } @@ -1018,11 +1062,11 @@ mod tests { let map = caddy_state.lock().await; assert!( - map.contains_key("slip-myapp-preview-pr-1"), + map.contains_key("slip-myapp-preview-pr-1-0"), "running preview route should be reconciled" ); assert!( - !map.contains_key("slip-myapp-preview-pr-2"), + !map.contains_key("slip-myapp-preview-pr-2-0"), "failed preview route should NOT be reconciled" ); } diff --git a/crates/slip-core/src/validate.rs b/crates/slip-core/src/validate.rs index 91a49a1..5ca0d0e 100644 --- a/crates/slip-core/src/validate.rs +++ b/crates/slip-core/src/validate.rs @@ -165,6 +165,9 @@ pub fn validate_repo_config(config: &RepoConfig, base_dir: &Path) -> ValidationR } } + // Validate routing configuration + validate_routing_config(&config.routing, &mut result); + // Validate preview configuration if let Some(ref preview) = config.preview { validate_preview_config(preview, &config.routing, &mut result); @@ -230,6 +233,36 @@ fn validate_app_name(name: &str, result: &mut ValidationResult) { } } +/// Validate repo-side routing configuration. +/// +/// Checks: +/// - If `routes` is non-empty, each entry should have `port` or `container` (at least one). +/// - If both `routes` and flat `port`/`container` are set, warn about ambiguity. +fn validate_routing_config( + routing: &crate::repo_config::RepoRoutingConfig, + result: &mut ValidationResult, +) { + if !routing.routes.is_empty() { + // Multi-route mode + if routing.port.is_some() || routing.container.is_some() { + result.add_warning( + "both 'routing.routes' and flat 'routing.port'/'routing.container' are set; \ + 'routing.routes' takes precedence" + .to_string(), + ); + } + for (i, route) in routing.routes.iter().enumerate() { + if route.port.is_none() && route.container.is_none() { + result.add_warning(format!( + "routing.routes[{}] has neither port nor container; \ + route may not be functional", + i + )); + } + } + } +} + /// Validate preview configuration. fn validate_preview_config( preview: &PreviewConfig, @@ -1242,4 +1275,77 @@ kind = "unknown_kind" "should error on unknown kind" ); } + + // ── Multi-route validation tests ────────────────────────────────────────── + + #[test] + fn multi_route_valid() { + let temp = TempDir::new().unwrap(); + let toml = r#" +[app] +name = "multiapp" + +[[routing.routes]] +port = 3000 +container = "web" + +[[routing.routes]] +port = 3001 +container = "admin" +"#; + let (config, result) = parse_and_validate(toml, temp.path(), false); + assert!( + result.is_valid(), + "multi-route config should be valid, errors: {:?}", + result.errors + ); + let cfg = config.unwrap(); + assert_eq!(cfg.routing.routes.len(), 2); + } + + #[test] + fn multi_route_with_flat_routing_warns() { + let temp = TempDir::new().unwrap(); + let toml = r#" +[app] +name = "multiapp" + +[routing] +port = 8080 +container = "old" + +[[routing.routes]] +port = 3000 +container = "new" +"#; + let (_, result) = parse_and_validate(toml, temp.path(), false); + assert!(result.is_valid()); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("both 'routing.routes' and flat")), + "should warn about both routes and flat routing being set" + ); + } + + #[test] + fn multi_route_empty_entry_warns() { + let temp = TempDir::new().unwrap(); + let toml = r#" +[app] +name = "multiapp" + +[[routing.routes]] +"#; + let (_, result) = parse_and_validate(toml, temp.path(), false); + assert!(result.is_valid()); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("has neither port nor container")), + "should warn about empty route entry" + ); + } }