1use std::sync::Arc;
4
5use async_trait::async_trait;
6use http::{Method, StatusCode};
7use serde_json::{json, Map, Value};
8use tokio::sync::Mutex as AsyncMutex;
9use uuid::Uuid;
10
11use fakecloud_aws::arn::Arn;
12use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
13use fakecloud_persistence::{SnapshotHook, SnapshotStore};
14
15use crate::state::{BatchSnapshot, SharedBatchState, BATCH_SNAPSHOT_SCHEMA_VERSION};
16
17const SUPPORTED_ACTIONS: &[&str] = &[
18 "CancelJob",
19 "CreateComputeEnvironment",
20 "CreateConsumableResource",
21 "CreateJobQueue",
22 "CreateQuotaShare",
23 "CreateSchedulingPolicy",
24 "CreateServiceEnvironment",
25 "DeleteComputeEnvironment",
26 "DeleteConsumableResource",
27 "DeleteJobQueue",
28 "DeleteQuotaShare",
29 "DeleteSchedulingPolicy",
30 "DeleteServiceEnvironment",
31 "DeregisterJobDefinition",
32 "DescribeComputeEnvironments",
33 "DescribeConsumableResource",
34 "DescribeJobDefinitions",
35 "DescribeJobQueues",
36 "DescribeJobs",
37 "DescribeQuotaShare",
38 "DescribeSchedulingPolicies",
39 "DescribeServiceEnvironments",
40 "DescribeServiceJob",
41 "GetJobQueueSnapshot",
42 "ListConsumableResources",
43 "ListJobs",
44 "ListJobsByConsumableResource",
45 "ListQuotaShares",
46 "ListSchedulingPolicies",
47 "ListServiceJobs",
48 "ListTagsForResource",
49 "RegisterJobDefinition",
50 "SubmitJob",
51 "SubmitServiceJob",
52 "TagResource",
53 "TerminateJob",
54 "TerminateServiceJob",
55 "UntagResource",
56 "UpdateComputeEnvironment",
57 "UpdateConsumableResource",
58 "UpdateJobQueue",
59 "UpdateQuotaShare",
60 "UpdateSchedulingPolicy",
61 "UpdateServiceEnvironment",
62 "UpdateServiceJob",
63];
64
65const MUTATING_ACTIONS: &[&str] = &[
67 "CreateComputeEnvironment",
68 "UpdateComputeEnvironment",
69 "DeleteComputeEnvironment",
70 "CreateJobQueue",
71 "UpdateJobQueue",
72 "DeleteJobQueue",
73 "RegisterJobDefinition",
74 "DeregisterJobDefinition",
75 "CreateSchedulingPolicy",
76 "UpdateSchedulingPolicy",
77 "DeleteSchedulingPolicy",
78 "SubmitJob",
79 "CancelJob",
80 "TerminateJob",
81 "TagResource",
82 "UntagResource",
83];
84
85pub struct BatchService {
86 state: SharedBatchState,
87 snapshot_store: Option<Arc<dyn SnapshotStore>>,
88 snapshot_lock: Arc<AsyncMutex<()>>,
89 ecs_state: Option<fakecloud_ecs::SharedEcsState>,
93 ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
94}
95
96impl BatchService {
97 pub fn new(state: SharedBatchState) -> Self {
98 Self {
99 state,
100 snapshot_store: None,
101 snapshot_lock: Arc::new(AsyncMutex::new(())),
102 ecs_state: None,
103 ecs_runtime: None,
104 }
105 }
106
107 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
108 self.snapshot_store = Some(store);
109 self
110 }
111
112 pub fn with_ecs(
115 mut self,
116 state: fakecloud_ecs::SharedEcsState,
117 runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
118 ) -> Self {
119 self.ecs_state = Some(state);
120 self.ecs_runtime = runtime;
121 self
122 }
123
124 async fn save_snapshot(&self) {
125 let Some(store) = self.snapshot_store.clone() else {
126 return;
127 };
128 let _guard = self.snapshot_lock.lock().await;
129 let bytes = {
130 let snap = BatchSnapshot {
131 schema_version: BATCH_SNAPSHOT_SCHEMA_VERSION,
132 accounts: Some(self.state.read().clone()),
133 };
134 serde_json::to_vec(&snap).unwrap_or_default()
135 };
136 let _ = tokio::task::spawn_blocking(move || store.save(&bytes)).await;
137 }
138
139 pub fn snapshot_hook(&self) -> Option<SnapshotHook> {
141 let store = self.snapshot_store.clone()?;
142 let state = self.state.clone();
143 let lock = self.snapshot_lock.clone();
144 Some(Arc::new(move || {
145 let store = store.clone();
146 let state = state.clone();
147 let lock = lock.clone();
148 Box::pin(async move {
149 let _guard = lock.lock().await;
150 let bytes = {
151 let snap = BatchSnapshot {
152 schema_version: BATCH_SNAPSHOT_SCHEMA_VERSION,
153 accounts: Some(state.read().clone()),
154 };
155 serde_json::to_vec(&snap).unwrap_or_default()
156 };
157 let _ = tokio::task::spawn_blocking(move || store.save(&bytes)).await;
158 })
159 }))
160 }
161
162 pub async fn reconcile_persisted_jobs(&self) {
169 const NON_TERMINAL: &[&str] = &["SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING"];
170 let now = chrono::Utc::now().timestamp_millis();
171 let mut changed = false;
172 {
173 let mut accounts = self.state.write();
174 for acct in accounts.accounts.values_mut() {
175 for job in acct.jobs.values_mut() {
176 let Some(o) = job.as_object_mut() else {
177 continue;
178 };
179 let st = o.get("status").and_then(Value::as_str).unwrap_or("");
180 if NON_TERMINAL.contains(&st) {
181 o.insert("status".into(), json!("FAILED"));
182 o.insert(
183 "statusReason".into(),
184 json!("Job interrupted by a fakecloud restart"),
185 );
186 o.entry("stoppedAt".to_string())
187 .or_insert_with(|| json!(now));
188 changed = true;
189 }
190 }
191 }
192 }
193 if changed {
194 self.save_snapshot().await;
195 }
196 }
197
198 fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
201 let segs = &req.path_segments;
202 if segs.first().map(|s| s.as_str()) != Some("v1") {
203 return None;
204 }
205 if segs.get(1).map(|s| s.as_str()) == Some("tags") {
207 return match req.method {
208 Method::GET => Some("ListTagsForResource"),
209 Method::POST => Some("TagResource"),
210 Method::DELETE => Some("UntagResource"),
211 _ => None,
212 };
213 }
214 let op = segs.get(1)?.as_str();
215 Some(match op {
216 "canceljob" => "CancelJob",
217 "createcomputeenvironment" => "CreateComputeEnvironment",
218 "createconsumableresource" => "CreateConsumableResource",
219 "createjobqueue" => "CreateJobQueue",
220 "createquotashare" => "CreateQuotaShare",
221 "createschedulingpolicy" => "CreateSchedulingPolicy",
222 "createserviceenvironment" => "CreateServiceEnvironment",
223 "deletecomputeenvironment" => "DeleteComputeEnvironment",
224 "deleteconsumableresource" => "DeleteConsumableResource",
225 "deletejobqueue" => "DeleteJobQueue",
226 "deletequotashare" => "DeleteQuotaShare",
227 "deleteschedulingpolicy" => "DeleteSchedulingPolicy",
228 "deleteserviceenvironment" => "DeleteServiceEnvironment",
229 "deregisterjobdefinition" => "DeregisterJobDefinition",
230 "describecomputeenvironments" => "DescribeComputeEnvironments",
231 "describeconsumableresource" => "DescribeConsumableResource",
232 "describejobdefinitions" => "DescribeJobDefinitions",
233 "describejobqueues" => "DescribeJobQueues",
234 "describejobs" => "DescribeJobs",
235 "describequotashare" => "DescribeQuotaShare",
236 "describeschedulingpolicies" => "DescribeSchedulingPolicies",
237 "describeserviceenvironments" => "DescribeServiceEnvironments",
238 "describeservicejob" => "DescribeServiceJob",
239 "getjobqueuesnapshot" => "GetJobQueueSnapshot",
240 "listconsumableresources" => "ListConsumableResources",
241 "listjobs" => "ListJobs",
242 "listjobsbyconsumableresource" => "ListJobsByConsumableResource",
243 "listquotashares" => "ListQuotaShares",
244 "listschedulingpolicies" => "ListSchedulingPolicies",
245 "listservicejobs" => "ListServiceJobs",
246 "registerjobdefinition" => "RegisterJobDefinition",
247 "submitjob" => "SubmitJob",
248 "submitservicejob" => "SubmitServiceJob",
249 "terminatejob" => "TerminateJob",
250 "terminateservicejob" => "TerminateServiceJob",
251 "updatecomputeenvironment" => "UpdateComputeEnvironment",
252 "updateconsumableresource" => "UpdateConsumableResource",
253 "updatejobqueue" => "UpdateJobQueue",
254 "updatequotashare" => "UpdateQuotaShare",
255 "updateschedulingpolicy" => "UpdateSchedulingPolicy",
256 "updateserviceenvironment" => "UpdateServiceEnvironment",
257 "updateservicejob" => "UpdateServiceJob",
258 _ => return None,
259 })
260 }
261
262 fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
263 match action {
264 "CreateComputeEnvironment" => self.create_compute_environment(req),
265 "DescribeComputeEnvironments" => self.describe_compute_environments(req),
266 "DeleteComputeEnvironment" => self.delete_compute_environment(req),
267 "CreateJobQueue" => self.create_job_queue(req),
268 "DescribeJobQueues" => self.describe_job_queues(req),
269 "DeleteJobQueue" => self.delete_job_queue(req),
270 "UpdateComputeEnvironment" => self.update_compute_environment(req),
271 "UpdateJobQueue" => self.update_job_queue(req),
272 "RegisterJobDefinition" => self.register_job_definition(req),
273 "DescribeJobDefinitions" => self.describe_job_definitions(req),
274 "DeregisterJobDefinition" => self.deregister_job_definition(req),
275 "CreateSchedulingPolicy" => self.create_scheduling_policy(req),
276 "DescribeSchedulingPolicies" => self.describe_scheduling_policies(req),
277 "ListSchedulingPolicies" => self.list_scheduling_policies(req),
278 "UpdateSchedulingPolicy" => self.update_scheduling_policy(req),
279 "DeleteSchedulingPolicy" => self.delete_scheduling_policy(req),
280 "DescribeJobs" => self.describe_jobs(req),
281 "ListJobs" => self.list_jobs(req),
282 "CancelJob" => self.cancel_job(req),
283 "TerminateJob" => self.terminate_job(req),
284 "TagResource" => self.tag_resource(req),
285 "UntagResource" => self.untag_resource(req),
286 "ListTagsForResource" => self.list_tags_for_resource(req),
287 other => Err(AwsServiceError::action_not_implemented("batch", other)),
288 }
289 }
290}
291
292fn obj(v: &Value) -> Map<String, Value> {
293 v.as_object().cloned().unwrap_or_default()
294}
295
296fn client_error(code: &str, msg: impl Into<String>) -> AwsServiceError {
297 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg.into())
298}
299
300type TagStore = std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>;
301
302fn seed_inline_tags(tags: &mut TagStore, arn: &str, stored: &Map<String, Value>) {
305 if let Some(inline) = stored.get("tags").and_then(Value::as_object) {
306 let entry = tags.entry(arn.to_string()).or_default();
307 for (k, v) in inline {
308 if let Some(s) = v.as_str() {
309 entry.insert(k.clone(), s.to_string());
310 }
311 }
312 }
313}
314
315fn merge_tag_overlay(resource: &Value, arn_key: &str, tags: &TagStore) -> Value {
319 let mut o = obj(resource);
320 if let Some(arn) = o.get(arn_key).and_then(Value::as_str).map(String::from) {
321 if let Some(t) = tags.get(&arn) {
322 o.insert(
323 "tags".into(),
324 Value::Object(t.iter().map(|(k, v)| (k.clone(), json!(v))).collect()),
325 );
326 }
327 }
328 Value::Object(o)
329}
330
331fn job_summary(j: &Value) -> Value {
335 let mut s = serde_json::Map::new();
336 for key in [
337 "jobId",
338 "jobArn",
339 "jobName",
340 "createdAt",
341 "status",
342 "statusReason",
343 "startedAt",
344 "stoppedAt",
345 "jobDefinition",
346 "container",
347 "arrayProperties",
348 "nodeProperties",
349 ] {
350 if let Some(v) = j.get(key) {
351 s.insert(key.to_string(), v.clone());
352 }
353 }
354 Value::Object(s)
355}
356
357impl BatchService {
358 fn arn(&self, account: &str, region: &str, resource: &str) -> String {
359 Arn::new("batch", region, account, resource).to_string()
360 }
361
362 fn create_compute_environment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
365 let body = req.json_body();
366 let name = body
367 .get("computeEnvironmentName")
368 .and_then(Value::as_str)
369 .ok_or_else(|| client_error("ClientException", "computeEnvironmentName is required"))?
370 .to_string();
371 let arn = self.arn(
372 &req.account_id,
373 &req.region,
374 &format!("compute-environment/{name}"),
375 );
376 let mut stored = obj(&body);
377 stored.insert("computeEnvironmentArn".into(), json!(arn));
378 stored.insert("status".into(), json!("VALID"));
379 stored.insert("statusReason".into(), json!("ComputeEnvironment Healthy"));
380 stored
381 .entry("state".to_string())
382 .or_insert_with(|| json!("ENABLED"));
383 let uuid = Uuid::new_v4().to_string();
384 stored.insert(
387 "ecsClusterArn".into(),
388 json!(format!(
389 "arn:aws:ecs:{}:{}:cluster/AWSBatch-{name}-{uuid}",
390 req.region, req.account_id
391 )),
392 );
393 stored.insert("uuid".into(), json!(uuid));
394
395 let mut accounts = self.state.write();
396 let st = accounts.get_or_create(&req.account_id);
397 if st.compute_environments.contains_key(&name) {
398 return Err(client_error(
399 "ClientException",
400 format!("Object already exists: {name}"),
401 ));
402 }
403 seed_inline_tags(&mut st.tags, &arn, &stored);
404 st.compute_environments
405 .insert(name.clone(), Value::Object(stored));
406 Ok(AwsResponse::ok_json(json!({
407 "computeEnvironmentName": name,
408 "computeEnvironmentArn": arn,
409 })))
410 }
411
412 fn describe_compute_environments(
413 &self,
414 req: &AwsRequest,
415 ) -> Result<AwsResponse, AwsServiceError> {
416 let body = req.json_body();
417 let wanted = string_set(&body, "computeEnvironments");
418 let accounts = self.state.read();
419 let items: Vec<Value> = accounts
420 .get(&req.account_id)
421 .map(|st| {
422 st.compute_environments
423 .values()
424 .filter(|ce| {
425 match_named(
426 ce,
427 &wanted,
428 "computeEnvironmentName",
429 "computeEnvironmentArn",
430 )
431 })
432 .map(|ce| {
433 let mut v = merge_tag_overlay(ce, "computeEnvironmentArn", &st.tags);
434 if let Some(o) = v.as_object_mut() {
436 o.entry("containerOrchestrationType".to_string())
437 .or_insert_with(|| json!("ECS"));
438 }
439 v
440 })
441 .collect()
442 })
443 .unwrap_or_default();
444 Ok(AwsResponse::ok_json(
445 json!({ "computeEnvironments": items }),
446 ))
447 }
448
449 fn delete_compute_environment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
450 let body = req.json_body();
451 let name = arn_or_name(&body, "computeEnvironment")?;
452 let mut accounts = self.state.write();
453 accounts
454 .get_or_create(&req.account_id)
455 .compute_environments
456 .remove(&name);
457 Ok(AwsResponse::ok_json(json!({})))
458 }
459
460 fn create_job_queue(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
463 let body = req.json_body();
464 let name = body
465 .get("jobQueueName")
466 .and_then(Value::as_str)
467 .ok_or_else(|| client_error("ClientException", "jobQueueName is required"))?
468 .to_string();
469 let arn = self.arn(&req.account_id, &req.region, &format!("job-queue/{name}"));
470 let mut stored = obj(&body);
471 stored.insert("jobQueueArn".into(), json!(arn));
472 stored.insert("status".into(), json!("VALID"));
473 stored.insert("statusReason".into(), json!("JobQueue Healthy"));
474 stored
475 .entry("state".to_string())
476 .or_insert_with(|| json!("ENABLED"));
477
478 let mut accounts = self.state.write();
479 let st = accounts.get_or_create(&req.account_id);
480 if st.job_queues.contains_key(&name) {
481 return Err(client_error(
482 "ClientException",
483 format!("Object already exists: {name}"),
484 ));
485 }
486 seed_inline_tags(&mut st.tags, &arn, &stored);
487 st.job_queues.insert(name.clone(), Value::Object(stored));
488 Ok(AwsResponse::ok_json(json!({
489 "jobQueueName": name,
490 "jobQueueArn": arn,
491 })))
492 }
493
494 fn describe_job_queues(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
495 let body = req.json_body();
496 let wanted = string_set(&body, "jobQueues");
497 let accounts = self.state.read();
498 let items: Vec<Value> = accounts
499 .get(&req.account_id)
500 .map(|st| {
501 st.job_queues
502 .values()
503 .filter(|q| match_named(q, &wanted, "jobQueueName", "jobQueueArn"))
504 .map(|q| merge_tag_overlay(q, "jobQueueArn", &st.tags))
505 .collect()
506 })
507 .unwrap_or_default();
508 Ok(AwsResponse::ok_json(json!({ "jobQueues": items })))
509 }
510
511 fn delete_job_queue(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
512 let body = req.json_body();
513 let name = arn_or_name(&body, "jobQueue")?;
514 let mut accounts = self.state.write();
515 accounts
516 .get_or_create(&req.account_id)
517 .job_queues
518 .remove(&name);
519 Ok(AwsResponse::ok_json(json!({})))
520 }
521
522 fn register_job_definition(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
525 let body = req.json_body();
526 let name = body
527 .get("jobDefinitionName")
528 .and_then(Value::as_str)
529 .ok_or_else(|| client_error("ClientException", "jobDefinitionName is required"))?
530 .to_string();
531 let mut accounts = self.state.write();
532 let st = accounts.get_or_create(&req.account_id);
533 let revision = st.job_def_revisions.entry(name.clone()).or_insert(0);
534 *revision += 1;
535 let revision = *revision;
536 let arn = self.arn(
537 &req.account_id,
538 &req.region,
539 &format!("job-definition/{name}:{revision}"),
540 );
541 let mut stored = obj(&body);
542 stored.insert("jobDefinitionArn".into(), json!(arn));
543 stored.insert("revision".into(), json!(revision));
544 stored.insert("status".into(), json!("ACTIVE"));
545 if let Some(cp) = stored
549 .get_mut("containerProperties")
550 .and_then(Value::as_object_mut)
551 {
552 for key in [
553 "environment",
554 "mountPoints",
555 "resourceRequirements",
556 "secrets",
557 "ulimits",
558 "volumes",
559 ] {
560 cp.entry(key.to_string()).or_insert_with(|| json!([]));
561 }
562 }
563 seed_inline_tags(&mut st.tags, &arn, &stored);
564 st.job_definitions
565 .insert(format!("{name}:{revision}"), Value::Object(stored));
566 Ok(AwsResponse::ok_json(json!({
567 "jobDefinitionName": name,
568 "jobDefinitionArn": arn,
569 "revision": revision,
570 })))
571 }
572
573 fn describe_job_definitions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
574 let body = req.json_body();
575 let wanted = string_set(&body, "jobDefinitions");
576 let name_filter = body
577 .get("jobDefinitionName")
578 .and_then(Value::as_str)
579 .map(|s| s.to_string());
580 let status_filter = body
581 .get("status")
582 .and_then(Value::as_str)
583 .map(|s| s.to_string());
584 let accounts = self.state.read();
585 let items: Vec<Value> = accounts
586 .get(&req.account_id)
587 .map(|st| {
588 st.job_definitions
589 .values()
590 .filter(|jd| {
591 let arn_ok = wanted.is_empty()
593 || jd
594 .get("jobDefinitionArn")
595 .and_then(Value::as_str)
596 .map(|a| wanted.contains(a))
597 .unwrap_or(false)
598 || jd
599 .get("jobDefinitionName")
600 .and_then(Value::as_str)
601 .map(|n| wanted.contains(n))
602 .unwrap_or(false);
603 let name_ok = name_filter.as_deref().is_none_or(|n| {
604 jd.get("jobDefinitionName").and_then(Value::as_str) == Some(n)
605 });
606 let status_ok = status_filter
607 .as_deref()
608 .is_none_or(|s| jd.get("status").and_then(Value::as_str) == Some(s));
609 arn_ok && name_ok && status_ok
610 })
611 .map(|jd| merge_tag_overlay(jd, "jobDefinitionArn", &st.tags))
612 .collect()
613 })
614 .unwrap_or_default();
615 Ok(AwsResponse::ok_json(json!({ "jobDefinitions": items })))
616 }
617
618 fn deregister_job_definition(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
619 let body = req.json_body();
620 let id = body
621 .get("jobDefinition")
622 .and_then(Value::as_str)
623 .ok_or_else(|| client_error("ClientException", "jobDefinition is required"))?;
624 let key = id.rsplit('/').next().unwrap_or(id).to_string();
626 let mut accounts = self.state.write();
627 let st = accounts.get_or_create(&req.account_id);
628 if let Some(jd) = st.job_definitions.get_mut(&key) {
629 if let Some(o) = jd.as_object_mut() {
630 o.insert("status".into(), json!("INACTIVE"));
631 }
632 }
633 Ok(AwsResponse::ok_json(json!({})))
634 }
635
636 fn update_compute_environment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
637 let body = req.json_body();
638 let name = arn_or_name(&body, "computeEnvironment")?;
639 let mut accounts = self.state.write();
640 let st = accounts.get_or_create(&req.account_id);
641 let ce = st
642 .compute_environments
643 .get_mut(&name)
644 .ok_or_else(|| client_error("ClientException", format!("Object not found: {name}")))?;
645 let arn = merge_updates(
646 ce,
647 &body,
648 &[
649 "state",
650 "desiredvCpus",
651 "computeResources",
652 "serviceRole",
653 "updatePolicy",
654 ],
655 "computeEnvironmentArn",
656 );
657 Ok(AwsResponse::ok_json(json!({
658 "computeEnvironmentName": name,
659 "computeEnvironmentArn": arn,
660 })))
661 }
662
663 fn update_job_queue(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
664 let body = req.json_body();
665 let name = arn_or_name(&body, "jobQueue")?;
666 let mut accounts = self.state.write();
667 let st = accounts.get_or_create(&req.account_id);
668 let q = st
669 .job_queues
670 .get_mut(&name)
671 .ok_or_else(|| client_error("ClientException", format!("Object not found: {name}")))?;
672 let arn = merge_updates(
673 q,
674 &body,
675 &[
676 "state",
677 "priority",
678 "computeEnvironmentOrder",
679 "schedulingPolicyArn",
680 "jobStateTimeLimitActions",
681 ],
682 "jobQueueArn",
683 );
684 Ok(AwsResponse::ok_json(json!({
685 "jobQueueName": name,
686 "jobQueueArn": arn,
687 })))
688 }
689
690 fn create_scheduling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
693 let body = req.json_body();
694 let name = body
695 .get("name")
696 .and_then(Value::as_str)
697 .ok_or_else(|| client_error("ClientException", "name is required"))?
698 .to_string();
699 let arn = self.arn(
700 &req.account_id,
701 &req.region,
702 &format!("scheduling-policy/{name}"),
703 );
704 let mut stored = obj(&body);
705 stored.insert("arn".into(), json!(arn));
706 let mut accounts = self.state.write();
707 let st = accounts.get_or_create(&req.account_id);
708 if st.scheduling_policies.contains_key(&name) {
709 return Err(client_error(
710 "ClientException",
711 format!("Object already exists: {name}"),
712 ));
713 }
714 seed_inline_tags(&mut st.tags, &arn, &stored);
715 st.scheduling_policies
716 .insert(name.clone(), Value::Object(stored));
717 Ok(AwsResponse::ok_json(json!({ "name": name, "arn": arn })))
718 }
719
720 fn describe_scheduling_policies(
721 &self,
722 req: &AwsRequest,
723 ) -> Result<AwsResponse, AwsServiceError> {
724 let body = req.json_body();
725 let wanted = string_set(&body, "arns");
726 let accounts = self.state.read();
727 let items: Vec<Value> = accounts
728 .get(&req.account_id)
729 .map(|st| {
730 st.scheduling_policies
731 .values()
732 .filter(|p| {
733 wanted.is_empty()
734 || p.get("arn")
735 .and_then(Value::as_str)
736 .map(|a| wanted.contains(a))
737 .unwrap_or(false)
738 })
739 .map(|p| merge_tag_overlay(p, "arn", &st.tags))
740 .collect()
741 })
742 .unwrap_or_default();
743 Ok(AwsResponse::ok_json(json!({ "schedulingPolicies": items })))
744 }
745
746 fn list_scheduling_policies(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
747 let accounts = self.state.read();
748 let items: Vec<Value> = accounts
749 .get(&req.account_id)
750 .map(|st| {
751 st.scheduling_policies
752 .values()
753 .filter_map(|p| p.get("arn").map(|a| json!({ "arn": a })))
754 .collect()
755 })
756 .unwrap_or_default();
757 Ok(AwsResponse::ok_json(json!({ "schedulingPolicies": items })))
758 }
759
760 fn update_scheduling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
761 let body = req.json_body();
762 let name = arn_or_name(&body, "arn")?;
763 let mut accounts = self.state.write();
764 let st = accounts.get_or_create(&req.account_id);
765 let p = st
766 .scheduling_policies
767 .get_mut(&name)
768 .ok_or_else(|| client_error("ClientException", format!("Object not found: {name}")))?;
769 merge_updates(p, &body, &["fairsharePolicy"], "arn");
770 Ok(AwsResponse::ok_json(json!({})))
771 }
772
773 fn delete_scheduling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
774 let body = req.json_body();
775 let name = arn_or_name(&body, "arn")?;
776 let mut accounts = self.state.write();
777 accounts
778 .get_or_create(&req.account_id)
779 .scheduling_policies
780 .remove(&name);
781 Ok(AwsResponse::ok_json(json!({})))
782 }
783
784 async fn submit_job(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
787 let body = req.json_body();
788 let job_name = body
789 .get("jobName")
790 .and_then(Value::as_str)
791 .ok_or_else(|| client_error("ClientException", "jobName is required"))?
792 .to_string();
793 let job_queue = body
794 .get("jobQueue")
795 .and_then(Value::as_str)
796 .ok_or_else(|| client_error("ClientException", "jobQueue is required"))?
797 .to_string();
798 {
800 let queue_name = job_queue.rsplit('/').next().unwrap_or(&job_queue);
801 let accounts = self.state.read();
802 let exists = accounts.get(&req.account_id).is_some_and(|st| {
803 st.job_queues.contains_key(queue_name)
804 || st.job_queues.values().any(|q| {
805 q.get("jobQueueArn").and_then(Value::as_str) == Some(job_queue.as_str())
806 })
807 });
808 if !exists {
809 return Err(client_error(
810 "ClientException",
811 format!("Job queue {job_queue} does not exist"),
812 ));
813 }
814 }
815 let job_definition = body
816 .get("jobDefinition")
817 .and_then(Value::as_str)
818 .ok_or_else(|| client_error("ClientException", "jobDefinition is required"))?
819 .to_string();
820 let job_id = Uuid::new_v4().to_string();
821 let arn = self.arn(&req.account_id, &req.region, &format!("job/{job_id}"));
822 let now = chrono::Utc::now().timestamp_millis();
823 let array_size = match body
827 .pointer("/arrayProperties/size")
828 .and_then(Value::as_i64)
829 {
830 Some(n) if (2..=10_000).contains(&n) => Some(n),
831 Some(n) => {
832 return Err(client_error(
833 "ClientException",
834 format!("Array job size must be between 2 and 10000, but was {n}"),
835 ));
836 }
837 None => None,
838 };
839 let depends_on: Vec<String> = body
840 .get("dependsOn")
841 .and_then(Value::as_array)
842 .map(|a| {
843 a.iter()
844 .filter_map(|d| d.get("jobId").and_then(Value::as_str).map(String::from))
845 .collect()
846 })
847 .unwrap_or_default();
848
849 let mut job = obj(&body);
850 job.insert("jobId".into(), json!(job_id));
851 job.insert("jobArn".into(), json!(arn));
852 job.insert("jobName".into(), json!(job_name));
853 job.insert("jobQueue".into(), json!(job_queue));
854 job.insert("jobDefinition".into(), json!(job_definition));
855 job.insert("createdAt".into(), json!(now));
856
857 let container = self.resolve_container(&req.account_id, &job_definition, &body);
860
861 if let Some(size) = array_size {
862 job.insert("status".into(), json!("PENDING"));
867 job.insert("arrayProperties".into(), json!({ "size": size }));
868 {
869 let mut accounts = self.state.write();
870 accounts
871 .get_or_create(&req.account_id)
872 .jobs
873 .insert(job_id.clone(), Value::Object(job));
874 }
875 for index in 0..size {
876 let child_id = format!("{job_id}:{index}");
877 let child_arn = self.arn(&req.account_id, &req.region, &format!("job/{child_id}"));
878 let mut child = serde_json::Map::new();
879 child.insert("jobId".into(), json!(child_id));
880 child.insert("jobArn".into(), json!(child_arn));
881 child.insert("jobName".into(), json!(job_name));
882 child.insert("jobQueue".into(), json!(job_queue));
883 child.insert("jobDefinition".into(), json!(job_definition));
884 child.insert("status".into(), json!("SUBMITTED"));
885 child.insert("createdAt".into(), json!(now));
886 child.insert(
887 "arrayProperties".into(),
888 json!({ "index": index, "statusSummary": {} }),
889 );
890 {
891 let mut accounts = self.state.write();
892 accounts
893 .get_or_create(&req.account_id)
894 .jobs
895 .insert(child_id.clone(), Value::Object(child));
896 }
897 let child_container = container.clone().map(|c| with_array_index_env(c, index));
898 self.launch_job(req, &child_id, &job_name, child_container, now)
899 .await;
900 }
901 } else if !depends_on.is_empty() {
902 job.insert("status".into(), json!("PENDING"));
906 {
907 let mut accounts = self.state.write();
908 accounts
909 .get_or_create(&req.account_id)
910 .jobs
911 .insert(job_id.clone(), Value::Object(job));
912 }
913 spawn_dependency_waiter(
914 self.launch_ctx(),
915 req.account_id.clone(),
916 req.region.clone(),
917 req.request_id.clone(),
918 job_id.clone(),
919 job_name.clone(),
920 container,
921 depends_on,
922 now,
923 );
924 } else {
925 job.insert("status".into(), json!("SUBMITTED"));
926 {
927 let mut accounts = self.state.write();
928 accounts
929 .get_or_create(&req.account_id)
930 .jobs
931 .insert(job_id.clone(), Value::Object(job));
932 }
933 self.launch_job(req, &job_id, &job_name, container, now)
934 .await;
935 }
936
937 Ok(AwsResponse::ok_json(json!({
938 "jobArn": arn,
939 "jobName": job_name,
940 "jobId": job_id,
941 })))
942 }
943
944 fn launch_ctx(&self) -> LaunchCtx {
951 LaunchCtx {
952 batch_state: self.state.clone(),
953 ecs_state: self.ecs_state.clone(),
954 ecs_runtime: self.ecs_runtime.clone(),
955 snapshot_store: self.snapshot_store.clone(),
956 snapshot_lock: self.snapshot_lock.clone(),
957 }
958 }
959
960 async fn launch_job(
961 &self,
962 req: &AwsRequest,
963 job_id: &str,
964 job_name: &str,
965 container: Option<Value>,
966 now: i64,
967 ) {
968 launch(
969 &self.launch_ctx(),
970 &req.account_id,
971 &req.region,
972 &req.request_id,
973 job_id,
974 job_name,
975 container,
976 now,
977 )
978 .await;
979 }
980
981 fn resolve_container(
985 &self,
986 account_id: &str,
987 job_definition: &str,
988 submit_body: &Value,
989 ) -> Option<Value> {
990 let key = job_definition.rsplit('/').next().unwrap_or(job_definition);
991 let accounts = self.state.read();
992 let st = accounts.get(account_id)?;
993 let jd = if key.contains(':') {
995 st.job_definitions.get(key).cloned()
996 } else {
997 st.job_definitions
998 .iter()
999 .filter(|(k, _)| k.rsplit_once(':').map(|(n, _)| n) == Some(key))
1000 .max_by_key(|(k, _)| {
1001 k.rsplit_once(':')
1002 .and_then(|(_, r)| r.parse::<i64>().ok())
1003 .unwrap_or(0)
1004 })
1005 .map(|(_, v)| v.clone())
1006 }?;
1007 let mut container = jd.get("containerProperties")?.as_object()?.clone();
1008
1009 if let Some(ov) = submit_body
1010 .get("containerOverrides")
1011 .and_then(Value::as_object)
1012 {
1013 for f in ["command", "environment", "resourceRequirements"] {
1014 if let Some(v) = ov.get(f) {
1015 container.insert(f.to_string(), v.clone());
1016 }
1017 }
1018 }
1019 Some(Value::Object(container))
1020 }
1021
1022 fn describe_jobs(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1023 let body = req.json_body();
1024 let wanted = string_set(&body, "jobs");
1025 let accounts = self.state.read();
1026 let items: Vec<Value> = accounts
1027 .get(&req.account_id)
1028 .map(|st| {
1029 st.jobs
1030 .values()
1031 .filter(|j| {
1032 wanted.is_empty()
1033 || j.get("jobId")
1034 .and_then(Value::as_str)
1035 .map(|id| wanted.contains(id))
1036 .unwrap_or(false)
1037 })
1038 .map(|j| {
1039 let id = j.get("jobId").and_then(Value::as_str).unwrap_or("");
1043 let is_parent = j.pointer("/arrayProperties/size").is_some();
1044 if !is_parent {
1045 return j.clone();
1046 }
1047 let prefix = format!("{id}:");
1048 let children: Vec<&Value> = st
1049 .jobs
1050 .iter()
1051 .filter(|(k, _)| k.starts_with(&prefix))
1052 .map(|(_, v)| v)
1053 .collect();
1054 let (summary, status) = array_status_summary(&children);
1055 let mut out = j.clone();
1056 if let Some(o) = out.as_object_mut() {
1057 o.insert("status".into(), json!(status));
1058 if let Some(ap) =
1059 o.get_mut("arrayProperties").and_then(|v| v.as_object_mut())
1060 {
1061 ap.insert("statusSummary".into(), summary);
1062 }
1063 }
1064 out
1065 })
1066 .collect()
1067 })
1068 .unwrap_or_default();
1069 Ok(AwsResponse::ok_json(json!({ "jobs": items })))
1070 }
1071
1072 fn list_jobs(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1073 let body = req.json_body();
1074 let queue = body.get("jobQueue").and_then(Value::as_str);
1075 let array_job_id = body.get("arrayJobId").and_then(Value::as_str);
1076 let multi_node_job_id = body.get("multiNodeJobId").and_then(Value::as_str);
1077 let selectors = [queue, array_job_id, multi_node_job_id]
1079 .iter()
1080 .filter(|s| s.is_some())
1081 .count();
1082 if selectors != 1 {
1083 return Err(client_error(
1084 "ClientException",
1085 "The ListJobs request must specify exactly one of jobQueue, arrayJobId, or multiNodeJobId",
1086 ));
1087 }
1088 let status = body
1090 .get("jobStatus")
1091 .and_then(Value::as_str)
1092 .unwrap_or("RUNNING")
1093 .to_string();
1094 let max_results = body
1095 .get("maxResults")
1096 .and_then(Value::as_i64)
1097 .filter(|n| *n > 0)
1098 .map(|n| n.min(100) as usize)
1099 .unwrap_or(100);
1100 let start: usize = body
1101 .get("nextToken")
1102 .and_then(Value::as_str)
1103 .and_then(|t| t.parse().ok())
1104 .unwrap_or(0);
1105
1106 let accounts = self.state.read();
1107 let mut matched: Vec<&Value> = accounts
1108 .get(&req.account_id)
1109 .map(|st| {
1110 st.jobs
1111 .values()
1112 .filter(|j| {
1113 let selector_ok = if let Some(q) = queue {
1114 j.get("jobQueue").and_then(Value::as_str) == Some(q)
1115 } else if let Some(a) = array_job_id {
1116 j.get("jobId")
1117 .and_then(Value::as_str)
1118 .is_some_and(|id| id.starts_with(&format!("{a}:")))
1119 } else if let Some(m) = multi_node_job_id {
1120 j.get("jobId")
1121 .and_then(Value::as_str)
1122 .is_some_and(|id| id.starts_with(&format!("{m}#")))
1123 } else {
1124 false
1125 };
1126 selector_ok
1127 && j.get("status").and_then(Value::as_str) == Some(status.as_str())
1128 })
1129 .collect()
1130 })
1131 .unwrap_or_default();
1132 matched.sort_by(|a, b| {
1134 let ka = a.get("createdAt").and_then(Value::as_i64).unwrap_or(0);
1135 let kb = b.get("createdAt").and_then(Value::as_i64).unwrap_or(0);
1136 kb.cmp(&ka).then_with(|| {
1137 a.get("jobId")
1138 .and_then(Value::as_str)
1139 .cmp(&b.get("jobId").and_then(Value::as_str))
1140 })
1141 });
1142 let total = matched.len();
1143 let items: Vec<Value> = matched
1144 .into_iter()
1145 .skip(start)
1146 .take(max_results)
1147 .map(job_summary)
1148 .collect();
1149 let mut resp = serde_json::Map::new();
1150 resp.insert("jobSummaryList".into(), Value::Array(items));
1151 let next = start + max_results;
1152 if next < total {
1153 resp.insert("nextToken".into(), json!(next.to_string()));
1154 }
1155 Ok(AwsResponse::ok_json(Value::Object(resp)))
1156 }
1157
1158 fn cancel_job(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1159 self.stop_job(req, &["SUBMITTED", "PENDING", "RUNNABLE"], "CancelJob")
1160 }
1161
1162 fn terminate_job(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1163 self.stop_job(
1164 req,
1165 &["SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING"],
1166 "TerminateJob",
1167 )
1168 }
1169
1170 fn stop_job(
1171 &self,
1172 req: &AwsRequest,
1173 cancelable: &[&str],
1174 op: &str,
1175 ) -> Result<AwsResponse, AwsServiceError> {
1176 let body = req.json_body();
1177 let job_id = body
1178 .get("jobId")
1179 .and_then(Value::as_str)
1180 .ok_or_else(|| client_error("ClientException", "jobId is required"))?
1181 .to_string();
1182 let reason = body
1183 .get("reason")
1184 .and_then(Value::as_str)
1185 .unwrap_or(op)
1186 .to_string();
1187 let mut accounts = self.state.write();
1188 if let Some(job) = accounts
1189 .get_or_create(&req.account_id)
1190 .jobs
1191 .get_mut(&job_id)
1192 {
1193 if let Some(o) = job.as_object_mut() {
1194 let cur = o.get("status").and_then(Value::as_str).unwrap_or("");
1195 if cancelable.contains(&cur) {
1196 o.insert("status".into(), json!("FAILED"));
1197 o.insert("statusReason".into(), json!(reason));
1198 }
1199 }
1200 }
1201 Ok(AwsResponse::ok_json(json!({})))
1202 }
1203
1204 fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1207 let arn = req
1208 .path_segments
1209 .get(2)
1210 .map(|s| percent_decode(s))
1211 .ok_or_else(|| client_error("ClientException", "resourceArn is required"))?;
1212 let body = req.json_body();
1213 let tags = body
1214 .get("tags")
1215 .and_then(Value::as_object)
1216 .cloned()
1217 .unwrap_or_default();
1218 let mut accounts = self.state.write();
1219 let entry = accounts
1220 .get_or_create(&req.account_id)
1221 .tags
1222 .entry(arn)
1223 .or_default();
1224 for (k, v) in tags {
1225 if let Some(s) = v.as_str() {
1226 entry.insert(k, s.to_string());
1227 }
1228 }
1229 Ok(AwsResponse::ok_json(json!({})))
1230 }
1231
1232 fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1233 let arn = req
1234 .path_segments
1235 .get(2)
1236 .map(|s| percent_decode(s))
1237 .ok_or_else(|| client_error("ClientException", "resourceArn is required"))?;
1238 let keys: Vec<String> = req
1242 .raw_query
1243 .split('&')
1244 .filter_map(|pair| pair.strip_prefix("tagKeys="))
1245 .map(percent_decode)
1246 .collect();
1247 let mut accounts = self.state.write();
1248 if let Some(entry) = accounts.get_or_create(&req.account_id).tags.get_mut(&arn) {
1249 for k in keys {
1250 entry.remove(&k);
1251 }
1252 }
1253 Ok(AwsResponse::ok_json(json!({})))
1254 }
1255
1256 fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1257 let arn = req
1258 .path_segments
1259 .get(2)
1260 .map(|s| percent_decode(s))
1261 .ok_or_else(|| client_error("ClientException", "resourceArn is required"))?;
1262 let accounts = self.state.read();
1263 let tags = accounts
1264 .get(&req.account_id)
1265 .and_then(|st| st.tags.get(&arn))
1266 .map(|m| {
1267 m.iter()
1268 .map(|(k, v)| (k.clone(), json!(v)))
1269 .collect::<Map<String, Value>>()
1270 })
1271 .unwrap_or_default();
1272 Ok(AwsResponse::ok_json(json!({ "tags": tags })))
1273 }
1274}
1275
1276fn merge_updates(stored: &mut Value, body: &Value, fields: &[&str], arn_key: &str) -> String {
1279 if let Some(o) = stored.as_object_mut() {
1280 for f in fields {
1281 if let Some(v) = body.get(*f) {
1282 o.insert((*f).to_string(), v.clone());
1283 }
1284 }
1285 o.get(arn_key)
1286 .and_then(Value::as_str)
1287 .unwrap_or_default()
1288 .to_string()
1289 } else {
1290 String::new()
1291 }
1292}
1293
1294#[derive(Clone)]
1297struct LaunchCtx {
1298 batch_state: SharedBatchState,
1299 ecs_state: Option<fakecloud_ecs::SharedEcsState>,
1300 ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
1301 snapshot_store: Option<Arc<dyn SnapshotStore>>,
1302 snapshot_lock: Arc<AsyncMutex<()>>,
1303}
1304
1305#[allow(clippy::too_many_arguments)]
1310async fn launch(
1311 ctx: &LaunchCtx,
1312 account: &str,
1313 region: &str,
1314 request_id: &str,
1315 job_id: &str,
1316 job_name: &str,
1317 container: Option<Value>,
1318 now: i64,
1319) {
1320 let (Some(ecs_state), Some(container)) = (ctx.ecs_state.clone(), container) else {
1321 return;
1322 };
1323 if container.get("image").and_then(Value::as_str).is_none() {
1324 return;
1325 }
1326 let src = bare_request(account, region, request_id);
1327 match launch_ecs_task(
1328 &ecs_state,
1329 &ctx.ecs_runtime,
1330 &src,
1331 job_id,
1332 job_name,
1333 &container,
1334 )
1335 .await
1336 {
1337 Ok((cluster, task_arn)) => {
1338 {
1339 let mut accounts = ctx.batch_state.write();
1340 if let Some(j) = accounts
1341 .get_or_create(account)
1342 .jobs
1343 .get_mut(job_id)
1344 .and_then(|j| j.as_object_mut())
1345 {
1346 j.insert("status".into(), json!("STARTING"));
1347 j.insert("ecsCluster".into(), json!(cluster));
1348 j.insert("ecsTaskArn".into(), json!(task_arn));
1349 j.insert("startedAt".into(), json!(now));
1350 }
1351 }
1352 spawn_status_sync(
1353 ctx,
1354 ecs_state,
1355 account.to_string(),
1356 region.to_string(),
1357 request_id.to_string(),
1358 job_id.to_string(),
1359 job_name.to_string(),
1360 cluster,
1361 task_arn,
1362 container,
1363 );
1364 }
1365 Err(err) => {
1366 let mut accounts = ctx.batch_state.write();
1367 if let Some(j) = accounts
1368 .get_or_create(account)
1369 .jobs
1370 .get_mut(job_id)
1371 .and_then(|j| j.as_object_mut())
1372 {
1373 j.insert("status".into(), json!("FAILED"));
1374 j.insert("statusReason".into(), json!(err.message().to_string()));
1375 }
1376 }
1377 }
1378}
1379
1380#[allow(clippy::too_many_arguments)]
1385fn spawn_dependency_waiter(
1386 ctx: LaunchCtx,
1387 account: String,
1388 region: String,
1389 request_id: String,
1390 job_id: String,
1391 job_name: String,
1392 container: Option<Value>,
1393 depends_on: Vec<String>,
1394 now: i64,
1395) {
1396 tokio::spawn(async move {
1397 for _ in 0..1800u32 {
1398 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1399 let statuses: Vec<String> = {
1400 let accounts = ctx.batch_state.read();
1401 let jobs = accounts.get(&account).map(|s| &s.jobs);
1402 depends_on
1403 .iter()
1404 .map(|d| {
1405 jobs.and_then(|m| m.get(d))
1406 .and_then(|j| j.get("status").and_then(Value::as_str))
1407 .unwrap_or("")
1408 .to_string()
1409 })
1410 .collect()
1411 };
1412 if statuses.iter().any(|s| s == "FAILED") {
1413 {
1414 let mut accounts = ctx.batch_state.write();
1415 if let Some(j) = accounts
1416 .get_or_create(&account)
1417 .jobs
1418 .get_mut(&job_id)
1419 .and_then(|j| j.as_object_mut())
1420 {
1421 j.insert("status".into(), json!("FAILED"));
1422 j.insert("statusReason".into(), json!("Dependent job failed"));
1423 }
1424 }
1425 save_snapshot_now(&ctx.batch_state, &ctx.snapshot_store, &ctx.snapshot_lock).await;
1426 return;
1427 }
1428 if statuses.iter().all(|s| s == "SUCCEEDED") {
1429 launch(
1430 &ctx,
1431 &account,
1432 ®ion,
1433 &request_id,
1434 &job_id,
1435 &job_name,
1436 container,
1437 now,
1438 )
1439 .await;
1440 return;
1441 }
1442 }
1443 });
1444}
1445
1446async fn launch_ecs_task(
1451 ecs_state: &fakecloud_ecs::SharedEcsState,
1452 ecs_runtime: &Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
1453 src: &AwsRequest,
1454 job_id: &str,
1455 job_name: &str,
1456 container: &Value,
1457) -> Result<(String, String), AwsServiceError> {
1458 let mut ecs = fakecloud_ecs::EcsService::new(ecs_state.clone());
1459 if let Some(rt) = ecs_runtime.clone() {
1460 ecs = ecs.with_runtime(rt);
1461 }
1462 let cluster = "fakecloud-batch".to_string();
1463 let _ = ecs
1464 .handle(ecs_request(
1465 "CreateCluster",
1466 json!({ "clusterName": cluster }),
1467 src,
1468 ))
1469 .await;
1470
1471 let image = container.get("image").and_then(Value::as_str).unwrap_or("");
1472 let (vcpus, memory) = container_resources(container);
1473 let mut cdef = serde_json::Map::new();
1474 cdef.insert("name".into(), json!("default"));
1475 cdef.insert("image".into(), json!(image));
1476 cdef.insert("essential".into(), json!(true));
1477 cdef.insert("cpu".into(), json!((vcpus * 1024.0).round() as i64));
1478 cdef.insert("memory".into(), json!(memory));
1479 if let Some(cmd) = container.get("command").filter(|v| v.is_array()) {
1480 cdef.insert("command".into(), cmd.clone());
1481 }
1482 if let Some(env) = container.get("environment").filter(|v| v.is_array()) {
1483 cdef.insert("environment".into(), env.clone());
1484 }
1485 let family = format!("batch-{job_name}");
1486 let reg = ecs
1487 .handle(ecs_request(
1488 "RegisterTaskDefinition",
1489 json!({
1490 "family": family,
1491 "containerDefinitions": [Value::Object(cdef)],
1492 "networkMode": "bridge",
1493 "requiresCompatibilities": ["EC2"],
1494 }),
1495 src,
1496 ))
1497 .await?;
1498 let reg_body: Value = parse_body(®);
1499 let task_def_arn = reg_body
1500 .pointer("/taskDefinition/taskDefinitionArn")
1501 .and_then(Value::as_str)
1502 .map(String::from)
1503 .unwrap_or(family);
1504
1505 let run = ecs
1506 .handle(ecs_request(
1507 "RunTask",
1508 json!({
1509 "cluster": cluster,
1510 "taskDefinition": task_def_arn,
1511 "count": 1,
1512 "launchType": "EC2",
1513 "startedBy": format!("batch:{job_id}"),
1514 }),
1515 src,
1516 ))
1517 .await?;
1518 let run_body: Value = parse_body(&run);
1519 let task_arn = run_body
1520 .pointer("/tasks/0/taskArn")
1521 .and_then(Value::as_str)
1522 .map(String::from)
1523 .ok_or_else(|| client_error("ServerException", "RunTask returned no task"))?;
1524 Ok((cluster, task_arn))
1525}
1526
1527#[allow(clippy::too_many_arguments)]
1533fn spawn_status_sync(
1534 ctx: &LaunchCtx,
1535 ecs_state: fakecloud_ecs::SharedEcsState,
1536 account_id: String,
1537 region: String,
1538 request_id: String,
1539 job_id: String,
1540 job_name: String,
1541 cluster: String,
1542 task_arn: String,
1543 container: Value,
1544) {
1545 let batch_state = ctx.batch_state.clone();
1546 let snapshot_store = ctx.snapshot_store.clone();
1547 let snapshot_lock = ctx.snapshot_lock.clone();
1548 let ecs_runtime = ctx.ecs_runtime.clone();
1549 tokio::spawn(async move {
1550 let ecs = fakecloud_ecs::EcsService::new(ecs_state.clone());
1551 let src = bare_request(&account_id, ®ion, &request_id);
1552 let (max_attempts, timeout_secs) = {
1554 let accounts = batch_state.read();
1555 let j = accounts.get(&account_id).and_then(|s| s.jobs.get(&job_id));
1556 let ma = j
1557 .and_then(|j| j.pointer("/retryStrategy/attempts"))
1558 .and_then(Value::as_i64)
1559 .unwrap_or(1)
1560 .clamp(1, 10);
1561 let to = j
1562 .and_then(|j| j.pointer("/timeout/attemptDurationSeconds"))
1563 .and_then(Value::as_i64)
1564 .filter(|t| *t > 0);
1565 (ma, to)
1566 };
1567 let max_polls = timeout_secs.unwrap_or(900).min(900) as u32;
1568 let mut task = task_arn;
1569 let mut attempt: i64 = 1;
1570 loop {
1571 let mut outcome: Option<(Option<i64>, Option<String>)> = None; let mut succeeded = false;
1574 for _ in 0..max_polls {
1575 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1576 let resp = match ecs
1577 .handle(ecs_request(
1578 "DescribeTasks",
1579 json!({ "cluster": cluster, "tasks": [task] }),
1580 &src,
1581 ))
1582 .await
1583 {
1584 Ok(r) => r,
1585 Err(_) => continue,
1586 };
1587 let body = parse_body(&resp);
1588 let Some(t) = body.pointer("/tasks/0") else {
1589 continue;
1590 };
1591 match t.get("lastStatus").and_then(Value::as_str).unwrap_or("") {
1592 "RUNNING" => {
1593 let mut accounts = batch_state.write();
1594 if let Some(j) = accounts
1595 .get_or_create(&account_id)
1596 .jobs
1597 .get_mut(&job_id)
1598 .and_then(|j| j.as_object_mut())
1599 {
1600 if j.get("status").and_then(Value::as_str) != Some("RUNNING") {
1601 j.insert("status".into(), json!("RUNNING"));
1602 }
1603 }
1604 }
1605 "STOPPED" => {
1606 let code = t.pointer("/containers/0/exitCode").and_then(Value::as_i64);
1607 let reason = t
1608 .get("stoppedReason")
1609 .and_then(Value::as_str)
1610 .map(String::from);
1611 if code == Some(0) {
1612 succeeded = true;
1613 } else {
1614 outcome =
1615 Some((code, reason.or(Some("Essential container exited".into()))));
1616 }
1617 break;
1618 }
1619 _ => continue,
1620 }
1621 }
1622
1623 if succeeded {
1624 set_job_terminal(
1625 &batch_state,
1626 &account_id,
1627 &job_id,
1628 "SUCCEEDED",
1629 Some(0),
1630 None,
1631 );
1632 save_snapshot_now(&batch_state, &snapshot_store, &snapshot_lock).await;
1633 break;
1634 }
1635
1636 let (exit_code, reason) =
1638 outcome.unwrap_or((None, Some("Job attempt duration exceeded timeout".into())));
1639 if attempt < max_attempts {
1640 {
1642 let mut accounts = batch_state.write();
1643 if let Some(j) = accounts
1644 .get_or_create(&account_id)
1645 .jobs
1646 .get_mut(&job_id)
1647 .and_then(|j| j.as_object_mut())
1648 {
1649 let attempts = j.entry("attempts".to_string()).or_insert_with(|| json!([]));
1650 if let Some(a) = attempts.as_array_mut() {
1651 a.push(json!({
1652 "exitCode": exit_code,
1653 "statusReason": reason,
1654 }));
1655 }
1656 j.insert("status".into(), json!("RUNNABLE"));
1657 }
1658 }
1659 save_snapshot_now(&batch_state, &snapshot_store, &snapshot_lock).await;
1660 match launch_ecs_task(
1661 &ecs_state,
1662 &ecs_runtime,
1663 &src,
1664 &job_id,
1665 &job_name,
1666 &container,
1667 )
1668 .await
1669 {
1670 Ok((_, new_task)) => {
1671 task = new_task;
1672 attempt += 1;
1673 let mut accounts = batch_state.write();
1674 if let Some(j) = accounts
1675 .get_or_create(&account_id)
1676 .jobs
1677 .get_mut(&job_id)
1678 .and_then(|j| j.as_object_mut())
1679 {
1680 j.insert("status".into(), json!("STARTING"));
1681 j.insert("ecsTaskArn".into(), json!(task));
1682 }
1683 continue;
1684 }
1685 Err(_) => {
1686 set_job_terminal(
1687 &batch_state,
1688 &account_id,
1689 &job_id,
1690 "FAILED",
1691 exit_code,
1692 reason,
1693 );
1694 save_snapshot_now(&batch_state, &snapshot_store, &snapshot_lock).await;
1695 break;
1696 }
1697 }
1698 } else {
1699 set_job_terminal(
1700 &batch_state,
1701 &account_id,
1702 &job_id,
1703 "FAILED",
1704 exit_code,
1705 reason,
1706 );
1707 save_snapshot_now(&batch_state, &snapshot_store, &snapshot_lock).await;
1708 break;
1709 }
1710 }
1711 });
1712}
1713
1714fn set_job_terminal(
1716 batch_state: &SharedBatchState,
1717 account_id: &str,
1718 job_id: &str,
1719 status: &str,
1720 exit_code: Option<i64>,
1721 reason: Option<String>,
1722) {
1723 let mut accounts = batch_state.write();
1724 if let Some(j) = accounts
1725 .get_or_create(account_id)
1726 .jobs
1727 .get_mut(job_id)
1728 .and_then(|j| j.as_object_mut())
1729 {
1730 j.insert("status".into(), json!(status));
1731 if let Some(c) = exit_code {
1732 let container = j
1733 .entry("container".to_string())
1734 .or_insert_with(|| json!({}));
1735 if let Some(o) = container.as_object_mut() {
1736 o.insert("exitCode".into(), json!(c));
1737 }
1738 }
1739 if let Some(r) = reason {
1740 j.insert("statusReason".into(), json!(r.clone()));
1741 if let Some(o) = j.get_mut("container").and_then(|v| v.as_object_mut()) {
1742 o.insert("reason".into(), json!(r));
1743 }
1744 }
1745 j.insert(
1746 "stoppedAt".into(),
1747 json!(chrono::Utc::now().timestamp_millis()),
1748 );
1749 }
1750}
1751
1752fn ecs_request(action: &str, body: Value, src: &AwsRequest) -> AwsRequest {
1755 AwsRequest {
1756 service: "ecs".to_string(),
1757 action: action.to_string(),
1758 region: src.region.clone(),
1759 account_id: src.account_id.clone(),
1760 request_id: src.request_id.clone(),
1761 headers: http::HeaderMap::new(),
1762 query_params: std::collections::HashMap::new(),
1763 body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap_or_default()),
1764 body_stream: parking_lot::Mutex::new(None),
1765 path_segments: Vec::new(),
1766 raw_path: "/".to_string(),
1767 raw_query: String::new(),
1768 method: Method::POST,
1769 is_query_protocol: false,
1770 access_key_id: None,
1771 principal: None,
1772 }
1773}
1774
1775fn bare_request(account_id: &str, region: &str, request_id: &str) -> AwsRequest {
1778 AwsRequest {
1779 service: "batch".to_string(),
1780 action: String::new(),
1781 region: region.to_string(),
1782 account_id: account_id.to_string(),
1783 request_id: request_id.to_string(),
1784 headers: http::HeaderMap::new(),
1785 query_params: std::collections::HashMap::new(),
1786 body: bytes::Bytes::new(),
1787 body_stream: parking_lot::Mutex::new(None),
1788 path_segments: Vec::new(),
1789 raw_path: "/".to_string(),
1790 raw_query: String::new(),
1791 method: Method::POST,
1792 is_query_protocol: false,
1793 access_key_id: None,
1794 principal: None,
1795 }
1796}
1797
1798fn parse_body(resp: &AwsResponse) -> Value {
1800 serde_json::from_slice(resp.body.expect_bytes()).unwrap_or(Value::Null)
1801}
1802
1803fn container_resources(container: &Value) -> (f64, i64) {
1806 let mut vcpus = container
1807 .get("vcpus")
1808 .and_then(Value::as_f64)
1809 .unwrap_or(1.0);
1810 let mut memory = container
1811 .get("memory")
1812 .and_then(Value::as_i64)
1813 .unwrap_or(512);
1814 if let Some(rr) = container
1815 .get("resourceRequirements")
1816 .and_then(Value::as_array)
1817 {
1818 for r in rr {
1819 let ty = r.get("type").and_then(Value::as_str).unwrap_or("");
1820 let val = r
1821 .get("value")
1822 .and_then(Value::as_str)
1823 .and_then(|s| s.parse::<f64>().ok());
1824 match (ty, val) {
1825 ("VCPU", Some(v)) => vcpus = v,
1826 ("MEMORY", Some(v)) => memory = v as i64,
1827 _ => {}
1828 }
1829 }
1830 }
1831 (vcpus.max(0.25), memory.max(4))
1832}
1833
1834async fn save_snapshot_now(
1837 state: &SharedBatchState,
1838 store: &Option<Arc<dyn SnapshotStore>>,
1839 lock: &Arc<AsyncMutex<()>>,
1840) {
1841 let Some(store) = store.clone() else {
1842 return;
1843 };
1844 let _guard = lock.lock().await;
1845 let bytes = {
1846 let snap = BatchSnapshot {
1847 schema_version: BATCH_SNAPSHOT_SCHEMA_VERSION,
1848 accounts: Some(state.read().clone()),
1849 };
1850 serde_json::to_vec(&snap).unwrap_or_default()
1851 };
1852 let _ = tokio::task::spawn_blocking(move || store.save(&bytes)).await;
1853}
1854
1855fn with_array_index_env(mut container: Value, index: i64) -> Value {
1858 if let Some(obj) = container.as_object_mut() {
1859 let env = obj
1860 .entry("environment".to_string())
1861 .or_insert_with(|| json!([]));
1862 if let Some(arr) = env.as_array_mut() {
1863 arr.retain(|e| {
1864 e.get("name").and_then(Value::as_str) != Some("AWS_BATCH_JOB_ARRAY_INDEX")
1865 });
1866 arr.push(json!({ "name": "AWS_BATCH_JOB_ARRAY_INDEX", "value": index.to_string() }));
1867 }
1868 }
1869 container
1870}
1871
1872fn array_status_summary(children: &[&Value]) -> (Value, &'static str) {
1875 let mut summary = serde_json::Map::new();
1876 for s in [
1877 "SUBMITTED",
1878 "PENDING",
1879 "RUNNABLE",
1880 "STARTING",
1881 "RUNNING",
1882 "SUCCEEDED",
1883 "FAILED",
1884 ] {
1885 let n = children
1886 .iter()
1887 .filter(|c| c.get("status").and_then(Value::as_str) == Some(s))
1888 .count();
1889 summary.insert(s.to_string(), json!(n));
1890 }
1891 let total = children.len();
1892 let succeeded = summary["SUCCEEDED"].as_u64().unwrap_or(0) as usize;
1893 let failed = summary["FAILED"].as_u64().unwrap_or(0) as usize;
1894 let status = if total > 0 && succeeded + failed == total {
1895 if failed > 0 {
1896 "FAILED"
1897 } else {
1898 "SUCCEEDED"
1899 }
1900 } else if summary["RUNNING"].as_u64().unwrap_or(0) > 0
1901 || summary["STARTING"].as_u64().unwrap_or(0) > 0
1902 {
1903 "RUNNING"
1904 } else {
1905 "PENDING"
1906 };
1907 (Value::Object(summary), status)
1908}
1909
1910fn string_set(body: &Value, key: &str) -> std::collections::HashSet<String> {
1912 body.get(key)
1913 .and_then(Value::as_array)
1914 .map(|a| {
1915 a.iter()
1916 .filter_map(|v| v.as_str().map(String::from))
1917 .collect()
1918 })
1919 .unwrap_or_default()
1920}
1921
1922fn match_named(
1924 res: &Value,
1925 wanted: &std::collections::HashSet<String>,
1926 name_key: &str,
1927 arn_key: &str,
1928) -> bool {
1929 if wanted.is_empty() {
1930 return true;
1931 }
1932 let name = res.get(name_key).and_then(Value::as_str);
1933 let arn = res.get(arn_key).and_then(Value::as_str);
1934 name.map(|n| wanted.contains(n)).unwrap_or(false)
1935 || arn.map(|a| wanted.contains(a)).unwrap_or(false)
1936}
1937
1938fn arn_or_name(body: &Value, key: &str) -> Result<String, AwsServiceError> {
1941 let raw = body
1942 .get(key)
1943 .and_then(Value::as_str)
1944 .ok_or_else(|| client_error("ClientException", format!("{key} is required")))?;
1945 Ok(raw.rsplit('/').next().unwrap_or(raw).to_string())
1946}
1947
1948fn hex_val(b: u8) -> Option<u8> {
1949 match b {
1950 b'0'..=b'9' => Some(b - b'0'),
1951 b'a'..=b'f' => Some(b - b'a' + 10),
1952 b'A'..=b'F' => Some(b - b'A' + 10),
1953 _ => None,
1954 }
1955}
1956
1957fn percent_decode(s: &str) -> String {
1958 let bytes = s.as_bytes();
1963 let mut out = Vec::with_capacity(bytes.len());
1964 let mut i = 0;
1965 while i < bytes.len() {
1966 if bytes[i] == b'%' && i + 2 < bytes.len() {
1967 if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
1968 out.push(hi * 16 + lo);
1969 i += 3;
1970 continue;
1971 }
1972 }
1973 out.push(bytes[i]);
1974 i += 1;
1975 }
1976 String::from_utf8_lossy(&out).into_owned()
1977}
1978
1979#[async_trait]
1980impl AwsService for BatchService {
1981 fn service_name(&self) -> &str {
1982 "batch"
1983 }
1984
1985 fn supported_actions(&self) -> &[&str] {
1986 SUPPORTED_ACTIONS
1987 }
1988
1989 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1990 let Some(action) = Self::resolve_action(&req) else {
1991 return Err(AwsServiceError::aws_error(
1992 StatusCode::NOT_FOUND,
1993 "ResourceNotFoundException",
1994 format!("Unknown operation: {} {}", req.method, req.raw_path),
1995 ));
1996 };
1997 let result = if action == "SubmitJob" {
2000 self.submit_job(&req).await
2001 } else {
2002 self.dispatch(action, &req)
2003 };
2004 if MUTATING_ACTIONS.contains(&action)
2005 && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
2006 {
2007 self.save_snapshot().await;
2008 }
2009 result
2010 }
2011}
2012
2013#[cfg(test)]
2014mod tests {
2015 use super::*;
2016 use crate::state::BatchAccounts;
2017 use parking_lot::RwLock;
2018 use std::collections::HashMap;
2019
2020 fn svc() -> BatchService {
2021 BatchService::new(Arc::new(RwLock::new(BatchAccounts::new())))
2022 }
2023
2024 fn req(path: &str, body: Value) -> AwsRequest {
2025 let p = path.split('?').next().unwrap_or(path);
2026 let path_segments: Vec<String> = p
2027 .split('/')
2028 .filter(|s| !s.is_empty())
2029 .map(String::from)
2030 .collect();
2031 AwsRequest {
2032 service: "batch".into(),
2033 action: String::new(),
2034 region: "us-east-1".into(),
2035 account_id: "123456789012".into(),
2036 request_id: "t".into(),
2037 headers: http::HeaderMap::new(),
2038 query_params: HashMap::new(),
2039 body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
2040 body_stream: parking_lot::Mutex::new(None),
2041 path_segments,
2042 raw_path: path.to_string(),
2043 raw_query: String::new(),
2044 method: Method::POST,
2045 is_query_protocol: false,
2046 access_key_id: None,
2047 principal: None,
2048 }
2049 }
2050
2051 fn body_of(r: AwsResponse) -> Value {
2052 serde_json::from_slice(r.body.expect_bytes()).unwrap()
2053 }
2054
2055 async fn mk_queue(s: &BatchService, name: &str) {
2056 s.handle(req(
2057 "/v1/createjobqueue",
2058 json!({"jobQueueName": name, "priority": 1}),
2059 ))
2060 .await
2061 .unwrap();
2062 }
2063
2064 #[tokio::test]
2065 async fn compute_environment_lifecycle() {
2066 let s = svc();
2067 let r = s
2068 .handle(req(
2069 "/v1/createcomputeenvironment",
2070 json!({"computeEnvironmentName": "ce1", "type": "MANAGED"}),
2071 ))
2072 .await
2073 .unwrap();
2074 let v = body_of(r);
2075 assert_eq!(v["computeEnvironmentName"], "ce1");
2076 assert!(v["computeEnvironmentArn"]
2077 .as_str()
2078 .unwrap()
2079 .contains("compute-environment/ce1"));
2080
2081 let d = body_of(
2082 s.handle(req("/v1/describecomputeenvironments", json!({})))
2083 .await
2084 .unwrap(),
2085 );
2086 let ces = d["computeEnvironments"].as_array().unwrap();
2087 assert_eq!(ces.len(), 1);
2088 assert_eq!(ces[0]["status"], "VALID");
2089 assert_eq!(ces[0]["state"], "ENABLED");
2090
2091 s.handle(req(
2092 "/v1/deletecomputeenvironment",
2093 json!({"computeEnvironment": "ce1"}),
2094 ))
2095 .await
2096 .unwrap();
2097 let d2 = body_of(
2098 s.handle(req("/v1/describecomputeenvironments", json!({})))
2099 .await
2100 .unwrap(),
2101 );
2102 assert_eq!(d2["computeEnvironments"].as_array().unwrap().len(), 0);
2103 }
2104
2105 #[tokio::test]
2106 async fn job_definition_revisions_increment() {
2107 let s = svc();
2108 for expected in 1..=3 {
2109 let v = body_of(
2110 s.handle(req(
2111 "/v1/registerjobdefinition",
2112 json!({"jobDefinitionName": "jd", "type": "container"}),
2113 ))
2114 .await
2115 .unwrap(),
2116 );
2117 assert_eq!(v["revision"], expected);
2118 }
2119 let d = body_of(
2120 s.handle(req(
2121 "/v1/describejobdefinitions",
2122 json!({"jobDefinitionName": "jd"}),
2123 ))
2124 .await
2125 .unwrap(),
2126 );
2127 assert_eq!(d["jobDefinitions"].as_array().unwrap().len(), 3);
2128 }
2129
2130 #[tokio::test]
2131 async fn unimplemented_op_errors_not_fakes() {
2132 let s = svc();
2133 let err = match s
2136 .handle(req(
2137 "/v1/createconsumableresource",
2138 json!({"consumableResourceName": "r"}),
2139 ))
2140 .await
2141 {
2142 Err(e) => e,
2143 Ok(_) => panic!("unimplemented op must not fake-succeed"),
2144 };
2145 assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
2146 }
2147
2148 #[tokio::test]
2149 async fn tags_agree_across_describe_and_list_tags() {
2150 let s = svc();
2151 let c = body_of(
2153 s.handle(req(
2154 "/v1/createcomputeenvironment",
2155 json!({"computeEnvironmentName": "ce", "type": "MANAGED",
2156 "tags": {"team": "data"}}),
2157 ))
2158 .await
2159 .unwrap(),
2160 );
2161 let arn = c["computeEnvironmentArn"].as_str().unwrap().to_string();
2162 let enc = arn.replace('/', "%2F");
2163
2164 let mut lt = req(&format!("/v1/tags/{enc}"), json!({}));
2167 lt.method = Method::GET;
2168 let tags = body_of(s.handle(lt).await.unwrap());
2169 assert_eq!(tags["tags"]["team"], "data");
2170
2171 let mut tr = req(&format!("/v1/tags/{enc}"), json!({"tags": {"env": "prod"}}));
2173 tr.method = Method::POST;
2174 s.handle(tr).await.unwrap();
2175 let d = body_of(
2176 s.handle(req("/v1/describecomputeenvironments", json!({})))
2177 .await
2178 .unwrap(),
2179 );
2180 let ce = &d["computeEnvironments"][0];
2181 assert_eq!(ce["tags"]["team"], "data");
2182 assert_eq!(ce["tags"]["env"], "prod");
2183 assert_eq!(ce["containerOrchestrationType"], "ECS");
2185 }
2186
2187 #[tokio::test]
2188 async fn job_submit_describe_cancel_lifecycle() {
2189 let s = svc();
2190 mk_queue(&s, "q1").await;
2191 let sub = body_of(
2192 s.handle(req(
2193 "/v1/submitjob",
2194 json!({"jobName": "j1", "jobQueue": "q1", "jobDefinition": "jd:1"}),
2195 ))
2196 .await
2197 .unwrap(),
2198 );
2199 let job_id = sub["jobId"].as_str().unwrap().to_string();
2200 assert_eq!(sub["jobName"], "j1");
2201
2202 let d = body_of(
2203 s.handle(req("/v1/describejobs", json!({"jobs": [job_id]})))
2204 .await
2205 .unwrap(),
2206 );
2207 assert_eq!(d["jobs"][0]["status"], "SUBMITTED");
2208
2209 let running = body_of(
2211 s.handle(req("/v1/listjobs", json!({"jobQueue": "q1"})))
2212 .await
2213 .unwrap(),
2214 );
2215 assert_eq!(running["jobSummaryList"].as_array().unwrap().len(), 0);
2216 let l = body_of(
2219 s.handle(req(
2220 "/v1/listjobs",
2221 json!({"jobQueue": "q1", "jobStatus": "SUBMITTED"}),
2222 ))
2223 .await
2224 .unwrap(),
2225 );
2226 assert_eq!(l["jobSummaryList"].as_array().unwrap().len(), 1);
2227 assert!(l["jobSummaryList"][0]["jobArn"].as_str().is_some());
2228
2229 match s.handle(req("/v1/listjobs", json!({}))).await {
2231 Err(e) => assert!(format!("{e:?}").contains("exactly one")),
2232 Ok(_) => panic!("ListJobs without a selector must be rejected"),
2233 }
2234
2235 s.handle(req(
2236 "/v1/canceljob",
2237 json!({"jobId": job_id, "reason": "stop it"}),
2238 ))
2239 .await
2240 .unwrap();
2241 let d2 = body_of(
2242 s.handle(req("/v1/describejobs", json!({"jobs": [job_id]})))
2243 .await
2244 .unwrap(),
2245 );
2246 assert_eq!(d2["jobs"][0]["status"], "FAILED");
2247 assert_eq!(d2["jobs"][0]["statusReason"], "stop it");
2248 }
2249
2250 #[tokio::test]
2251 async fn scheduling_policy_crud() {
2252 let s = svc();
2253 let c = body_of(
2254 s.handle(req("/v1/createschedulingpolicy", json!({"name": "sp1"})))
2255 .await
2256 .unwrap(),
2257 );
2258 let arn = c["arn"].as_str().unwrap().to_string();
2259 assert!(arn.contains("scheduling-policy/sp1"));
2260 let d = body_of(
2261 s.handle(req(
2262 "/v1/describeschedulingpolicies",
2263 json!({"arns": [arn]}),
2264 ))
2265 .await
2266 .unwrap(),
2267 );
2268 assert_eq!(d["schedulingPolicies"].as_array().unwrap().len(), 1);
2269 s.handle(req("/v1/deleteschedulingpolicy", json!({"arn": "sp1"})))
2270 .await
2271 .unwrap();
2272 let l = body_of(
2273 s.handle(req("/v1/listschedulingpolicies", json!({})))
2274 .await
2275 .unwrap(),
2276 );
2277 assert_eq!(l["schedulingPolicies"].as_array().unwrap().len(), 0);
2278 }
2279
2280 #[tokio::test]
2281 async fn update_compute_environment_persists() {
2282 let s = svc();
2283 s.handle(req(
2284 "/v1/createcomputeenvironment",
2285 json!({"computeEnvironmentName": "ce1", "state": "ENABLED"}),
2286 ))
2287 .await
2288 .unwrap();
2289 s.handle(req(
2290 "/v1/updatecomputeenvironment",
2291 json!({"computeEnvironment": "ce1", "state": "DISABLED"}),
2292 ))
2293 .await
2294 .unwrap();
2295 let d = body_of(
2296 s.handle(req("/v1/describecomputeenvironments", json!({})))
2297 .await
2298 .unwrap(),
2299 );
2300 assert_eq!(d["computeEnvironments"][0]["state"], "DISABLED");
2301 }
2302
2303 #[test]
2304 fn container_resources_from_both_shapes() {
2305 let (v, m) = container_resources(&json!({"vcpus": 2, "memory": 2048}));
2307 assert_eq!(v, 2.0);
2308 assert_eq!(m, 2048);
2309 let (v, m) = container_resources(&json!({
2311 "resourceRequirements": [
2312 {"type": "VCPU", "value": "4"},
2313 {"type": "MEMORY", "value": "8192"}
2314 ]
2315 }));
2316 assert_eq!(v, 4.0);
2317 assert_eq!(m, 8192);
2318 let (v, m) = container_resources(&json!({}));
2320 assert_eq!(v, 1.0);
2321 assert_eq!(m, 512);
2322 }
2323
2324 #[tokio::test]
2325 async fn resolve_container_picks_latest_revision_and_overrides() {
2326 let s = svc();
2327 for cmd in [json!(["echo", "v1"]), json!(["echo", "v2"])] {
2328 s.handle(req(
2329 "/v1/registerjobdefinition",
2330 json!({
2331 "jobDefinitionName": "jd",
2332 "type": "container",
2333 "containerProperties": {"image": "alpine", "command": cmd}
2334 }),
2335 ))
2336 .await
2337 .unwrap();
2338 }
2339 let c = s
2341 .resolve_container("123456789012", "jd", &json!({}))
2342 .unwrap();
2343 assert_eq!(c["command"], json!(["echo", "v2"]));
2344 let c = s
2346 .resolve_container(
2347 "123456789012",
2348 "jd:1",
2349 &json!({"containerOverrides": {"command": ["overridden"]}}),
2350 )
2351 .unwrap();
2352 assert_eq!(c["command"], json!(["overridden"]));
2353 assert_eq!(c["image"], "alpine");
2354 }
2355
2356 #[tokio::test]
2357 async fn submit_without_ecs_parks_at_submitted_never_auto_succeeds() {
2358 let s = svc();
2361 mk_queue(&s, "q").await;
2362 s.handle(req(
2363 "/v1/registerjobdefinition",
2364 json!({"jobDefinitionName": "jd", "type": "container",
2365 "containerProperties": {"image": "alpine"}}),
2366 ))
2367 .await
2368 .unwrap();
2369 let sub = body_of(
2370 s.handle(req(
2371 "/v1/submitjob",
2372 json!({"jobName": "j", "jobQueue": "q", "jobDefinition": "jd"}),
2373 ))
2374 .await
2375 .unwrap(),
2376 );
2377 let id = sub["jobId"].as_str().unwrap().to_string();
2378 let d = body_of(
2379 s.handle(req("/v1/describejobs", json!({"jobs": [id]})))
2380 .await
2381 .unwrap(),
2382 );
2383 assert_eq!(d["jobs"][0]["status"], "SUBMITTED");
2384 }
2385
2386 #[tokio::test]
2387 async fn reconcile_fails_in_flight_jobs_after_restart() {
2388 let s = svc();
2392 mk_queue(&s, "q").await;
2393 s.handle(req(
2394 "/v1/registerjobdefinition",
2395 json!({"jobDefinitionName": "jd", "type": "container",
2396 "containerProperties": {"image": "alpine"}}),
2397 ))
2398 .await
2399 .unwrap();
2400 let sub = body_of(
2401 s.handle(req(
2402 "/v1/submitjob",
2403 json!({"jobName": "j", "jobQueue": "q", "jobDefinition": "jd"}),
2404 ))
2405 .await
2406 .unwrap(),
2407 );
2408 let id = sub["jobId"].as_str().unwrap().to_string();
2409
2410 s.reconcile_persisted_jobs().await;
2411
2412 let d = body_of(
2413 s.handle(req("/v1/describejobs", json!({"jobs": [id]})))
2414 .await
2415 .unwrap(),
2416 );
2417 assert_eq!(d["jobs"][0]["status"], "FAILED");
2418 assert_eq!(
2419 d["jobs"][0]["statusReason"],
2420 "Job interrupted by a fakecloud restart"
2421 );
2422 assert!(d["jobs"][0]["stoppedAt"].is_i64());
2423
2424 s.reconcile_persisted_jobs().await;
2426 let d2 = body_of(
2427 s.handle(req("/v1/describejobs", json!({"jobs": [id]})))
2428 .await
2429 .unwrap(),
2430 );
2431 assert_eq!(d2["jobs"][0]["status"], "FAILED");
2432 }
2433
2434 #[test]
2435 fn array_index_env_injected() {
2436 let c = with_array_index_env(json!({"image": "alpine"}), 5);
2437 let env = c["environment"].as_array().unwrap();
2438 assert!(env
2439 .iter()
2440 .any(|e| e["name"] == "AWS_BATCH_JOB_ARRAY_INDEX" && e["value"] == "5"));
2441 }
2442
2443 #[tokio::test]
2444 async fn array_job_spawns_children_and_parent_aggregates() {
2445 let s = svc();
2446 mk_queue(&s, "q").await;
2447 s.handle(req(
2448 "/v1/registerjobdefinition",
2449 json!({"jobDefinitionName": "jd", "type": "container",
2450 "containerProperties": {"image": "alpine"}}),
2451 ))
2452 .await
2453 .unwrap();
2454 let sub = body_of(
2455 s.handle(req(
2456 "/v1/submitjob",
2457 json!({"jobName": "arr", "jobQueue": "q", "jobDefinition": "jd",
2458 "arrayProperties": {"size": 3}}),
2459 ))
2460 .await
2461 .unwrap(),
2462 );
2463 let parent = sub["jobId"].as_str().unwrap().to_string();
2464
2465 let listed = body_of(
2468 s.handle(req(
2469 "/v1/listjobs",
2470 json!({"arrayJobId": parent, "jobStatus": "SUBMITTED"}),
2471 ))
2472 .await
2473 .unwrap(),
2474 );
2475 let children = listed["jobSummaryList"].as_array().unwrap();
2476 assert_eq!(children.len(), 3);
2477 assert!(children.iter().all(|j| j["jobId"]
2478 .as_str()
2479 .unwrap()
2480 .starts_with(&format!("{parent}:"))));
2481
2482 let d = body_of(
2484 s.handle(req("/v1/describejobs", json!({"jobs": [parent]})))
2485 .await
2486 .unwrap(),
2487 );
2488 let p = &d["jobs"][0];
2489 assert_eq!(p["status"], "PENDING");
2490 assert_eq!(p["arrayProperties"]["statusSummary"]["SUBMITTED"], 3);
2491 assert_eq!(p["arrayProperties"]["size"], 3);
2492 }
2493
2494 #[test]
2495 fn array_summary_terminal_states() {
2496 let succ = json!({"status": "SUCCEEDED"});
2497 let fail = json!({"status": "FAILED"});
2498 let run = json!({"status": "RUNNING"});
2499 let (_, st) = array_status_summary(&[&succ, &succ]);
2500 assert_eq!(st, "SUCCEEDED");
2501 let (_, st) = array_status_summary(&[&succ, &fail]);
2502 assert_eq!(st, "FAILED");
2503 let (_, st) = array_status_summary(&[&succ, &run]);
2504 assert_eq!(st, "RUNNING");
2505 }
2506
2507 #[tokio::test]
2508 async fn depends_on_parks_at_pending() {
2509 let s = svc();
2510 mk_queue(&s, "q").await;
2511 s.handle(req(
2512 "/v1/registerjobdefinition",
2513 json!({"jobDefinitionName": "jd", "type": "container",
2514 "containerProperties": {"image": "alpine"}}),
2515 ))
2516 .await
2517 .unwrap();
2518 let a = body_of(
2519 s.handle(req(
2520 "/v1/submitjob",
2521 json!({"jobName": "a", "jobQueue": "q", "jobDefinition": "jd"}),
2522 ))
2523 .await
2524 .unwrap(),
2525 );
2526 let a_id = a["jobId"].as_str().unwrap().to_string();
2527 let b = body_of(
2528 s.handle(req(
2529 "/v1/submitjob",
2530 json!({"jobName": "b", "jobQueue": "q", "jobDefinition": "jd",
2531 "dependsOn": [{"jobId": a_id, "type": "SEQUENTIAL"}]}),
2532 ))
2533 .await
2534 .unwrap(),
2535 );
2536 let d = body_of(
2539 s.handle(req(
2540 "/v1/describejobs",
2541 json!({"jobs": [b["jobId"].clone()]}),
2542 ))
2543 .await
2544 .unwrap(),
2545 );
2546 assert_eq!(d["jobs"][0]["status"], "PENDING");
2547 }
2548
2549 #[test]
2550 fn routes_tag_family_by_method() {
2551 let mut r = req("/v1/tags/arn%3Aaws", json!({}));
2552 r.method = Method::GET;
2553 assert_eq!(
2554 BatchService::resolve_action(&r),
2555 Some("ListTagsForResource")
2556 );
2557 r.method = Method::DELETE;
2558 assert_eq!(BatchService::resolve_action(&r), Some("UntagResource"));
2559 }
2560
2561 #[test]
2562 fn percent_decode_handles_multibyte_without_panicking() {
2563 assert_eq!(percent_decode("%€"), "%€");
2567 assert_eq!(percent_decode("%"), "%");
2569 assert_eq!(percent_decode("%2"), "%2");
2570 assert_eq!(percent_decode("%zz"), "%zz");
2571 assert_eq!(percent_decode("arn%3Aaws%3Abatch"), "arn:aws:batch");
2573 assert_eq!(percent_decode("caf%C3%A9"), "café");
2574 assert_eq!(percent_decode("plain"), "plain");
2575 }
2576
2577 #[tokio::test]
2578 async fn list_tags_with_multibyte_arn_does_not_panic() {
2579 let s = svc();
2580 let mut r = req("/v1/tags/%€", json!({}));
2583 r.method = Method::GET;
2584 let resp = s.handle(r).await.unwrap();
2585 let v = body_of(resp);
2586 assert!(v.get("tags").is_some());
2587 }
2588
2589 #[tokio::test]
2590 async fn submit_job_rejects_out_of_range_array_size() {
2591 let s = svc();
2592 mk_queue(&s, "q").await;
2593 for bad in [1_i64, 0, -3, 10_001, 2_000_000_000] {
2594 let res = s
2595 .handle(req(
2596 "/v1/submitjob",
2597 json!({
2598 "jobName": "j", "jobQueue": "q", "jobDefinition": "d",
2599 "arrayProperties": {"size": bad}
2600 }),
2601 ))
2602 .await;
2603 match res {
2604 Err(e) => assert!(
2605 format!("{e:?}").contains("between 2 and 10000"),
2606 "size {bad}: wrong error {e:?}"
2607 ),
2608 Ok(_) => panic!("size {bad}: out-of-range array size must be rejected"),
2609 }
2610 }
2611 }
2612}