1use std::sync::Arc;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Duration, Utc};
7use http::StatusCode;
8use parking_lot::RwLock;
9use serde_json::{json, Value};
10use uuid::Uuid;
11
12use fakecloud_aws::arn::{partition_for, Arn};
13use fakecloud_core::pagination::paginate;
14use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
15
16use crate::state::{
17 AccountState, ApplicationAutoScalingAccounts, NotScaledReason, ScalableTarget,
18 ScalableTargetAction, ScalingActivity, ScalingPolicy, ScheduledAction,
19 SharedApplicationAutoScalingState, SuspendedState,
20};
21
22const SUPPORTED_ACTIONS: &[&str] = &[
23 "RegisterScalableTarget",
24 "DescribeScalableTargets",
25 "DeregisterScalableTarget",
26 "PutScalingPolicy",
27 "DescribeScalingPolicies",
28 "DeleteScalingPolicy",
29 "PutScheduledAction",
30 "DescribeScheduledActions",
31 "DeleteScheduledAction",
32 "DescribeScalingActivities",
33 "GetPredictiveScalingForecast",
34 "ListTagsForResource",
35 "TagResource",
36 "UntagResource",
37];
38
39pub struct ApplicationAutoScalingService {
40 state: SharedApplicationAutoScalingState,
41}
42
43impl ApplicationAutoScalingService {
44 pub fn new(state: SharedApplicationAutoScalingState) -> Self {
45 Self { state }
46 }
47
48 pub fn shared_state(&self) -> SharedApplicationAutoScalingState {
49 Arc::clone(&self.state)
50 }
51}
52
53impl Default for ApplicationAutoScalingService {
54 fn default() -> Self {
55 Self::new(Arc::new(RwLock::new(ApplicationAutoScalingAccounts::new())))
56 }
57}
58
59#[async_trait]
60impl AwsService for ApplicationAutoScalingService {
61 fn service_name(&self) -> &str {
62 "application-autoscaling"
63 }
64
65 fn supported_actions(&self) -> &[&str] {
66 SUPPORTED_ACTIONS
67 }
68
69 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
70 match req.action.as_str() {
71 "RegisterScalableTarget" => self.register_scalable_target(&req),
72 "DescribeScalableTargets" => self.describe_scalable_targets(&req),
73 "DeregisterScalableTarget" => self.deregister_scalable_target(&req),
74 "PutScalingPolicy" => self.put_scaling_policy(&req),
75 "DescribeScalingPolicies" => self.describe_scaling_policies(&req),
76 "DeleteScalingPolicy" => self.delete_scaling_policy(&req),
77 "PutScheduledAction" => self.put_scheduled_action(&req),
78 "DescribeScheduledActions" => self.describe_scheduled_actions(&req),
79 "DeleteScheduledAction" => self.delete_scheduled_action(&req),
80 "DescribeScalingActivities" => self.describe_scaling_activities(&req),
81 "GetPredictiveScalingForecast" => self.get_predictive_scaling_forecast(&req),
82 "ListTagsForResource" => self.list_tags_for_resource(&req),
83 "TagResource" => self.tag_resource(&req),
84 "UntagResource" => self.untag_resource(&req),
85 other => Err(AwsServiceError::action_not_implemented(
86 "application-autoscaling",
87 other,
88 )),
89 }
90 }
91}
92
93impl ApplicationAutoScalingService {
94 fn register_scalable_target(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
95 let body = req.json_body();
96 let service_namespace = require_str(&body, "ServiceNamespace")?;
97 validate_service_namespace(&service_namespace)?;
98 let resource_id = require_str(&body, "ResourceId")?;
99 validate_resource_id_len("resourceId", &resource_id)?;
100 let scalable_dimension = require_str(&body, "ScalableDimension")?;
101 validate_scalable_dimension(&scalable_dimension)?;
102 let min_capacity = body
103 .get("MinCapacity")
104 .and_then(Value::as_i64)
105 .map(|n| n as i32);
106 let max_capacity = body
107 .get("MaxCapacity")
108 .and_then(Value::as_i64)
109 .map(|n| n as i32);
110 let role_arn = body
111 .get("RoleARN")
112 .and_then(Value::as_str)
113 .map(|s| s.to_string());
114 if let Some(role) = role_arn.as_deref() {
115 validate_resource_id_len("roleARN", role)?;
116 }
117 let suspended_state = body.get("SuspendedState").map(parse_suspended_state);
118
119 let key = (
120 service_namespace.clone(),
121 resource_id.clone(),
122 scalable_dimension.clone(),
123 );
124
125 let mut state = self.state.write();
126 let account = account_mut(&mut state, &req.account_id);
127 let now = Utc::now();
128 let (arn, prev_min, prev_max, was_existing) = if let Some(existing) =
129 account.scalable_targets.get_mut(&key)
130 {
131 let prev_min = existing.min_capacity;
132 let prev_max = existing.max_capacity;
133 let new_min = min_capacity.unwrap_or(existing.min_capacity);
134 let new_max = max_capacity.unwrap_or(existing.max_capacity);
135 if new_min > new_max {
136 return Err(invalid_param("MinCapacity must be <= MaxCapacity"));
137 }
138 existing.min_capacity = new_min;
139 existing.max_capacity = new_max;
140 if let Some(role) = role_arn {
141 existing.role_arn = role;
142 }
143 if let Some(sus) = suspended_state {
144 existing.suspended_state = Some(sus);
145 }
146 (existing.arn.clone(), Some(prev_min), Some(prev_max), true)
147 } else {
148 let min = min_capacity
149 .ok_or_else(|| invalid_param("MinCapacity is required for new scalable targets"))?;
150 let max = max_capacity
151 .ok_or_else(|| invalid_param("MaxCapacity is required for new scalable targets"))?;
152 if min > max {
153 return Err(invalid_param("MinCapacity must be <= MaxCapacity"));
154 }
155 let arn = synth_scalable_target_arn(&req.account_id, &req.region);
156 let role = role_arn.unwrap_or_else(|| {
157 default_service_linked_role(&req.account_id, &service_namespace)
158 });
159 let target = ScalableTarget {
160 arn: arn.clone(),
161 service_namespace: service_namespace.clone(),
162 resource_id: resource_id.clone(),
163 scalable_dimension: scalable_dimension.clone(),
164 min_capacity: min,
165 max_capacity: max,
166 role_arn: role,
167 creation_time: now,
168 suspended_state,
169 predicted_capacity: None,
170 };
171 account.scalable_targets.insert(key, target);
172 (arn, None, None, false)
173 };
174
175 let cur_min = min_capacity.or(prev_min).unwrap_or(0);
180 let cur_max = max_capacity.or(prev_max).unwrap_or(0);
181 let bounds_changed =
182 !was_existing || prev_min != Some(cur_min) || prev_max != Some(cur_max);
183 if bounds_changed {
184 let description = if was_existing {
185 format!(
186 "Updated min capacity to {cur_min} and max capacity to {cur_max} for {resource_id}"
187 )
188 } else {
189 format!(
190 "Setting min capacity to {cur_min} and max capacity to {cur_max} for {resource_id}"
191 )
192 };
193 let activity = ScalingActivity {
194 activity_id: synth_activity_id(),
195 service_namespace: service_namespace.clone(),
196 resource_id: resource_id.clone(),
197 scalable_dimension: scalable_dimension.clone(),
198 description,
199 cause: "User initiated capacity change via RegisterScalableTarget".to_string(),
200 start_time: now,
201 end_time: Some(now),
202 status_code: "Successful".to_string(),
203 status_message: Some("Successfully set scaling target.".to_string()),
204 details: None,
205 not_scaled_reasons: Vec::new(),
206 };
207 account.scaling_activities.push(activity);
208 }
209 Ok(AwsResponse::ok_json(json!({
210 "ScalableTargetARN": arn,
211 })))
212 }
213
214 fn describe_scalable_targets(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
215 let body = req.json_body();
216 let namespace = require_str(&body, "ServiceNamespace")?;
217 validate_service_namespace(&namespace)?;
218 let resource_ids: Vec<String> = body
219 .get("ResourceIds")
220 .and_then(Value::as_array)
221 .map(|v| {
222 v.iter()
223 .filter_map(|s| s.as_str().map(|s| s.to_string()))
224 .collect()
225 })
226 .unwrap_or_default();
227 for rid in &resource_ids {
228 validate_resource_id_len("resourceIds", rid)?;
229 }
230 let dimension = body
231 .get("ScalableDimension")
232 .and_then(Value::as_str)
233 .map(|s| s.to_string());
234 if let Some(d) = dimension.as_deref() {
235 validate_scalable_dimension(d)?;
236 }
237 let max_results = body
238 .get("MaxResults")
239 .and_then(Value::as_u64)
240 .map(|n| n as usize)
241 .unwrap_or(50);
242 let next_token = body
243 .get("NextToken")
244 .and_then(Value::as_str)
245 .map(|s| s.to_string());
246
247 let state = self.state.read();
248 let mut all: Vec<ScalableTarget> = state
249 .accounts
250 .get(&req.account_id)
251 .map(|a| {
252 a.scalable_targets
253 .values()
254 .filter(|t| t.service_namespace == namespace)
255 .filter(|t| resource_ids.is_empty() || resource_ids.contains(&t.resource_id))
256 .filter(|t| {
257 dimension
258 .as_deref()
259 .is_none_or(|d| t.scalable_dimension == d)
260 })
261 .cloned()
262 .collect()
263 })
264 .unwrap_or_default();
265 drop(state);
266 all.sort_by(|a, b| a.arn.cmp(&b.arn));
267 let (page, next) = paginate(&all, next_token.as_deref(), max_results);
268 let mut response = json!({
269 "ScalableTargets": page.iter().map(scalable_target_json).collect::<Vec<_>>(),
270 });
271 if let Some(t) = next {
272 response
273 .as_object_mut()
274 .unwrap()
275 .insert("NextToken".to_string(), Value::String(t));
276 }
277 Ok(AwsResponse::ok_json(response))
278 }
279
280 fn deregister_scalable_target(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
281 let body = req.json_body();
282 let namespace = require_str(&body, "ServiceNamespace")?;
283 let resource_id = require_str(&body, "ResourceId")?;
284 let dimension = require_str(&body, "ScalableDimension")?;
285 let key = (namespace, resource_id, dimension);
286
287 let mut state = self.state.write();
288 let account = account_mut(&mut state, &req.account_id);
289 if account.scalable_targets.remove(&key).is_none() {
290 return Err(object_not_found(format!(
291 "No scalable target registered for ServiceNamespace={} ResourceId={} ScalableDimension={}",
292 key.0, key.1, key.2
293 )));
294 }
295 account
298 .scaling_policies
299 .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
300 account
301 .scheduled_actions
302 .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
303 Ok(AwsResponse::ok_json(json!({})))
304 }
305
306 fn put_scaling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
307 let body = req.json_body();
308 let policy_name = require_str(&body, "PolicyName")?;
309 let namespace = require_str(&body, "ServiceNamespace")?;
310 let resource_id = require_str(&body, "ResourceId")?;
311 let dimension = require_str(&body, "ScalableDimension")?;
312 let policy_type = body
313 .get("PolicyType")
314 .and_then(Value::as_str)
315 .unwrap_or("StepScaling")
316 .to_string();
317 let step_cfg = body.get("StepScalingPolicyConfiguration").cloned();
318 let tt_cfg = body
319 .get("TargetTrackingScalingPolicyConfiguration")
320 .cloned();
321 let pred_cfg = body.get("PredictiveScalingPolicyConfiguration").cloned();
322
323 let target_key = (namespace.clone(), resource_id.clone(), dimension.clone());
324 let policy_key = (
325 namespace.clone(),
326 resource_id.clone(),
327 dimension.clone(),
328 policy_name.clone(),
329 );
330
331 let mut state = self.state.write();
332 let account = account_mut(&mut state, &req.account_id);
333 if !account.scalable_targets.contains_key(&target_key) {
334 return Err(object_not_found(format!(
335 "No scalable target registered for ServiceNamespace={namespace} ResourceId={resource_id} ScalableDimension={dimension}"
336 )));
337 }
338 let arn = if let Some(existing) = account.scaling_policies.get_mut(&policy_key) {
339 existing.policy_type = policy_type.clone();
340 existing.step_scaling_policy_configuration = step_cfg;
341 existing.target_tracking_scaling_policy_configuration = tt_cfg;
342 existing.predictive_scaling_policy_configuration = pred_cfg;
343 existing.arn.clone()
344 } else {
345 let arn = synth_policy_arn(
346 &req.account_id,
347 &req.region,
348 &namespace,
349 &resource_id,
350 &policy_name,
351 );
352 let policy = ScalingPolicy {
353 arn: arn.clone(),
354 policy_name: policy_name.clone(),
355 service_namespace: namespace.clone(),
356 resource_id: resource_id.clone(),
357 scalable_dimension: dimension.clone(),
358 policy_type: policy_type.clone(),
359 creation_time: Utc::now(),
360 step_scaling_policy_configuration: step_cfg,
361 target_tracking_scaling_policy_configuration: tt_cfg,
362 predictive_scaling_policy_configuration: pred_cfg,
363 alarms: Vec::new(),
364 last_applied_at: None,
365 };
366 account.scaling_policies.insert(policy_key, policy);
367 arn
368 };
369 Ok(AwsResponse::ok_json(json!({
370 "PolicyARN": arn,
371 "Alarms": [],
372 })))
373 }
374
375 fn describe_scaling_policies(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
376 let body = req.json_body();
377 let namespace = require_str(&body, "ServiceNamespace")?;
378 validate_service_namespace(&namespace)?;
379 let policy_names: Vec<String> = body
380 .get("PolicyNames")
381 .and_then(Value::as_array)
382 .map(|v| {
383 v.iter()
384 .filter_map(|s| s.as_str().map(|s| s.to_string()))
385 .collect()
386 })
387 .unwrap_or_default();
388 let resource_id = body
389 .get("ResourceId")
390 .and_then(Value::as_str)
391 .map(|s| s.to_string());
392 if let Some(r) = resource_id.as_deref() {
393 validate_resource_id_len("resourceId", r)?;
394 }
395 let dimension = body
396 .get("ScalableDimension")
397 .and_then(Value::as_str)
398 .map(|s| s.to_string());
399 if let Some(d) = dimension.as_deref() {
400 validate_scalable_dimension(d)?;
401 }
402 let max_results = body
403 .get("MaxResults")
404 .and_then(Value::as_u64)
405 .map(|n| n as usize)
406 .unwrap_or(50);
407 let next_token = body
408 .get("NextToken")
409 .and_then(Value::as_str)
410 .map(|s| s.to_string());
411
412 let state = self.state.read();
413 let mut all: Vec<ScalingPolicy> = state
414 .accounts
415 .get(&req.account_id)
416 .map(|a| {
417 a.scaling_policies
418 .values()
419 .filter(|p| p.service_namespace == namespace)
420 .filter(|p| policy_names.is_empty() || policy_names.contains(&p.policy_name))
421 .filter(|p| resource_id.as_deref().is_none_or(|r| p.resource_id == r))
422 .filter(|p| {
423 dimension
424 .as_deref()
425 .is_none_or(|d| p.scalable_dimension == d)
426 })
427 .cloned()
428 .collect()
429 })
430 .unwrap_or_default();
431 drop(state);
432 all.sort_by(|a, b| a.arn.cmp(&b.arn));
433 let (page, next) = paginate(&all, next_token.as_deref(), max_results);
434 let mut response = json!({
435 "ScalingPolicies": page.iter().map(scaling_policy_json).collect::<Vec<_>>(),
436 });
437 if let Some(t) = next {
438 response
439 .as_object_mut()
440 .unwrap()
441 .insert("NextToken".to_string(), Value::String(t));
442 }
443 Ok(AwsResponse::ok_json(response))
444 }
445
446 fn delete_scaling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
447 let body = req.json_body();
448 let policy_name = require_str(&body, "PolicyName")?;
449 let namespace = require_str(&body, "ServiceNamespace")?;
450 let resource_id = require_str(&body, "ResourceId")?;
451 let dimension = require_str(&body, "ScalableDimension")?;
452 let key = (namespace, resource_id, dimension, policy_name);
453
454 let mut state = self.state.write();
455 let account = account_mut(&mut state, &req.account_id);
456 if account.scaling_policies.remove(&key).is_none() {
457 return Err(object_not_found(format!(
458 "No scaling policy named {} found for ServiceNamespace={} ResourceId={} ScalableDimension={}",
459 key.3, key.0, key.1, key.2
460 )));
461 }
462 Ok(AwsResponse::ok_json(json!({})))
463 }
464
465 fn put_scheduled_action(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
466 let body = req.json_body();
467 let action_name = require_str(&body, "ScheduledActionName")?;
468 let namespace = require_str(&body, "ServiceNamespace")?;
469 let resource_id = require_str(&body, "ResourceId")?;
470 let dimension = require_str(&body, "ScalableDimension")?;
471 let schedule = body
472 .get("Schedule")
473 .and_then(Value::as_str)
474 .map(|s| s.to_string());
475 let timezone = body
476 .get("Timezone")
477 .and_then(Value::as_str)
478 .map(|s| s.to_string());
479 let start_time = parse_epoch_time(body.get("StartTime"));
480 let end_time = parse_epoch_time(body.get("EndTime"));
481 let action = body
482 .get("ScalableTargetAction")
483 .map(parse_scalable_target_action);
484
485 let target_key = (namespace.clone(), resource_id.clone(), dimension.clone());
486 let action_key = (
487 namespace.clone(),
488 resource_id.clone(),
489 dimension.clone(),
490 action_name.clone(),
491 );
492
493 let mut state = self.state.write();
494 let account = account_mut(&mut state, &req.account_id);
495 if !account.scalable_targets.contains_key(&target_key) {
496 return Err(object_not_found(format!(
497 "No scalable target registered for ServiceNamespace={namespace} ResourceId={resource_id} ScalableDimension={dimension}"
498 )));
499 }
500 if let Some(existing) = account.scheduled_actions.get_mut(&action_key) {
501 if let Some(s) = schedule {
502 existing.schedule = s;
503 }
504 if timezone.is_some() {
505 existing.timezone = timezone;
506 }
507 if start_time.is_some() {
508 existing.start_time = start_time;
509 }
510 if end_time.is_some() {
511 existing.end_time = end_time;
512 }
513 if action.is_some() {
514 existing.scalable_target_action = action;
515 }
516 } else {
517 let schedule = schedule
518 .ok_or_else(|| invalid_param("Schedule is required for new scheduled actions"))?;
519 let arn = synth_scheduled_action_arn(
520 &req.account_id,
521 &req.region,
522 &namespace,
523 &resource_id,
524 &action_name,
525 );
526 let scheduled = ScheduledAction {
527 arn,
528 scheduled_action_name: action_name.clone(),
529 service_namespace: namespace.clone(),
530 resource_id: resource_id.clone(),
531 scalable_dimension: Some(dimension.clone()),
532 schedule,
533 timezone,
534 start_time,
535 end_time,
536 scalable_target_action: action,
537 creation_time: Utc::now(),
538 last_fired_at: None,
539 };
540 account.scheduled_actions.insert(action_key, scheduled);
541 }
542 Ok(AwsResponse::ok_json(json!({})))
543 }
544
545 fn describe_scheduled_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
546 let body = req.json_body();
547 let namespace = require_str(&body, "ServiceNamespace")?;
548 validate_service_namespace(&namespace)?;
549 let names: Vec<String> = body
550 .get("ScheduledActionNames")
551 .and_then(Value::as_array)
552 .map(|v| {
553 v.iter()
554 .filter_map(|s| s.as_str().map(|s| s.to_string()))
555 .collect()
556 })
557 .unwrap_or_default();
558 let resource_id = body
559 .get("ResourceId")
560 .and_then(Value::as_str)
561 .map(|s| s.to_string());
562 if let Some(r) = resource_id.as_deref() {
563 validate_resource_id_len("resourceId", r)?;
564 }
565 let dimension = body
566 .get("ScalableDimension")
567 .and_then(Value::as_str)
568 .map(|s| s.to_string());
569 if let Some(d) = dimension.as_deref() {
570 validate_scalable_dimension(d)?;
571 }
572 let max_results = body
573 .get("MaxResults")
574 .and_then(Value::as_u64)
575 .map(|n| n as usize)
576 .unwrap_or(50);
577 let next_token = body
578 .get("NextToken")
579 .and_then(Value::as_str)
580 .map(|s| s.to_string());
581
582 let state = self.state.read();
583 let mut all: Vec<ScheduledAction> = state
584 .accounts
585 .get(&req.account_id)
586 .map(|a| {
587 a.scheduled_actions
588 .values()
589 .filter(|s| s.service_namespace == namespace)
590 .filter(|s| names.is_empty() || names.contains(&s.scheduled_action_name))
591 .filter(|s| resource_id.as_deref().is_none_or(|r| s.resource_id == r))
592 .filter(|s| {
593 dimension
594 .as_deref()
595 .is_none_or(|d| s.scalable_dimension.as_deref() == Some(d))
596 })
597 .cloned()
598 .collect()
599 })
600 .unwrap_or_default();
601 drop(state);
602 all.sort_by(|a, b| a.arn.cmp(&b.arn));
603 let (page, next) = paginate(&all, next_token.as_deref(), max_results);
604 let mut response = json!({
605 "ScheduledActions": page.iter().map(scheduled_action_json).collect::<Vec<_>>(),
606 });
607 if let Some(t) = next {
608 response
609 .as_object_mut()
610 .unwrap()
611 .insert("NextToken".to_string(), Value::String(t));
612 }
613 Ok(AwsResponse::ok_json(response))
614 }
615
616 fn delete_scheduled_action(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
617 let body = req.json_body();
618 let namespace = require_str(&body, "ServiceNamespace")?;
619 let action_name = require_str(&body, "ScheduledActionName")?;
620 let resource_id = require_str(&body, "ResourceId")?;
621 let dimension = require_str(&body, "ScalableDimension")?;
622 let key = (namespace, resource_id, dimension, action_name);
623 let mut state = self.state.write();
624 let account = account_mut(&mut state, &req.account_id);
625 if account.scheduled_actions.remove(&key).is_none() {
626 return Err(object_not_found(format!(
627 "No scheduled action named {} found for ServiceNamespace={} ResourceId={} ScalableDimension={}",
628 key.3, key.0, key.1, key.2
629 )));
630 }
631 Ok(AwsResponse::ok_json(json!({})))
632 }
633
634 fn describe_scaling_activities(
635 &self,
636 req: &AwsRequest,
637 ) -> Result<AwsResponse, AwsServiceError> {
638 let body = req.json_body();
639 let namespace = require_str(&body, "ServiceNamespace")?;
640 validate_service_namespace(&namespace)?;
641 let resource_id = body
642 .get("ResourceId")
643 .and_then(Value::as_str)
644 .map(|s| s.to_string());
645 if let Some(r) = resource_id.as_deref() {
646 validate_resource_id_len("resourceId", r)?;
647 }
648 let dimension = body
649 .get("ScalableDimension")
650 .and_then(Value::as_str)
651 .map(|s| s.to_string());
652 if let Some(d) = dimension.as_deref() {
653 validate_scalable_dimension(d)?;
654 }
655 let include_not_scaled = body
656 .get("IncludeNotScaledActivities")
657 .and_then(Value::as_bool)
658 .unwrap_or(false);
659 let max_results = body
660 .get("MaxResults")
661 .and_then(Value::as_u64)
662 .map(|n| n as usize)
663 .unwrap_or(50);
664 let next_token = body
665 .get("NextToken")
666 .and_then(Value::as_str)
667 .map(|s| s.to_string());
668
669 let state = self.state.read();
670 let mut all: Vec<ScalingActivity> = state
671 .accounts
672 .get(&req.account_id)
673 .map(|a| {
674 a.scaling_activities
675 .iter()
676 .filter(|act| act.service_namespace == namespace)
677 .filter(|act| resource_id.as_deref().is_none_or(|r| act.resource_id == r))
678 .filter(|act| {
679 dimension
680 .as_deref()
681 .is_none_or(|d| act.scalable_dimension == d)
682 })
683 .filter(|act| include_not_scaled || act.status_code != "Failed")
684 .cloned()
685 .collect()
686 })
687 .unwrap_or_default();
688 drop(state);
689 all.sort_by_key(|a| std::cmp::Reverse(a.start_time));
690 let (page, next) = paginate(&all, next_token.as_deref(), max_results);
691 let mut response = json!({
692 "ScalingActivities": page.iter().map(scaling_activity_json).collect::<Vec<_>>(),
693 });
694 if let Some(t) = next {
695 response
696 .as_object_mut()
697 .unwrap()
698 .insert("NextToken".to_string(), Value::String(t));
699 }
700 Ok(AwsResponse::ok_json(response))
701 }
702
703 fn get_predictive_scaling_forecast(
704 &self,
705 req: &AwsRequest,
706 ) -> Result<AwsResponse, AwsServiceError> {
707 let body = req.json_body();
708 let namespace = require_str(&body, "ServiceNamespace")?;
709 let resource_id = require_str(&body, "ResourceId")?;
710 let dimension = require_str(&body, "ScalableDimension")?;
711 let policy_name = require_str(&body, "PolicyName")?;
712 let start = parse_epoch_time(body.get("StartTime"))
713 .ok_or_else(|| invalid_param("StartTime is required"))?;
714 let end = parse_epoch_time(body.get("EndTime"))
715 .ok_or_else(|| invalid_param("EndTime is required"))?;
716
717 let policy_key = (
718 namespace.clone(),
719 resource_id.clone(),
720 dimension.clone(),
721 policy_name.clone(),
722 );
723 let state = self.state.read();
724 let policy = state
725 .accounts
726 .get(&req.account_id)
727 .and_then(|a| a.scaling_policies.get(&policy_key))
728 .ok_or_else(|| {
729 invalid_param(format!(
734 "No predictive scaling policy named {policy_name} found for ServiceNamespace={namespace} ResourceId={resource_id} ScalableDimension={dimension}"
735 ))
736 })?;
737 if policy.policy_type != "PredictiveScaling" {
738 return Err(invalid_param(
739 "Policy is not a PredictiveScaling policy; cannot return a forecast",
740 ));
741 }
742 let buckets = synth_forecast(start, end);
743 Ok(AwsResponse::ok_json(json!({
744 "LoadForecast": [{
745 "Timestamps": buckets.iter().map(|(t, _)| t.timestamp() as f64).collect::<Vec<_>>(),
746 "Values": buckets.iter().map(|(_, v)| *v as f64).collect::<Vec<_>>(),
747 "MetricSpecification": {
748 "TargetValue": 70.0,
749 "PredefinedMetricPairSpecification": {
750 "PredefinedMetricType": "ECSServiceCPUUtilization"
751 }
752 },
753 }],
754 "CapacityForecast": {
755 "Timestamps": buckets.iter().map(|(t, _)| t.timestamp() as f64).collect::<Vec<_>>(),
756 "Values": buckets
757 .iter()
758 .map(|(_, v)| ((*v as f64) / 100.0).ceil().max(1.0))
759 .collect::<Vec<_>>(),
760 },
761 "UpdateTime": Utc::now().timestamp() as f64,
762 })))
763 }
764
765 fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
766 let body = req.json_body();
767 let arn = require_str(&body, "ResourceARN")?;
768 let state = self.state.read();
769 let account = state.accounts.get(&req.account_id);
770 let exists = account.is_some_and(|a| resource_exists(a, &arn));
776 if !exists {
777 return Err(resource_not_found(format!("Resource {arn} not found")));
778 }
779 let tags = account
780 .and_then(|a| a.tags.get(&arn))
781 .cloned()
782 .unwrap_or_default();
783 Ok(AwsResponse::ok_json(json!({ "Tags": tags })))
784 }
785
786 fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
787 let body = req.json_body();
788 let arn = require_str(&body, "ResourceARN")?;
789 let tags_in = body
790 .get("Tags")
791 .and_then(Value::as_object)
792 .ok_or_else(|| invalid_param("Tags is required"))?;
793 let mut state = self.state.write();
794 let account = account_mut(&mut state, &req.account_id);
795 if !resource_exists(account, &arn) {
796 return Err(resource_not_found(format!("Resource {arn} not found")));
797 }
798 let entry = account.tags.entry(arn).or_default();
799 for (k, v) in tags_in {
800 if let Some(s) = v.as_str() {
801 entry.insert(k.clone(), s.to_string());
802 }
803 }
804 Ok(AwsResponse::ok_json(json!({})))
805 }
806
807 fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
808 let body = req.json_body();
809 let arn = require_str(&body, "ResourceARN")?;
810 let keys: Vec<String> = body
811 .get("TagKeys")
812 .and_then(Value::as_array)
813 .map(|v| {
814 v.iter()
815 .filter_map(|s| s.as_str().map(|s| s.to_string()))
816 .collect()
817 })
818 .unwrap_or_default();
819 let mut state = self.state.write();
820 let account = account_mut(&mut state, &req.account_id);
821 if !resource_exists(account, &arn) {
822 return Err(resource_not_found(format!("Resource {arn} not found")));
823 }
824 if let Some(tags) = account.tags.get_mut(&arn) {
825 for k in keys {
826 tags.remove(&k);
827 }
828 }
829 Ok(AwsResponse::ok_json(json!({})))
830 }
831}
832
833fn account_mut<'a>(
834 state: &'a mut ApplicationAutoScalingAccounts,
835 account_id: &str,
836) -> &'a mut AccountState {
837 state.accounts.entry(account_id.to_string()).or_default()
838}
839
840fn require_str(body: &Value, field: &str) -> Result<String, AwsServiceError> {
841 body.get(field)
842 .and_then(Value::as_str)
843 .map(|s| s.to_string())
844 .ok_or_else(|| invalid_param(format!("{field} is required")))
845}
846
847fn invalid_param(msg: impl Into<String>) -> AwsServiceError {
848 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
849}
850
851const VALID_SERVICE_NAMESPACES: &[&str] = &[
853 "ecs",
854 "elasticmapreduce",
855 "ec2",
856 "appstream",
857 "dynamodb",
858 "rds",
859 "sagemaker",
860 "custom-resource",
861 "comprehend",
862 "lambda",
863 "cassandra",
864 "kafka",
865 "elasticache",
866 "neptune",
867 "workspaces",
868];
869
870const VALID_SCALABLE_DIMENSIONS: &[&str] = &[
872 "ecs:service:DesiredCount",
873 "ec2:spot-fleet-request:TargetCapacity",
874 "elasticmapreduce:instancegroup:InstanceCount",
875 "appstream:fleet:DesiredCapacity",
876 "dynamodb:table:ReadCapacityUnits",
877 "dynamodb:table:WriteCapacityUnits",
878 "dynamodb:index:ReadCapacityUnits",
879 "dynamodb:index:WriteCapacityUnits",
880 "rds:cluster:ReadReplicaCount",
881 "sagemaker:variant:DesiredInstanceCount",
882 "custom-resource:ResourceType:Property",
883 "comprehend:document-classifier-endpoint:DesiredInferenceUnits",
884 "comprehend:entity-recognizer-endpoint:DesiredInferenceUnits",
885 "lambda:function:ProvisionedConcurrency",
886 "cassandra:table:ReadCapacityUnits",
887 "cassandra:table:WriteCapacityUnits",
888 "kafka:broker-storage:VolumeSize",
889 "elasticache:cache-cluster:Nodes",
890 "elasticache:replication-group:NodeGroups",
891 "elasticache:replication-group:Replicas",
892 "neptune:cluster:ReadReplicaCount",
893 "sagemaker:variant:DesiredProvisionedConcurrency",
894 "sagemaker:inference-component:DesiredCopyCount",
895 "workspaces:workspacespool:DesiredUserSessions",
896];
897
898fn validate_service_namespace(value: &str) -> Result<(), AwsServiceError> {
899 if VALID_SERVICE_NAMESPACES.contains(&value) {
900 Ok(())
901 } else {
902 Err(invalid_param(format!(
903 "Value '{value}' at 'serviceNamespace' failed to satisfy constraint: Member must satisfy enum value set: {VALID_SERVICE_NAMESPACES:?}"
904 )))
905 }
906}
907
908fn validate_scalable_dimension(value: &str) -> Result<(), AwsServiceError> {
909 if VALID_SCALABLE_DIMENSIONS.contains(&value) {
910 Ok(())
911 } else {
912 Err(invalid_param(format!(
913 "Value '{value}' at 'scalableDimension' failed to satisfy constraint: Member must satisfy enum value set: {VALID_SCALABLE_DIMENSIONS:?}"
914 )))
915 }
916}
917
918fn validate_resource_id_len(field: &str, value: &str) -> Result<(), AwsServiceError> {
921 let len = value.chars().count();
922 if !(1..=1600).contains(&len) {
923 return Err(invalid_param(format!(
924 "Value at '{field}' failed to satisfy constraint: Member must have length between 1 and 1600, inclusive"
925 )));
926 }
927 Ok(())
928}
929
930fn object_not_found(msg: impl Into<String>) -> AwsServiceError {
931 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ObjectNotFoundException", msg)
932}
933
934fn resource_not_found(msg: impl Into<String>) -> AwsServiceError {
935 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ResourceNotFoundException", msg)
936}
937
938fn parse_suspended_state(value: &Value) -> SuspendedState {
939 SuspendedState {
940 dynamic_scaling_in_suspended: value
941 .get("DynamicScalingInSuspended")
942 .and_then(Value::as_bool),
943 dynamic_scaling_out_suspended: value
944 .get("DynamicScalingOutSuspended")
945 .and_then(Value::as_bool),
946 scheduled_scaling_suspended: value
947 .get("ScheduledScalingSuspended")
948 .and_then(Value::as_bool),
949 }
950}
951
952fn parse_scalable_target_action(value: &Value) -> ScalableTargetAction {
953 ScalableTargetAction {
954 min_capacity: value
955 .get("MinCapacity")
956 .and_then(Value::as_i64)
957 .map(|n| n as i32),
958 max_capacity: value
959 .get("MaxCapacity")
960 .and_then(Value::as_i64)
961 .map(|n| n as i32),
962 }
963}
964
965fn parse_epoch_time(value: Option<&Value>) -> Option<DateTime<Utc>> {
966 let v = value?;
967 if let Some(n) = v.as_f64() {
968 return DateTime::<Utc>::from_timestamp(
969 n.trunc() as i64,
970 ((n.fract() * 1e9) as u32).min(999_999_999),
971 );
972 }
973 if let Some(s) = v.as_str() {
974 return DateTime::parse_from_rfc3339(s)
975 .ok()
976 .map(|dt| dt.with_timezone(&Utc));
977 }
978 None
979}
980
981fn resource_exists(account: &AccountState, arn: &str) -> bool {
982 account.scalable_targets.values().any(|t| t.arn == arn)
983 || account.scaling_policies.values().any(|p| p.arn == arn)
984}
985
986fn synth_activity_id() -> String {
987 Uuid::new_v4().to_string()
988}
989
990fn synth_scalable_target_arn(account_id: &str, region: &str) -> String {
991 let region = if region.is_empty() {
992 "us-east-1"
993 } else {
994 region
995 };
996 let id = Uuid::new_v4().simple().to_string();
997 let id = &id[..10];
998 Arn::new(
999 "application-autoscaling",
1000 region,
1001 account_id,
1002 &format!("scalable-target/{id}"),
1003 )
1004 .with_partition(partition_for(region))
1005 .to_string()
1006}
1007
1008fn synth_policy_arn(
1009 account_id: &str,
1010 region: &str,
1011 namespace: &str,
1012 resource_id: &str,
1013 name: &str,
1014) -> String {
1015 let region = if region.is_empty() {
1016 "us-east-1"
1017 } else {
1018 region
1019 };
1020 let id = Uuid::new_v4();
1021 format!(
1022 "arn:aws:autoscaling:{region}:{account_id}:scalingPolicy:{id}:resource/{namespace}/{resource_id}:policyName/{name}"
1023 )
1024}
1025
1026fn synth_scheduled_action_arn(
1027 account_id: &str,
1028 region: &str,
1029 namespace: &str,
1030 resource_id: &str,
1031 name: &str,
1032) -> String {
1033 let region = if region.is_empty() {
1034 "us-east-1"
1035 } else {
1036 region
1037 };
1038 let id = Uuid::new_v4();
1039 format!(
1040 "arn:aws:autoscaling:{region}:{account_id}:scheduledAction:{id}:resource/{namespace}/{resource_id}:scheduledActionName/{name}"
1041 )
1042}
1043
1044fn default_service_linked_role(account_id: &str, namespace: &str) -> String {
1045 let suffix = match namespace {
1046 "ecs" => "ECSService",
1047 "elasticmapreduce" => "EMRContainerService",
1048 "ec2" => "EC2SpotFleetRequest",
1049 "appstream" => "ApplicationAutoScaling_AppStreamFleet",
1050 "dynamodb" => "DynamoDBTable",
1051 "rds" => "RDSCluster",
1052 "sagemaker" => "SageMakerEndpoint",
1053 "lambda" => "LambdaConcurrency",
1054 "elasticache" => "ElastiCacheRG",
1055 "cassandra" => "CassandraTable",
1056 "kafka" => "KafkaCluster",
1057 _ => "ApplicationAutoScaling_Default",
1058 };
1059 Arn::global(
1060 "iam",
1061 account_id,
1062 &format!("role/aws-service-role/applicationautoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_{suffix}"),
1063 )
1064 .to_string()
1065}
1066
1067fn synth_forecast(start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<(DateTime<Utc>, i32)> {
1068 let mut out = Vec::new();
1069 if end <= start {
1070 return out;
1071 }
1072 let mut cursor = start;
1073 let step = Duration::hours(1);
1074 while cursor < end {
1075 let h = cursor.timestamp().rem_euclid(86_400) / 3600;
1078 let v = 30 + ((h * 5) as i32 % 60).abs();
1079 out.push((cursor, v));
1080 cursor += step;
1081 if out.len() >= 168 {
1082 break; }
1084 }
1085 out
1086}
1087
1088fn scalable_target_json(t: &ScalableTarget) -> Value {
1091 let mut obj = json!({
1092 "ScalableTargetARN": t.arn,
1093 "ServiceNamespace": t.service_namespace,
1094 "ResourceId": t.resource_id,
1095 "ScalableDimension": t.scalable_dimension,
1096 "MinCapacity": t.min_capacity,
1097 "MaxCapacity": t.max_capacity,
1098 "RoleARN": t.role_arn,
1099 "CreationTime": t.creation_time.timestamp() as f64,
1100 });
1101 if let Some(s) = &t.suspended_state {
1102 obj.as_object_mut().unwrap().insert(
1103 "SuspendedState".to_string(),
1104 json!({
1105 "DynamicScalingInSuspended": s.dynamic_scaling_in_suspended,
1106 "DynamicScalingOutSuspended": s.dynamic_scaling_out_suspended,
1107 "ScheduledScalingSuspended": s.scheduled_scaling_suspended,
1108 }),
1109 );
1110 }
1111 if let Some(c) = t.predicted_capacity {
1112 obj.as_object_mut()
1113 .unwrap()
1114 .insert("PredictedCapacity".to_string(), json!(c));
1115 }
1116 obj
1117}
1118
1119fn scaling_policy_json(p: &ScalingPolicy) -> Value {
1120 let mut obj = json!({
1121 "PolicyARN": p.arn,
1122 "PolicyName": p.policy_name,
1123 "ServiceNamespace": p.service_namespace,
1124 "ResourceId": p.resource_id,
1125 "ScalableDimension": p.scalable_dimension,
1126 "PolicyType": p.policy_type,
1127 "CreationTime": p.creation_time.timestamp() as f64,
1128 "Alarms": p.alarms.iter().map(|a| json!({
1129 "AlarmName": a.alarm_name,
1130 "AlarmARN": a.alarm_arn,
1131 })).collect::<Vec<_>>(),
1132 });
1133 if let Some(c) = &p.step_scaling_policy_configuration {
1134 obj.as_object_mut()
1135 .unwrap()
1136 .insert("StepScalingPolicyConfiguration".to_string(), c.clone());
1137 }
1138 if let Some(c) = &p.target_tracking_scaling_policy_configuration {
1139 obj.as_object_mut().unwrap().insert(
1140 "TargetTrackingScalingPolicyConfiguration".to_string(),
1141 c.clone(),
1142 );
1143 }
1144 if let Some(c) = &p.predictive_scaling_policy_configuration {
1145 obj.as_object_mut().unwrap().insert(
1146 "PredictiveScalingPolicyConfiguration".to_string(),
1147 c.clone(),
1148 );
1149 }
1150 obj
1151}
1152
1153fn scheduled_action_json(s: &ScheduledAction) -> Value {
1154 let mut obj = json!({
1155 "ScheduledActionARN": s.arn,
1156 "ScheduledActionName": s.scheduled_action_name,
1157 "ServiceNamespace": s.service_namespace,
1158 "ResourceId": s.resource_id,
1159 "Schedule": s.schedule,
1160 "CreationTime": s.creation_time.timestamp() as f64,
1161 });
1162 if let Some(d) = &s.scalable_dimension {
1163 obj.as_object_mut()
1164 .unwrap()
1165 .insert("ScalableDimension".to_string(), Value::String(d.clone()));
1166 }
1167 if let Some(t) = &s.timezone {
1168 obj.as_object_mut()
1169 .unwrap()
1170 .insert("Timezone".to_string(), Value::String(t.clone()));
1171 }
1172 if let Some(t) = s.start_time {
1173 obj.as_object_mut()
1174 .unwrap()
1175 .insert("StartTime".to_string(), json!(t.timestamp() as f64));
1176 }
1177 if let Some(t) = s.end_time {
1178 obj.as_object_mut()
1179 .unwrap()
1180 .insert("EndTime".to_string(), json!(t.timestamp() as f64));
1181 }
1182 if let Some(a) = &s.scalable_target_action {
1183 let mut action = serde_json::Map::new();
1184 if let Some(min) = a.min_capacity {
1185 action.insert("MinCapacity".to_string(), json!(min));
1186 }
1187 if let Some(max) = a.max_capacity {
1188 action.insert("MaxCapacity".to_string(), json!(max));
1189 }
1190 obj.as_object_mut()
1191 .unwrap()
1192 .insert("ScalableTargetAction".to_string(), Value::Object(action));
1193 }
1194 obj
1195}
1196
1197fn scaling_activity_json(a: &ScalingActivity) -> Value {
1198 let mut obj = json!({
1199 "ActivityId": a.activity_id,
1200 "ServiceNamespace": a.service_namespace,
1201 "ResourceId": a.resource_id,
1202 "ScalableDimension": a.scalable_dimension,
1203 "Description": a.description,
1204 "Cause": a.cause,
1205 "StartTime": a.start_time.timestamp() as f64,
1206 "StatusCode": a.status_code,
1207 });
1208 if let Some(t) = a.end_time {
1209 obj.as_object_mut()
1210 .unwrap()
1211 .insert("EndTime".to_string(), json!(t.timestamp() as f64));
1212 }
1213 if let Some(m) = &a.status_message {
1214 obj.as_object_mut()
1215 .unwrap()
1216 .insert("StatusMessage".to_string(), Value::String(m.clone()));
1217 }
1218 if let Some(d) = &a.details {
1219 obj.as_object_mut()
1220 .unwrap()
1221 .insert("Details".to_string(), Value::String(d.clone()));
1222 }
1223 if !a.not_scaled_reasons.is_empty() {
1224 let arr: Vec<Value> = a
1225 .not_scaled_reasons
1226 .iter()
1227 .map(not_scaled_reason_json)
1228 .collect();
1229 obj.as_object_mut()
1230 .unwrap()
1231 .insert("NotScaledReasons".to_string(), Value::Array(arr));
1232 }
1233 obj
1234}
1235
1236fn not_scaled_reason_json(r: &NotScaledReason) -> Value {
1237 let mut obj = json!({ "Code": r.code });
1238 if let Some(v) = r.max_capacity {
1239 obj.as_object_mut()
1240 .unwrap()
1241 .insert("MaxCapacity".to_string(), json!(v));
1242 }
1243 if let Some(v) = r.min_capacity {
1244 obj.as_object_mut()
1245 .unwrap()
1246 .insert("MinCapacity".to_string(), json!(v));
1247 }
1248 if let Some(v) = r.current_capacity {
1249 obj.as_object_mut()
1250 .unwrap()
1251 .insert("CurrentCapacity".to_string(), json!(v));
1252 }
1253 obj
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258 use super::*;
1259 use http::Method;
1260 use std::collections::HashMap;
1261
1262 fn make_req(action: &str, body: Value) -> AwsRequest {
1263 AwsRequest {
1264 service: "application-autoscaling".to_string(),
1265 action: action.to_string(),
1266 region: "us-east-1".to_string(),
1267 account_id: "123456789012".to_string(),
1268 request_id: "rid".to_string(),
1269 headers: http::HeaderMap::new(),
1270 query_params: HashMap::new(),
1271 body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
1272 body_stream: parking_lot::Mutex::new(None),
1273 path_segments: vec![],
1274 raw_path: "/".to_string(),
1275 raw_query: String::new(),
1276 method: Method::POST,
1277 is_query_protocol: false,
1278 access_key_id: None,
1279 principal: None,
1280 }
1281 }
1282
1283 #[test]
1284 fn register_scalable_target_emits_scaling_activity() {
1285 let svc = ApplicationAutoScalingService::default();
1286 let req = make_req(
1287 "RegisterScalableTarget",
1288 json!({
1289 "ServiceNamespace": "ecs",
1290 "ResourceId": "service/cluster1/svc1",
1291 "ScalableDimension": "ecs:service:DesiredCount",
1292 "MinCapacity": 1,
1293 "MaxCapacity": 5,
1294 }),
1295 );
1296 svc.register_scalable_target(&req).unwrap();
1297 let state = svc.state.read();
1298 let activities = &state
1299 .accounts
1300 .get("123456789012")
1301 .unwrap()
1302 .scaling_activities;
1303 assert_eq!(
1304 activities.len(),
1305 1,
1306 "expected 1 activity, got {activities:?}"
1307 );
1308 let a = &activities[0];
1309 assert_eq!(a.service_namespace, "ecs");
1310 assert_eq!(a.status_code, "Successful");
1311 assert!(a.description.contains("min capacity to 1"));
1312 assert!(a.description.contains("max capacity to 5"));
1313 }
1314
1315 #[test]
1316 fn updating_bounds_appends_another_activity() {
1317 let svc = ApplicationAutoScalingService::default();
1318 let initial = make_req(
1319 "RegisterScalableTarget",
1320 json!({
1321 "ServiceNamespace": "ecs",
1322 "ResourceId": "service/cluster1/svc1",
1323 "ScalableDimension": "ecs:service:DesiredCount",
1324 "MinCapacity": 1,
1325 "MaxCapacity": 5,
1326 }),
1327 );
1328 svc.register_scalable_target(&initial).unwrap();
1329 let update = make_req(
1330 "RegisterScalableTarget",
1331 json!({
1332 "ServiceNamespace": "ecs",
1333 "ResourceId": "service/cluster1/svc1",
1334 "ScalableDimension": "ecs:service:DesiredCount",
1335 "MinCapacity": 2,
1336 "MaxCapacity": 10,
1337 }),
1338 );
1339 svc.register_scalable_target(&update).unwrap();
1340 let state = svc.state.read();
1341 let activities = &state
1342 .accounts
1343 .get("123456789012")
1344 .unwrap()
1345 .scaling_activities;
1346 assert_eq!(activities.len(), 2);
1347 assert!(activities[1].description.contains("Updated"));
1348 }
1349
1350 #[test]
1351 fn re_register_with_same_bounds_does_not_log_activity() {
1352 let svc = ApplicationAutoScalingService::default();
1353 let req = make_req(
1354 "RegisterScalableTarget",
1355 json!({
1356 "ServiceNamespace": "ecs",
1357 "ResourceId": "service/cluster1/svc1",
1358 "ScalableDimension": "ecs:service:DesiredCount",
1359 "MinCapacity": 1,
1360 "MaxCapacity": 5,
1361 }),
1362 );
1363 svc.register_scalable_target(&req).unwrap();
1364 svc.register_scalable_target(&req).unwrap();
1366 let state = svc.state.read();
1367 let activities = &state
1368 .accounts
1369 .get("123456789012")
1370 .unwrap()
1371 .scaling_activities;
1372 assert_eq!(activities.len(), 1);
1373 }
1374
1375 #[test]
1380 fn paginate_checked_rejects_invalid_token() {
1381 use fakecloud_core::pagination::paginate_checked;
1382 let items: Vec<i32> = (0..5).collect();
1383 assert!(paginate_checked(&items, Some("not-a-valid-token"), 3).is_err());
1384 assert!(paginate_checked(&items, Some("2"), 3).is_ok());
1385 assert!(paginate_checked(&items, None, 3).is_ok());
1386 }
1387}