1use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::Utc;
8use http::{Method, StatusCode};
9use percent_encoding::percent_decode_str;
10use serde_json::{json, Value};
11use tokio::sync::Mutex as AsyncMutex;
12
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_persistence::SnapshotStore;
15
16use crate::persistence::save_snapshot;
17use crate::state::{
18 AccountSettings, ResourceGroup, ResourceQuery, SharedResourceGroupsState, TagSyncTask,
19};
20
21pub const RESOURCE_GROUPS_ACTIONS: &[&str] = &[
23 "CancelTagSyncTask",
24 "CreateGroup",
25 "DeleteGroup",
26 "GetAccountSettings",
27 "GetGroup",
28 "GetGroupConfiguration",
29 "GetGroupQuery",
30 "GetTagSyncTask",
31 "GetTags",
32 "GroupResources",
33 "ListGroupResources",
34 "ListGroupingStatuses",
35 "ListGroups",
36 "ListTagSyncTasks",
37 "PutGroupConfiguration",
38 "SearchResources",
39 "StartTagSyncTask",
40 "Tag",
41 "UngroupResources",
42 "Untag",
43 "UpdateAccountSettings",
44 "UpdateGroup",
45 "UpdateGroupQuery",
46];
47
48pub struct ResourceGroupsService {
49 state: SharedResourceGroupsState,
50 snapshot_store: Option<Arc<dyn SnapshotStore>>,
51 snapshot_lock: Arc<AsyncMutex<()>>,
52}
53
54enum Route {
56 CreateGroup,
57 GetGroup,
58 GetGroupQuery,
59 UpdateGroup,
60 UpdateGroupQuery,
61 DeleteGroup,
62 ListGroups,
63 GroupResources,
64 UngroupResources,
65 ListGroupResources,
66 SearchResources,
67 GetGroupConfiguration,
68 PutGroupConfiguration,
69 GetTags(String),
70 Tag(String),
71 Untag(String),
72 GetAccountSettings,
73 UpdateAccountSettings,
74 ListGroupingStatuses,
75 StartTagSyncTask,
76 GetTagSyncTask,
77 ListTagSyncTasks,
78 CancelTagSyncTask,
79}
80
81impl Route {
82 fn mutates(&self) -> bool {
83 matches!(
84 self,
85 Route::CreateGroup
86 | Route::UpdateGroup
87 | Route::UpdateGroupQuery
88 | Route::DeleteGroup
89 | Route::GroupResources
90 | Route::UngroupResources
91 | Route::PutGroupConfiguration
92 | Route::Tag(_)
93 | Route::Untag(_)
94 | Route::UpdateAccountSettings
95 | Route::StartTagSyncTask
96 | Route::CancelTagSyncTask
97 )
98 }
99}
100
101impl ResourceGroupsService {
102 pub fn new(state: SharedResourceGroupsState) -> Self {
103 Self {
104 state,
105 snapshot_store: None,
106 snapshot_lock: Arc::new(AsyncMutex::new(())),
107 }
108 }
109
110 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
111 self.snapshot_store = Some(store);
112 self
113 }
114
115 async fn persist(&self) {
116 save_snapshot(
117 &self.state,
118 self.snapshot_store.clone(),
119 &self.snapshot_lock,
120 )
121 .await;
122 }
123
124 fn resolve_route(req: &AwsRequest) -> Option<Route> {
125 let segs: Vec<&str> = req
126 .raw_path
127 .trim_matches('/')
128 .split('/')
129 .filter(|s| !s.is_empty())
130 .collect();
131 match (&req.method, segs.as_slice()) {
132 (&Method::POST, ["groups"]) => Some(Route::CreateGroup),
133 (&Method::POST, ["groups-list"]) => Some(Route::ListGroups),
134 (&Method::POST, ["get-group"]) => Some(Route::GetGroup),
135 (&Method::POST, ["get-group-query"]) => Some(Route::GetGroupQuery),
136 (&Method::POST, ["update-group"]) => Some(Route::UpdateGroup),
137 (&Method::POST, ["update-group-query"]) => Some(Route::UpdateGroupQuery),
138 (&Method::POST, ["delete-group"]) => Some(Route::DeleteGroup),
139 (&Method::POST, ["group-resources"]) => Some(Route::GroupResources),
140 (&Method::POST, ["ungroup-resources"]) => Some(Route::UngroupResources),
141 (&Method::POST, ["list-group-resources"]) => Some(Route::ListGroupResources),
142 (&Method::POST, ["resources", "search"]) => Some(Route::SearchResources),
143 (&Method::POST, ["get-group-configuration"]) => Some(Route::GetGroupConfiguration),
144 (&Method::POST, ["put-group-configuration"]) => Some(Route::PutGroupConfiguration),
145 (&Method::POST, ["get-account-settings"]) => Some(Route::GetAccountSettings),
146 (&Method::POST, ["update-account-settings"]) => Some(Route::UpdateAccountSettings),
147 (&Method::POST, ["list-grouping-statuses"]) => Some(Route::ListGroupingStatuses),
148 (&Method::POST, ["start-tag-sync-task"]) => Some(Route::StartTagSyncTask),
149 (&Method::POST, ["get-tag-sync-task"]) => Some(Route::GetTagSyncTask),
150 (&Method::POST, ["list-tag-sync-tasks"]) => Some(Route::ListTagSyncTasks),
151 (&Method::POST, ["cancel-tag-sync-task"]) => Some(Route::CancelTagSyncTask),
152 (&Method::GET, ["resources", arn, "tags"]) => Some(Route::GetTags(decode(arn))),
153 (&Method::PUT, ["resources", arn, "tags"]) => Some(Route::Tag(decode(arn))),
154 (&Method::PATCH, ["resources", arn, "tags"]) => Some(Route::Untag(decode(arn))),
155 _ => None,
156 }
157 }
158
159 fn create_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
162 let body = parse_json(&req.body)?;
163 let name = require_str(&body, "Name")?.to_string();
164 validate_group_name(&name)?;
165 let query = parse_resource_query(body.get("ResourceQuery"))?;
166 let configuration = body
167 .get("Configuration")
168 .and_then(|v| v.as_array())
169 .cloned()
170 .unwrap_or_default();
171 if query.is_none() && configuration.is_empty() {
172 return Err(bad_request(
173 "A group must be created with either a ResourceQuery or a Configuration.",
174 ));
175 }
176 let tags = parse_tags(body.get("Tags"));
177
178 let mut accounts = self.state.write();
179 let st = accounts.get_or_create(&req.account_id);
180 if st.groups.contains_key(&name) {
181 return Err(bad_request(&format!(
182 "A group with the name '{name}' already exists."
183 )));
184 }
185 let arn = group_arn(&req.region, &req.account_id, &name);
186 let group = ResourceGroup {
187 name: name.clone(),
188 arn,
189 description: opt_string(&body, "Description"),
190 query,
191 configuration,
192 tags: tags.clone(),
193 criticality: body
194 .get("Criticality")
195 .and_then(|v| v.as_i64())
196 .map(|n| n as i32),
197 owner: opt_string(&body, "Owner"),
198 display_name: opt_string(&body, "DisplayName"),
199 application_tag: BTreeMap::new(),
200 resources: BTreeSet::new(),
201 created_at: Utc::now(),
202 };
203 let out = json!({
204 "Group": group_json(&group),
205 "ResourceQuery": group.query.as_ref().map(resource_query_json),
206 "Tags": tags,
207 "GroupConfiguration": group_configuration_json(&group),
208 });
209 st.groups.insert(name, group);
210 Ok(AwsResponse::json_value(StatusCode::OK, out))
211 }
212
213 fn get_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
214 let body = parse_json(&req.body)?;
215 let key = group_key(&body)?;
216 let accounts = self.state.read();
217 let st = accounts.get(&req.account_id);
218 let group = st
219 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
220 .ok_or_else(|| not_found(&key))?;
221 Ok(AwsResponse::json_value(
222 StatusCode::OK,
223 json!({ "Group": group_json(group) }),
224 ))
225 }
226
227 fn get_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
228 let body = parse_json(&req.body)?;
229 let key = group_key(&body)?;
230 let accounts = self.state.read();
231 let st = accounts.get(&req.account_id);
232 let group = st
233 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
234 .ok_or_else(|| not_found(&key))?;
235 let query = group.query.as_ref().ok_or_else(|| {
236 bad_request("The group is not a resource-query group and has no query.")
237 })?;
238 Ok(AwsResponse::json_value(
239 StatusCode::OK,
240 json!({
241 "GroupQuery": {
242 "GroupName": group.name,
243 "ResourceQuery": resource_query_json(query),
244 }
245 }),
246 ))
247 }
248
249 fn update_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
250 let body = parse_json(&req.body)?;
251 let key = group_key(&body)?;
252 let mut accounts = self.state.write();
253 let st = accounts.get_or_create(&req.account_id);
254 let Some(name) = st.resolve_name(&key).map(String::from) else {
255 return Err(not_found(&key));
256 };
257 let group = st.groups.get_mut(&name).unwrap();
258 if let Some(d) = body.get("Description").and_then(|v| v.as_str()) {
259 group.description = Some(d.to_string());
260 }
261 if let Some(c) = body.get("Criticality").and_then(|v| v.as_i64()) {
262 group.criticality = Some(c as i32);
263 }
264 if let Some(o) = body.get("Owner").and_then(|v| v.as_str()) {
265 group.owner = Some(o.to_string());
266 }
267 if let Some(dn) = body.get("DisplayName").and_then(|v| v.as_str()) {
268 group.display_name = Some(dn.to_string());
269 }
270 Ok(AwsResponse::json_value(
271 StatusCode::OK,
272 json!({ "Group": group_json(group) }),
273 ))
274 }
275
276 fn update_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
277 let body = parse_json(&req.body)?;
278 let key = group_key(&body)?;
279 let query = parse_resource_query(body.get("ResourceQuery"))?
280 .ok_or_else(|| bad_request("ResourceQuery is required."))?;
281 let mut accounts = self.state.write();
282 let st = accounts.get_or_create(&req.account_id);
283 let Some(name) = st.resolve_name(&key).map(String::from) else {
284 return Err(not_found(&key));
285 };
286 let group = st.groups.get_mut(&name).unwrap();
287 group.query = Some(query.clone());
288 Ok(AwsResponse::json_value(
289 StatusCode::OK,
290 json!({
291 "GroupQuery": {
292 "GroupName": group.name,
293 "ResourceQuery": resource_query_json(&query),
294 }
295 }),
296 ))
297 }
298
299 fn delete_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
300 let body = parse_json(&req.body)?;
301 let key = group_key(&body)?;
302 let mut accounts = self.state.write();
303 let st = accounts.get_or_create(&req.account_id);
304 let Some(name) = st.resolve_name(&key).map(String::from) else {
305 return Err(not_found(&key));
306 };
307 let group = st.groups.remove(&name).unwrap();
308 Ok(AwsResponse::json_value(
309 StatusCode::OK,
310 json!({ "Group": group_json(&group) }),
311 ))
312 }
313
314 fn list_groups(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
315 let mut body = parse_json(&req.body)?;
318 inject_query_pagination(req, &mut body);
319 validate_max_results(&body)?;
320 validate_next_token(&body)?;
321 let accounts = self.state.read();
322 let groups: Vec<&ResourceGroup> = accounts
323 .get(&req.account_id)
324 .map(|s| s.groups.values().collect())
325 .unwrap_or_default();
326 let identifiers: Vec<Value> = groups.iter().map(|g| group_identifier_json(g)).collect();
327 let full: Vec<Value> = groups.iter().map(|g| group_json(g)).collect();
328 let (ident_page, next) = paginate(identifiers, &body);
329 let (full_page, _) = paginate(full, &body);
330 let mut out = json!({
331 "GroupIdentifiers": ident_page,
332 "Groups": full_page,
333 });
334 if let Some(nt) = next {
335 out["NextToken"] = json!(nt);
336 }
337 Ok(AwsResponse::json_value(StatusCode::OK, out))
338 }
339
340 fn group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
343 let body = parse_json(&req.body)?;
344 let key = require_str(&body, "Group")?.to_string();
345 let arns = string_list(body.get("ResourceArns"));
346 if arns.is_empty() {
347 return Err(bad_request("ResourceArns must not be empty."));
348 }
349 let mut accounts = self.state.write();
350 let st = accounts.get_or_create(&req.account_id);
351 let Some(name) = st.resolve_name(&key).map(String::from) else {
352 return Err(not_found(&key));
353 };
354 let group = st.groups.get_mut(&name).unwrap();
355 if group.query.is_some() {
356 return Err(bad_request(
357 "Resources cannot be added to a resource-query group; membership is computed from its query.",
358 ));
359 }
360 for a in &arns {
361 group.resources.insert(a.clone());
362 }
363 Ok(AwsResponse::json_value(
364 StatusCode::OK,
365 json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
366 ))
367 }
368
369 fn ungroup_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
370 let body = parse_json(&req.body)?;
371 let key = require_str(&body, "Group")?.to_string();
372 let arns = string_list(body.get("ResourceArns"));
373 let mut accounts = self.state.write();
374 let st = accounts.get_or_create(&req.account_id);
375 let Some(name) = st.resolve_name(&key).map(String::from) else {
376 return Err(not_found(&key));
377 };
378 let group = st.groups.get_mut(&name).unwrap();
379 if group.query.is_some() {
380 return Err(bad_request(
381 "Resources cannot be removed from a resource-query group; membership is computed from its query.",
382 ));
383 }
384 for a in &arns {
385 group.resources.remove(a);
386 }
387 Ok(AwsResponse::json_value(
388 StatusCode::OK,
389 json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
390 ))
391 }
392
393 fn list_group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
394 let body = parse_json(&req.body)?;
395 validate_max_results(&body)?;
396 validate_next_token(&body)?;
397 let key = group_key_field(&body, "GroupName", "Group")?;
398 let accounts = self.state.read();
399 let st = accounts.get(&req.account_id);
400 let group = st
401 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
402 .ok_or_else(|| not_found(&key))?;
403 let items: Vec<Value> = group
404 .resources
405 .iter()
406 .map(|arn| {
407 json!({
408 "Identifier": resource_identifier_json(arn),
409 "Status": { "Name": "ACTIVE" },
410 })
411 })
412 .collect();
413 let idents: Vec<Value> = group
414 .resources
415 .iter()
416 .map(|arn| resource_identifier_json(arn))
417 .collect();
418 let (items_page, next) = paginate(items, &body);
419 let (idents_page, _) = paginate(idents, &body);
420 let mut out = json!({
421 "Resources": items_page,
422 "ResourceIdentifiers": idents_page,
423 "QueryErrors": [],
424 });
425 if let Some(nt) = next {
426 out["NextToken"] = json!(nt);
427 }
428 Ok(AwsResponse::json_value(StatusCode::OK, out))
429 }
430
431 fn search_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
432 let body = parse_json(&req.body)?;
433 validate_max_results(&body)?;
434 validate_next_token(&body)?;
435 parse_resource_query(body.get("ResourceQuery"))?
439 .ok_or_else(|| bad_request("ResourceQuery is required."))?;
440 Ok(AwsResponse::json_value(
441 StatusCode::OK,
442 json!({ "ResourceIdentifiers": [], "QueryErrors": [] }),
443 ))
444 }
445
446 fn get_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
449 let body = parse_json(&req.body)?;
450 let key = group_key_field(&body, "Group", "Group")?;
451 let accounts = self.state.read();
452 let st = accounts.get(&req.account_id);
453 let group = st
454 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
455 .ok_or_else(|| not_found(&key))?;
456 Ok(AwsResponse::json_value(
457 StatusCode::OK,
458 json!({ "GroupConfiguration": group_configuration_json(group) }),
459 ))
460 }
461
462 fn put_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
463 let body = parse_json(&req.body)?;
464 let key = require_str(&body, "Group")?.to_string();
465 let configuration = body
466 .get("Configuration")
467 .and_then(|v| v.as_array())
468 .cloned()
469 .unwrap_or_default();
470 let mut accounts = self.state.write();
471 let st = accounts.get_or_create(&req.account_id);
472 let Some(name) = st.resolve_name(&key).map(String::from) else {
473 return Err(not_found(&key));
474 };
475 st.groups.get_mut(&name).unwrap().configuration = configuration;
476 Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
477 }
478
479 fn get_tags(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
482 let accounts = self.state.read();
483 let st = accounts.get(&req.account_id);
484 let group = st
485 .and_then(|s| s.groups.values().find(|g| g.arn == arn))
486 .ok_or_else(|| not_found(arn))?;
487 Ok(AwsResponse::json_value(
488 StatusCode::OK,
489 json!({ "Arn": arn, "Tags": group.tags }),
490 ))
491 }
492
493 fn tag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
494 let body = parse_json(&req.body)?;
495 let tags = parse_tags(body.get("Tags"));
496 let mut accounts = self.state.write();
497 let st = accounts.get_or_create(&req.account_id);
498 let group = st
499 .groups
500 .values_mut()
501 .find(|g| g.arn == arn)
502 .ok_or_else(|| not_found(arn))?;
503 for (k, v) in &tags {
504 group.tags.insert(k.clone(), v.clone());
505 }
506 Ok(AwsResponse::json_value(
507 StatusCode::OK,
508 json!({ "Arn": arn, "Tags": tags }),
509 ))
510 }
511
512 fn untag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
513 let body = parse_json(&req.body)?;
514 let keys = string_list(body.get("Keys"));
515 let mut accounts = self.state.write();
516 let st = accounts.get_or_create(&req.account_id);
517 let group = st
518 .groups
519 .values_mut()
520 .find(|g| g.arn == arn)
521 .ok_or_else(|| not_found(arn))?;
522 for k in &keys {
523 group.tags.remove(k);
524 }
525 Ok(AwsResponse::json_value(
526 StatusCode::OK,
527 json!({ "Arn": arn, "Keys": keys }),
528 ))
529 }
530
531 fn get_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
534 let accounts = self.state.read();
535 let settings = accounts
536 .get(&req.account_id)
537 .map(|s| s.account_settings.clone())
538 .unwrap_or_default();
539 Ok(AwsResponse::json_value(
540 StatusCode::OK,
541 json!({ "AccountSettings": account_settings_json(&settings) }),
542 ))
543 }
544
545 fn update_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
546 let body = parse_json(&req.body)?;
547 let desired = body
548 .get("GroupLifecycleEventsDesiredStatus")
549 .and_then(|v| v.as_str());
550 let mut accounts = self.state.write();
551 let st = accounts.get_or_create(&req.account_id);
552 if let Some(d) = desired {
553 if d != "ACTIVE" && d != "INACTIVE" {
554 return Err(bad_request(
555 "GroupLifecycleEventsDesiredStatus must be ACTIVE or INACTIVE.",
556 ));
557 }
558 st.account_settings.desired_status = Some(d.to_string());
559 }
560 Ok(AwsResponse::json_value(
561 StatusCode::OK,
562 json!({ "AccountSettings": account_settings_json(&st.account_settings) }),
563 ))
564 }
565
566 fn list_grouping_statuses(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
567 let body = parse_json(&req.body)?;
568 validate_max_results(&body)?;
569 validate_next_token(&body)?;
570 let key = require_str(&body, "Group")?.to_string();
571 let accounts = self.state.read();
572 let st = accounts.get(&req.account_id);
573 let group = st
574 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
575 .ok_or_else(|| bad_request(&format!("The specified group '{key}' was not found.")))?;
578 Ok(AwsResponse::json_value(
580 StatusCode::OK,
581 json!({ "Group": group.arn, "GroupingStatuses": [] }),
582 ))
583 }
584
585 fn start_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
588 let body = parse_json(&req.body)?;
589 let key = require_str(&body, "Group")?.to_string();
590 let role_arn = require_str(&body, "RoleArn")?.to_string();
591 let mut accounts = self.state.write();
592 let st = accounts.get_or_create(&req.account_id);
593 let Some(name) = st.resolve_name(&key).map(String::from) else {
594 return Err(not_found(&key));
595 };
596 let group_arn = st.groups.get(&name).unwrap().arn.clone();
597 let task_arn = tag_sync_task_arn(&group_arn);
598 let task = TagSyncTask {
599 task_arn: task_arn.clone(),
600 group_arn: group_arn.clone(),
601 group_name: name.clone(),
602 tag_key: opt_string(&body, "TagKey"),
603 tag_value: opt_string(&body, "TagValue"),
604 resource_query: parse_resource_query(body.get("ResourceQuery"))?,
605 role_arn: role_arn.clone(),
606 status: "ACTIVE".to_string(),
607 created_at: Utc::now(),
608 };
609 st.tag_sync_tasks.insert(task_arn.clone(), task.clone());
610 Ok(AwsResponse::json_value(
611 StatusCode::OK,
612 json!({
613 "GroupArn": group_arn,
614 "GroupName": name,
615 "TaskArn": task_arn,
616 "TagKey": task.tag_key,
617 "TagValue": task.tag_value,
618 "ResourceQuery": task.resource_query.as_ref().map(resource_query_json),
619 "RoleArn": role_arn,
620 }),
621 ))
622 }
623
624 fn get_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
625 let body = parse_json(&req.body)?;
626 let task_arn = require_str(&body, "TaskArn")?.to_string();
627 let accounts = self.state.read();
628 let task = accounts
629 .get(&req.account_id)
630 .and_then(|s| s.tag_sync_tasks.get(&task_arn))
631 .ok_or_else(|| not_found(&task_arn))?;
632 Ok(AwsResponse::json_value(
633 StatusCode::OK,
634 tag_sync_task_json(task),
635 ))
636 }
637
638 fn list_tag_sync_tasks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
639 let body = parse_json(&req.body)?;
640 validate_max_results(&body)?;
641 validate_next_token(&body)?;
642 let accounts = self.state.read();
643 let all: Vec<Value> = accounts
644 .get(&req.account_id)
645 .map(|s| s.tag_sync_tasks.values().map(tag_sync_task_json).collect())
646 .unwrap_or_default();
647 let (page, next) = paginate(all, &body);
648 let mut out = json!({ "TagSyncTasks": page });
649 if let Some(nt) = next {
650 out["NextToken"] = json!(nt);
651 }
652 Ok(AwsResponse::json_value(StatusCode::OK, out))
653 }
654
655 fn cancel_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
656 let body = parse_json(&req.body)?;
657 let task_arn = require_str(&body, "TaskArn")?.to_string();
658 let mut accounts = self.state.write();
659 let st = accounts.get_or_create(&req.account_id);
660 if st.tag_sync_tasks.remove(&task_arn).is_none() {
661 return Err(bad_request(&format!(
664 "The specified tag-sync task '{task_arn}' was not found."
665 )));
666 }
667 Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
668 }
669}
670
671#[async_trait]
672impl AwsService for ResourceGroupsService {
673 fn service_name(&self) -> &str {
674 "resource-groups"
675 }
676
677 fn supported_actions(&self) -> &[&str] {
678 RESOURCE_GROUPS_ACTIONS
679 }
680
681 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
682 let Some(route) = Self::resolve_route(&req) else {
683 return Err(AwsServiceError::aws_error(
684 StatusCode::NOT_FOUND,
685 "UnknownOperationException",
686 format!("Unknown operation: {} {}", req.method, req.raw_path),
687 ));
688 };
689 let mutates = route.mutates();
690 let result = match route {
691 Route::CreateGroup => self.create_group(&req),
692 Route::GetGroup => self.get_group(&req),
693 Route::GetGroupQuery => self.get_group_query(&req),
694 Route::UpdateGroup => self.update_group(&req),
695 Route::UpdateGroupQuery => self.update_group_query(&req),
696 Route::DeleteGroup => self.delete_group(&req),
697 Route::ListGroups => self.list_groups(&req),
698 Route::GroupResources => self.group_resources(&req),
699 Route::UngroupResources => self.ungroup_resources(&req),
700 Route::ListGroupResources => self.list_group_resources(&req),
701 Route::SearchResources => self.search_resources(&req),
702 Route::GetGroupConfiguration => self.get_group_configuration(&req),
703 Route::PutGroupConfiguration => self.put_group_configuration(&req),
704 Route::GetTags(arn) => self.get_tags(&req, &arn),
705 Route::Tag(arn) => self.tag(&req, &arn),
706 Route::Untag(arn) => self.untag(&req, &arn),
707 Route::GetAccountSettings => self.get_account_settings(&req),
708 Route::UpdateAccountSettings => self.update_account_settings(&req),
709 Route::ListGroupingStatuses => self.list_grouping_statuses(&req),
710 Route::StartTagSyncTask => self.start_tag_sync_task(&req),
711 Route::GetTagSyncTask => self.get_tag_sync_task(&req),
712 Route::ListTagSyncTasks => self.list_tag_sync_tasks(&req),
713 Route::CancelTagSyncTask => self.cancel_tag_sync_task(&req),
714 };
715 if mutates && result.is_ok() {
716 self.persist().await;
717 }
718 result
719 }
720}
721
722fn group_json(g: &ResourceGroup) -> Value {
727 let mut v = json!({ "GroupArn": g.arn, "Name": g.name });
728 if let Some(d) = &g.description {
729 v["Description"] = json!(d);
730 }
731 if let Some(c) = g.criticality {
732 v["Criticality"] = json!(c);
733 }
734 if let Some(o) = &g.owner {
735 v["Owner"] = json!(o);
736 }
737 if let Some(dn) = &g.display_name {
738 v["DisplayName"] = json!(dn);
739 }
740 if !g.application_tag.is_empty() {
741 v["ApplicationTag"] = json!(g.application_tag);
742 }
743 v
744}
745
746fn group_identifier_json(g: &ResourceGroup) -> Value {
747 let mut v = json!({ "GroupName": g.name, "GroupArn": g.arn });
748 if let Some(d) = &g.description {
749 v["Description"] = json!(d);
750 }
751 if let Some(c) = g.criticality {
752 v["Criticality"] = json!(c);
753 }
754 if let Some(o) = &g.owner {
755 v["Owner"] = json!(o);
756 }
757 if let Some(dn) = &g.display_name {
758 v["DisplayName"] = json!(dn);
759 }
760 v
761}
762
763fn resource_query_json(q: &ResourceQuery) -> Value {
764 json!({ "Type": q.type_, "Query": q.query })
765}
766
767fn group_configuration_json(g: &ResourceGroup) -> Value {
768 json!({
769 "Configuration": g.configuration,
770 "Status": "UPDATE_COMPLETE",
771 })
772}
773
774fn resource_identifier_json(arn: &str) -> Value {
775 let mut v = json!({ "ResourceArn": arn });
776 if let Some(t) = resource_type_from_arn(arn) {
777 v["ResourceType"] = json!(t);
778 }
779 v
780}
781
782fn account_settings_json(s: &AccountSettings) -> Value {
783 let desired = s
784 .desired_status
785 .clone()
786 .unwrap_or_else(|| "INACTIVE".into());
787 json!({
788 "GroupLifecycleEventsDesiredStatus": desired,
789 "GroupLifecycleEventsStatus": desired,
790 })
791}
792
793fn tag_sync_task_json(t: &TagSyncTask) -> Value {
794 json!({
795 "GroupArn": t.group_arn,
796 "GroupName": t.group_name,
797 "TaskArn": t.task_arn,
798 "TagKey": t.tag_key,
799 "TagValue": t.tag_value,
800 "ResourceQuery": t.resource_query.as_ref().map(resource_query_json),
801 "RoleArn": t.role_arn,
802 "Status": t.status,
803 "CreatedAt": t.created_at.timestamp() as f64,
804 })
805}
806
807fn parse_json(body: &[u8]) -> Result<Value, AwsServiceError> {
812 if body.is_empty() {
813 return Ok(json!({}));
814 }
815 serde_json::from_slice(body).map_err(|e| bad_request(&format!("invalid request body: {e}")))
816}
817
818fn require_str<'a>(body: &'a Value, field: &str) -> Result<&'a str, AwsServiceError> {
819 body.get(field)
820 .and_then(|v| v.as_str())
821 .filter(|s| !s.is_empty())
822 .ok_or_else(|| bad_request(&format!("{field} is required.")))
823}
824
825fn opt_string(body: &Value, field: &str) -> Option<String> {
826 body.get(field).and_then(|v| v.as_str()).map(String::from)
827}
828
829fn group_key(body: &Value) -> Result<String, AwsServiceError> {
832 group_key_field(body, "GroupName", "Group")
833}
834
835fn group_key_field(body: &Value, a: &str, b: &str) -> Result<String, AwsServiceError> {
836 body.get(a)
837 .or_else(|| body.get(b))
838 .and_then(|v| v.as_str())
839 .filter(|s| !s.is_empty())
840 .map(String::from)
841 .ok_or_else(|| bad_request("A group name or ARN is required."))
842}
843
844fn parse_resource_query(v: Option<&Value>) -> Result<Option<ResourceQuery>, AwsServiceError> {
845 let Some(q) = v else { return Ok(None) };
846 if q.is_null() {
847 return Ok(None);
848 }
849 let type_ = q
850 .get("Type")
851 .and_then(|v| v.as_str())
852 .ok_or_else(|| bad_request("ResourceQuery.Type is required."))?;
853 if type_ != "TAG_FILTERS_1_0" && type_ != "CLOUDFORMATION_STACK_1_0" {
854 return Err(bad_request(
855 "ResourceQuery.Type must be TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0.",
856 ));
857 }
858 let query = q
859 .get("Query")
860 .and_then(|v| v.as_str())
861 .ok_or_else(|| bad_request("ResourceQuery.Query is required."))?;
862 serde_json::from_str::<Value>(query)
864 .map_err(|e| bad_request(&format!("ResourceQuery.Query is not valid JSON: {e}")))?;
865 Ok(Some(ResourceQuery {
866 type_: type_.to_string(),
867 query: query.to_string(),
868 }))
869}
870
871fn parse_tags(v: Option<&Value>) -> BTreeMap<String, String> {
872 v.and_then(|t| t.as_object())
873 .map(|o| {
874 o.iter()
875 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
876 .collect()
877 })
878 .unwrap_or_default()
879}
880
881fn string_list(v: Option<&Value>) -> Vec<String> {
882 v.and_then(|a| a.as_array())
883 .map(|a| {
884 a.iter()
885 .filter_map(|s| s.as_str().map(String::from))
886 .collect()
887 })
888 .unwrap_or_default()
889}
890
891fn validate_group_name(name: &str) -> Result<(), AwsServiceError> {
892 if name.len() > 300
893 || !name
894 .chars()
895 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
896 {
897 return Err(bad_request(
898 "Group name must be 1-300 chars of [A-Za-z0-9._-].",
899 ));
900 }
901 Ok(())
902}
903
904fn validate_max_results(body: &Value) -> Result<(), AwsServiceError> {
905 if let Some(v) = body.get("MaxResults") {
906 let n = v
907 .as_i64()
908 .ok_or_else(|| bad_request("MaxResults must be an integer."))?;
909 if !(1..=50).contains(&n) {
910 return Err(bad_request("MaxResults must be between 1 and 50."));
911 }
912 }
913 Ok(())
914}
915
916fn validate_next_token(body: &Value) -> Result<(), AwsServiceError> {
918 if let Some(t) = body.get("NextToken").and_then(|v| v.as_str()) {
919 if t.len() > 8192 {
920 return Err(bad_request("NextToken must be at most 8192 characters."));
921 }
922 }
923 Ok(())
924}
925
926fn inject_query_pagination(req: &AwsRequest, body: &mut Value) {
929 if body.get("MaxResults").is_none() {
930 if let Some(v) = req.query_params.get("maxResults") {
931 body["MaxResults"] = v
932 .parse::<i64>()
933 .map(|n| json!(n))
934 .unwrap_or_else(|_| json!(v));
935 }
936 }
937 if body.get("NextToken").is_none() {
938 if let Some(v) = req.query_params.get("nextToken") {
939 body["NextToken"] = json!(v);
940 }
941 }
942}
943
944fn paginate(items: Vec<Value>, body: &Value) -> (Vec<Value>, Option<String>) {
947 let max = body
948 .get("MaxResults")
949 .and_then(|v| v.as_i64())
950 .map(|n| n as usize)
951 .unwrap_or(50);
952 let start = match body.get("NextToken").and_then(|v| v.as_str()) {
953 Some(t) => match t.parse::<usize>() {
954 Ok(n) => n,
955 Err(_) => return (Vec::new(), None),
956 },
957 None => 0,
958 };
959 let total = items.len();
960 if start >= total {
961 return (Vec::new(), None);
962 }
963 let end = start.saturating_add(max).min(total);
964 let next = (end < total).then(|| end.to_string());
965 (items[start..end].to_vec(), next)
966}
967
968fn decode(seg: &str) -> String {
969 percent_decode_str(seg).decode_utf8_lossy().into_owned()
970}
971
972fn short_id() -> String {
975 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
976 let mut bytes = uuid::Uuid::new_v4().into_bytes().to_vec();
977 bytes.extend_from_slice(&uuid::Uuid::new_v4().into_bytes()[..10]);
978 bytes[..26]
979 .iter()
980 .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
981 .collect()
982}
983
984fn group_arn(region: &str, account: &str, name: &str) -> String {
985 format!(
986 "arn:aws:resource-groups:{region}:{account}:group/{name}/{}",
987 short_id()
988 )
989}
990
991fn tag_sync_task_arn(group_arn: &str) -> String {
994 format!("{group_arn}/tag-sync-task/{}", short_id())
995}
996
997fn resource_type_from_arn(arn: &str) -> Option<String> {
1000 let parts: Vec<&str> = arn.split(':').collect();
1001 if parts.len() < 6 || parts[0] != "arn" {
1002 return None;
1003 }
1004 let service = parts[2];
1005 if service.is_empty() {
1006 return None;
1007 }
1008 let resource_seg = parts[5];
1009 let restype = resource_seg
1010 .split(['/', ':'])
1011 .next()
1012 .filter(|s| !s.is_empty())?;
1013 Some(format!(
1014 "AWS::{}::{}",
1015 titlecase(service),
1016 titlecase(restype)
1017 ))
1018}
1019
1020fn titlecase(s: &str) -> String {
1021 let mut c = s.chars();
1022 match c.next() {
1023 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1024 None => String::new(),
1025 }
1026}
1027
1028fn bad_request(msg: &str) -> AwsServiceError {
1029 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
1030}
1031
1032fn not_found(what: &str) -> AwsServiceError {
1033 AwsServiceError::aws_error(
1034 StatusCode::NOT_FOUND,
1035 "NotFoundException",
1036 format!("The specified group or resource '{what}' was not found."),
1037 )
1038}