@@ -546,6 +546,13 @@ impl EcsService {
546546 let family = opt_str ( & body, "family" ) ;
547547 let status_filter = opt_str ( & body, "desiredStatus" ) . or ( Some ( "RUNNING" ) ) ;
548548 let started_by = opt_str ( & body, "startedBy" ) ;
549+ // A service's tasks carry group `service:<name>`. The serviceName
550+ // filter may arrive as a bare name or a full service ARN.
551+ let service_group =
552+ opt_str ( & body, "serviceName" ) . map ( |s| format ! ( "service:{}" , service_name_from_ref( s) ) ) ;
553+ // containerInstance may be a full ARN or a bare instance ID; compare on
554+ // the trailing ID segment so both forms match.
555+ let container_instance = opt_str ( & body, "containerInstance" ) . map ( id_from_ref) ;
549556 let max_results = body
550557 . get ( "maxResults" )
551558 . and_then ( |v| v. as_i64 ( ) )
@@ -564,6 +571,20 @@ impl EcsService {
564571 . filter ( |t| family. is_none_or ( |f| t. family == f) )
565572 . filter ( |t| status_filter. is_none_or ( |s| t. desired_status == s) )
566573 . filter ( |t| started_by. is_none_or ( |s| t. started_by . as_deref ( ) == Some ( s) ) )
574+ . filter ( |t| {
575+ service_group
576+ . as_deref ( )
577+ . is_none_or ( |g| t. group . as_deref ( ) == Some ( g) )
578+ } )
579+ . filter ( |t| {
580+ container_instance. as_deref ( ) . is_none_or ( |ci| {
581+ t. container_instance_arn
582+ . as_deref ( )
583+ . map ( id_from_ref)
584+ . as_deref ( )
585+ == Some ( ci)
586+ } )
587+ } )
567588 . map ( |t| t. task_arn . clone ( ) )
568589 . collect ( ) ,
569590 None => Vec :: new ( ) ,
@@ -591,7 +612,7 @@ mod multi_container_tests {
591612 use std:: collections:: HashMap ;
592613 use std:: sync:: Arc ;
593614
594- fn fresh_service ( ) -> EcsService {
615+ pub ( super ) fn fresh_service ( ) -> EcsService {
595616 let accounts: MultiAccountState < EcsState > =
596617 MultiAccountState :: new ( "000000000000" , "us-east-1" , "http://localhost:4566" ) ;
597618 let state = Arc :: new ( RwLock :: new ( accounts) ) ;
@@ -606,7 +627,7 @@ mod multi_container_tests {
606627 svc
607628 }
608629
609- fn make_request ( action : & str , body : Value ) -> AwsRequest {
630+ pub ( super ) fn make_request ( action : & str , body : Value ) -> AwsRequest {
610631 let body_bytes = Bytes :: from ( serde_json:: to_vec ( & body) . unwrap ( ) ) ;
611632 AwsRequest {
612633 service : "ecs" . into ( ) ,
@@ -687,7 +708,7 @@ mod multi_container_tests {
687708 ) ;
688709 }
689710
690- fn insert_task ( svc : & EcsService , id : & str , cluster : & str ) {
711+ pub ( super ) fn insert_task ( svc : & EcsService , id : & str , cluster : & str ) {
691712 let state = svc. state . clone ( ) ;
692713 let mut accounts = state. write ( ) ;
693714 let acct = accounts. get_or_create ( "000000000000" ) ;
@@ -1294,3 +1315,136 @@ mod port_mapping_tests {
12941315 assert_eq ! ( nb, & serde_json:: Value :: Array ( bindings) ) ;
12951316 }
12961317}
1318+
1319+ #[ cfg( test) ]
1320+ mod service_validation_tests {
1321+ use super :: multi_container_tests:: * ;
1322+ use super :: * ;
1323+ use serde_json:: { json, Value } ;
1324+
1325+ /// Register a task def + create an ACTIVE service named `web` with
1326+ /// desiredCount 0, so the update/create validation tests have a target.
1327+ fn service_ready ( svc : & EcsService ) {
1328+ svc. register_task_definition ( & make_request (
1329+ "RegisterTaskDefinition" ,
1330+ json ! ( { "family" : "web" , "containerDefinitions" : [ { "name" : "app" , "image" : "alpine" } ] } ) ,
1331+ ) )
1332+ . unwrap ( ) ;
1333+ svc. create_service ( & make_request (
1334+ "CreateService" ,
1335+ json ! ( { "serviceName" : "web" , "taskDefinition" : "web" , "desiredCount" : 0 } ) ,
1336+ ) )
1337+ . unwrap ( ) ;
1338+ }
1339+
1340+ #[ test]
1341+ fn update_service_negative_desired_count_is_invalid_parameter ( ) {
1342+ let svc = fresh_service ( ) ;
1343+ service_ready ( & svc) ;
1344+ // A negative desiredCount previously clamped to 0 and silently scaled
1345+ // the service down. AWS rejects it instead.
1346+ let err = match svc. update_service ( & make_request (
1347+ "UpdateService" ,
1348+ json ! ( { "service" : "web" , "desiredCount" : -1 } ) ,
1349+ ) ) {
1350+ Ok ( _) => panic ! ( "expected InvalidParameterException for negative desiredCount" ) ,
1351+ Err ( e) => e,
1352+ } ;
1353+ assert_eq ! ( err. code( ) , "InvalidParameterException" ) ;
1354+ }
1355+
1356+ #[ test]
1357+ fn update_service_on_inactive_service_is_service_not_active ( ) {
1358+ let svc = fresh_service ( ) ;
1359+ service_ready ( & svc) ;
1360+ // Simulate a deleted service left at INACTIVE (still describable).
1361+ {
1362+ let mut accounts = svc. state . write ( ) ;
1363+ let state = accounts. get_mut ( "000000000000" ) . unwrap ( ) ;
1364+ let key = EcsState :: service_key ( "default" , "web" ) ;
1365+ state. services . get_mut ( & key) . unwrap ( ) . status = "INACTIVE" . into ( ) ;
1366+ }
1367+ let err = match svc. update_service ( & make_request (
1368+ "UpdateService" ,
1369+ json ! ( { "service" : "web" , "desiredCount" : 2 } ) ,
1370+ ) ) {
1371+ Ok ( _) => panic ! ( "expected ServiceNotActiveException for a non-ACTIVE service" ) ,
1372+ Err ( e) => e,
1373+ } ;
1374+ assert_eq ! ( err. code( ) , "ServiceNotActiveException" ) ;
1375+ }
1376+
1377+ #[ test]
1378+ fn create_service_duplicate_name_is_invalid_parameter_not_idempotent ( ) {
1379+ let svc = fresh_service ( ) ;
1380+ service_ready ( & svc) ;
1381+ let err = match svc. create_service ( & make_request (
1382+ "CreateService" ,
1383+ json ! ( { "serviceName" : "web" , "taskDefinition" : "web" , "desiredCount" : 0 } ) ,
1384+ ) ) {
1385+ Ok ( _) => panic ! ( "expected InvalidParameterException for a duplicate service name" ) ,
1386+ Err ( e) => e,
1387+ } ;
1388+ assert_eq ! ( err. code( ) , "InvalidParameterException" ) ;
1389+ assert_eq ! ( err. message( ) , "Creation of service was not idempotent." ) ;
1390+ }
1391+
1392+ #[ test]
1393+ fn list_tasks_filters_by_service_name_and_container_instance ( ) {
1394+ let svc = fresh_service ( ) ;
1395+ // Two service-owned tasks (group service:web) on distinct container
1396+ // instances, plus one unrelated task (group service:api).
1397+ insert_task ( & svc, "web1" , "default" ) ;
1398+ insert_task ( & svc, "web2" , "default" ) ;
1399+ insert_task ( & svc, "api1" , "default" ) ;
1400+ {
1401+ let mut accounts = svc. state . write ( ) ;
1402+ let state = accounts. get_mut ( "000000000000" ) . unwrap ( ) ;
1403+ let ci_a = "arn:aws:ecs:us-east-1:000000000000:container-instance/default/aaaaaaaa"
1404+ . to_string ( ) ;
1405+ let ci_b = "arn:aws:ecs:us-east-1:000000000000:container-instance/default/bbbbbbbb"
1406+ . to_string ( ) ;
1407+ let t = state. tasks . get_mut ( "web1" ) . unwrap ( ) ;
1408+ t. group = Some ( "service:web" . into ( ) ) ;
1409+ t. container_instance_arn = Some ( ci_a) ;
1410+ let t = state. tasks . get_mut ( "web2" ) . unwrap ( ) ;
1411+ t. group = Some ( "service:web" . into ( ) ) ;
1412+ t. container_instance_arn = Some ( ci_b) ;
1413+ let t = state. tasks . get_mut ( "api1" ) . unwrap ( ) ;
1414+ t. group = Some ( "service:api" . into ( ) ) ;
1415+ }
1416+
1417+ // serviceName filter returns only the two web tasks.
1418+ let resp = svc
1419+ . list_tasks ( & make_request ( "ListTasks" , json ! ( { "serviceName" : "web" } ) ) )
1420+ . unwrap ( ) ;
1421+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1422+ let arns = body[ "taskArns" ] . as_array ( ) . unwrap ( ) ;
1423+ assert_eq ! ( arns. len( ) , 2 ) ;
1424+ assert ! ( arns. iter( ) . all( |a| a. as_str( ) . unwrap( ) . contains( "/web" ) ) ) ;
1425+
1426+ // containerInstance filter (bare ID) narrows to the single task on it.
1427+ let resp = svc
1428+ . list_tasks ( & make_request (
1429+ "ListTasks" ,
1430+ json ! ( { "containerInstance" : "aaaaaaaa" } ) ,
1431+ ) )
1432+ . unwrap ( ) ;
1433+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1434+ let arns = body[ "taskArns" ] . as_array ( ) . unwrap ( ) ;
1435+ assert_eq ! ( arns. len( ) , 1 ) ;
1436+ assert ! ( arns[ 0 ] . as_str( ) . unwrap( ) . ends_with( "/web1" ) ) ;
1437+
1438+ // containerInstance filter also accepts a full ARN.
1439+ let resp = svc
1440+ . list_tasks ( & make_request (
1441+ "ListTasks" ,
1442+ json ! ( { "containerInstance" : "arn:aws:ecs:us-east-1:000000000000:container-instance/default/bbbbbbbb" } ) ,
1443+ ) )
1444+ . unwrap ( ) ;
1445+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1446+ let arns = body[ "taskArns" ] . as_array ( ) . unwrap ( ) ;
1447+ assert_eq ! ( arns. len( ) , 1 ) ;
1448+ assert ! ( arns[ 0 ] . as_str( ) . unwrap( ) . ends_with( "/web2" ) ) ;
1449+ }
1450+ }
0 commit comments