diff --git a/crates/fakecloud-ecs/src/service_services_resource.rs b/crates/fakecloud-ecs/src/service_services_resource.rs index 2991a2f16..873d4fc1f 100644 --- a/crates/fakecloud-ecs/src/service_services_resource.rs +++ b/crates/fakecloud-ecs/src/service_services_resource.rs @@ -398,6 +398,67 @@ impl EcsService { service_cluster_arn = svc.cluster_arn.clone(); launch_type_clone = svc.launch_type.clone(); + // Apply the remaining mutable service fields UpdateService + // accepts. AWS updates each only when present in the request; + // omitting one leaves it unchanged. The old handler read only + // desiredCount / taskDefinition / lifecycleHooks and silently + // dropped everything else, so ECS Exec enablement, subnet/SG + // changes, deployment tuning, load-balancer swaps, etc. all + // no-op'd (bug-audit 2026-06-20, 1.15). + if let Some(dc) = body.get("deploymentConfiguration") { + if let Some(n) = dc.get("minimumHealthyPercent").and_then(|v| v.as_i64()) { + svc.minimum_healthy_percent = Some(n as i32); + } + if let Some(n) = dc.get("maximumPercent").and_then(|v| v.as_i64()) { + svc.maximum_percent = Some(n as i32); + } + if let Some(c) = dc.get("deploymentCircuitBreaker") { + svc.circuit_breaker = Some(CircuitBreakerConfig { + enable: c.get("enable").and_then(|v| v.as_bool()).unwrap_or(false), + rollback: c.get("rollback").and_then(|v| v.as_bool()).unwrap_or(false), + }); + } + } + if let Some(v) = body.get("networkConfiguration") { + svc.network_configuration = Some(v.clone()); + } + if let Some(s) = opt_str(&body, "platformVersion") { + svc.platform_version = Some(s.to_string()); + } + if let Some(n) = body + .get("healthCheckGracePeriodSeconds") + .and_then(|v| v.as_i64()) + { + svc.health_check_grace_period_seconds = Some(n as i32); + } + if let Some(b) = body.get("enableExecuteCommand").and_then(|v| v.as_bool()) { + svc.enable_execute_command = b; + } + if let Some(b) = body.get("enableECSManagedTags").and_then(|v| v.as_bool()) { + svc.enable_ecs_managed_tags = b; + } + if let Some(s) = opt_str(&body, "propagateTags") { + svc.propagate_tags = Some(s.to_string()); + } + if let Some(a) = body + .get("capacityProviderStrategy") + .and_then(|v| v.as_array()) + { + svc.capacity_provider_strategy = a.clone(); + } + if let Some(a) = body.get("placementConstraints").and_then(|v| v.as_array()) { + svc.placement_constraints = a.clone(); + } + if let Some(a) = body.get("placementStrategy").and_then(|v| v.as_array()) { + svc.placement_strategy = a.clone(); + } + if let Some(a) = body.get("loadBalancers").and_then(|v| v.as_array()) { + svc.load_balancers = a.clone(); + } + if let Some(a) = body.get("serviceRegistries").and_then(|v| v.as_array()) { + svc.service_registries = a.clone(); + } + if let Some(n) = new_desired { let n = n.max(0) as i32; svc.desired_count = n; diff --git a/crates/fakecloud-ecs/src/service_tests.rs b/crates/fakecloud-ecs/src/service_tests.rs index 0dc36f607..def1d17a9 100644 --- a/crates/fakecloud-ecs/src/service_tests.rs +++ b/crates/fakecloud-ecs/src/service_tests.rs @@ -402,6 +402,75 @@ mod scheduler_reconcile { Arc::new(RwLock::new(accounts)) } + fn ecs_request(action: &str, body: serde_json::Value) -> AwsRequest { + AwsRequest { + service: "ecs".into(), + action: action.into(), + region: "us-east-1".into(), + account_id: ACCOUNT.into(), + request_id: uuid::Uuid::new_v4().to_string(), + headers: http::HeaderMap::new(), + query_params: std::collections::HashMap::new(), + body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()), + body_stream: parking_lot::Mutex::new(None), + path_segments: Vec::new(), + raw_path: "/".into(), + raw_query: String::new(), + method: http::Method::POST, + is_query_protocol: false, + access_key_id: None, + principal: None, + } + } + + #[tokio::test] + async fn update_service_applies_extended_fields() { + // UpdateService previously read only desiredCount/taskDefinition/ + // lifecycleHooks and dropped everything else (bug-audit 2026-06-20, 1.15). + let state = empty_state(); + { + let mut g = state.write(); + let st = g.get_or_create(ACCOUNT); + st.services + .insert("default/api".to_string(), make_service(2)); + } + let svc = EcsService::new(state.clone()); + + let body = serde_json::json!({ + "service": "api", + "cluster": "default", + "enableExecuteCommand": true, + "enableECSManagedTags": true, + "healthCheckGracePeriodSeconds": 120, + "platformVersion": "1.4.0", + "propagateTags": "SERVICE", + "networkConfiguration": { "awsvpcConfiguration": { "subnets": ["subnet-aaa"] } }, + "deploymentConfiguration": { + "minimumHealthyPercent": 50, + "maximumPercent": 200, + "deploymentCircuitBreaker": { "enable": true, "rollback": true } + } + }); + svc.update_service(&ecs_request("UpdateService", body)) + .expect("UpdateService"); + + let g = state.read(); + let s = &g.get(ACCOUNT).unwrap().services["default/api"]; + assert!(s.enable_execute_command, "enableExecuteCommand must apply"); + assert!(s.enable_ecs_managed_tags); + assert_eq!(s.health_check_grace_period_seconds, Some(120)); + assert_eq!(s.platform_version.as_deref(), Some("1.4.0")); + assert_eq!(s.propagate_tags.as_deref(), Some("SERVICE")); + assert!(s.network_configuration.is_some()); + assert_eq!(s.minimum_healthy_percent, Some(50)); + assert_eq!(s.maximum_percent, Some(200)); + assert!(s + .circuit_breaker + .as_ref() + .map(|c| c.enable) + .unwrap_or(false)); + } + /// No snapshot store (memory mode) -> no persist hook for the CFN provisioner. #[test] fn snapshot_hook_is_none_without_store() {