Skip to content

Commit 8d505bb

Browse files
authored
fix(ecs): correct UpdateService/CreateService validation and ListTasks filtering (bug-hunt) (#2310)
2 parents 6f1d939 + 2826786 commit 8d505bb

3 files changed

Lines changed: 201 additions & 10 deletions

File tree

crates/fakecloud-ecs/src/helpers.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,13 @@ pub(crate) fn service_name_from_ref(input: &str) -> String {
764764
input.to_string()
765765
}
766766

767+
/// Extract the trailing ID segment from an ARN or path-style ref, e.g. a
768+
/// container-instance ARN `arn:aws:ecs:...:container-instance/<cluster>/<id>`
769+
/// yields `<id>`. A bare ID is returned unchanged.
770+
pub(crate) fn id_from_ref(input: &str) -> String {
771+
input.rsplit('/').next().unwrap_or(input).to_string()
772+
}
773+
767774
pub(crate) fn service_not_found(name: &str) -> AwsServiceError {
768775
AwsServiceError::aws_error(
769776
StatusCode::BAD_REQUEST,
@@ -772,11 +779,21 @@ pub(crate) fn service_not_found(name: &str) -> AwsServiceError {
772779
)
773780
}
774781

775-
pub(crate) fn service_already_exists(name: &str) -> AwsServiceError {
782+
pub(crate) fn service_already_exists() -> AwsServiceError {
783+
// AWS returns InvalidParameterException (not ServiceNotActiveException)
784+
// when a CreateService call collides with an existing active service.
785+
AwsServiceError::aws_error(
786+
StatusCode::BAD_REQUEST,
787+
"InvalidParameterException",
788+
"Creation of service was not idempotent.",
789+
)
790+
}
791+
792+
pub(crate) fn service_not_active(name: &str) -> AwsServiceError {
776793
AwsServiceError::aws_error(
777794
StatusCode::BAD_REQUEST,
778795
"ServiceNotActiveException",
779-
format!("The service {name} already exists"),
796+
format!("Service {name} is not ACTIVE."),
780797
)
781798
}
782799

crates/fakecloud-ecs/src/service_services_resource.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ impl EcsService {
150150
let key = EcsState::service_key(&cluster_name, &service_name);
151151
if let Some(existing) = state.services.get(&key) {
152152
if existing.status != "INACTIVE" {
153-
return Err(service_already_exists(&service_name));
153+
return Err(service_already_exists());
154154
}
155155
}
156156
let is_code_deploy = deployment_controller == "CODE_DEPLOY";
@@ -350,6 +350,16 @@ impl EcsService {
350350
let cluster_ref = opt_str(&body, "cluster");
351351
let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
352352
let new_desired = body.get("desiredCount").and_then(|v| v.as_i64());
353+
// AWS rejects a negative desiredCount with InvalidParameterException
354+
// rather than silently clamping it to 0 (which would scale the service
355+
// to zero and cause a silent outage). Matches CreateService's message.
356+
if let Some(n) = new_desired {
357+
if n < 0 {
358+
return Err(invalid_parameter(format!(
359+
"desiredCount cannot be negative. desiredCount={n}"
360+
)));
361+
}
362+
}
353363
let new_td_ref = opt_str(&body, "taskDefinition");
354364
let update_lifecycle_hooks: Vec<Value> = body
355365
.get("deploymentConfiguration")
@@ -371,8 +381,15 @@ impl EcsService {
371381
.get_mut(&account)
372382
.ok_or_else(|| service_not_found(&service_name))?;
373383
let key = EcsState::service_key(&cluster_name, &service_name);
374-
if !state.services.contains_key(&key) {
375-
return Err(service_not_found(&service_name));
384+
// A deleted service transitions to DRAINING then INACTIVE but stays
385+
// describable. UpdateService must not respawn one: AWS returns
386+
// ServiceNotActiveException for any non-ACTIVE service.
387+
match state.services.get(&key) {
388+
None => return Err(service_not_found(&service_name)),
389+
Some(svc) if svc.status != "ACTIVE" => {
390+
return Err(service_not_active(&service_name));
391+
}
392+
Some(_) => {}
376393
}
377394

378395
// Resolve new task definition (may stay on current one).
@@ -486,7 +503,8 @@ impl EcsService {
486503
}
487504

488505
if let Some(n) = new_desired {
489-
let n = n.max(0) as i32;
506+
// Negatives are already rejected above; n is >= 0 here.
507+
let n = n as i32;
490508
svc.desired_count = n;
491509
if svc.deployment_controller != "CODE_DEPLOY" {
492510
if let Some(d) = svc.deployments.iter_mut().find(|d| d.status == "PRIMARY")
@@ -1073,12 +1091,14 @@ impl EcsService {
10731091
let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
10741092
let launch_type = opt_str(&body, "launchType");
10751093
let scheduling = opt_str(&body, "schedulingStrategy");
1094+
// The ListServices model defaults maxResults to 10 (unlike sibling ECS
1095+
// list ops which default to 100).
10761096
let max_results = body
10771097
.get("maxResults")
10781098
.and_then(|v| v.as_i64())
10791099
.filter(|n| (1..=100).contains(n))
10801100
.map(|n| n as usize)
1081-
.unwrap_or(100);
1101+
.unwrap_or(10);
10821102
let next_token = opt_str(&body, "nextToken").unwrap_or("");
10831103

10841104
let account = request.account_id.clone();

crates/fakecloud-ecs/src/service_tasks.rs

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)