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 tag_registry: Option<fakecloud_core::tag_index::TagProviderRegistry>,
56}
57
58enum Route {
60 CreateGroup,
61 GetGroup,
62 GetGroupQuery,
63 UpdateGroup,
64 UpdateGroupQuery,
65 DeleteGroup,
66 ListGroups,
67 GroupResources,
68 UngroupResources,
69 ListGroupResources,
70 SearchResources,
71 GetGroupConfiguration,
72 PutGroupConfiguration,
73 GetTags(String),
74 Tag(String),
75 Untag(String),
76 GetAccountSettings,
77 UpdateAccountSettings,
78 ListGroupingStatuses,
79 StartTagSyncTask,
80 GetTagSyncTask,
81 ListTagSyncTasks,
82 CancelTagSyncTask,
83}
84
85impl Route {
86 fn mutates(&self) -> bool {
87 matches!(
88 self,
89 Route::CreateGroup
90 | Route::UpdateGroup
91 | Route::UpdateGroupQuery
92 | Route::DeleteGroup
93 | Route::GroupResources
94 | Route::UngroupResources
95 | Route::PutGroupConfiguration
96 | Route::Tag(_)
97 | Route::Untag(_)
98 | Route::UpdateAccountSettings
99 | Route::StartTagSyncTask
100 | Route::CancelTagSyncTask
101 )
102 }
103}
104
105impl ResourceGroupsService {
106 pub fn new(state: SharedResourceGroupsState) -> Self {
107 Self {
108 state,
109 snapshot_store: None,
110 snapshot_lock: Arc::new(AsyncMutex::new(())),
111 tag_registry: None,
112 }
113 }
114
115 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
116 self.snapshot_store = Some(store);
117 self
118 }
119
120 pub fn with_tag_registry(
121 mut self,
122 registry: fakecloud_core::tag_index::TagProviderRegistry,
123 ) -> Self {
124 self.tag_registry = Some(registry);
125 self
126 }
127
128 async fn persist(&self) {
129 save_snapshot(
130 &self.state,
131 self.snapshot_store.clone(),
132 &self.snapshot_lock,
133 )
134 .await;
135 }
136
137 fn resolve_route(req: &AwsRequest) -> Option<Route> {
138 let segs: Vec<&str> = req
139 .raw_path
140 .trim_matches('/')
141 .split('/')
142 .filter(|s| !s.is_empty())
143 .collect();
144 match (&req.method, segs.as_slice()) {
145 (&Method::POST, ["groups"]) => Some(Route::CreateGroup),
146 (&Method::POST, ["groups-list"]) => Some(Route::ListGroups),
147 (&Method::POST, ["get-group"]) => Some(Route::GetGroup),
148 (&Method::POST, ["get-group-query"]) => Some(Route::GetGroupQuery),
149 (&Method::POST, ["update-group"]) => Some(Route::UpdateGroup),
150 (&Method::POST, ["update-group-query"]) => Some(Route::UpdateGroupQuery),
151 (&Method::POST, ["delete-group"]) => Some(Route::DeleteGroup),
152 (&Method::POST, ["group-resources"]) => Some(Route::GroupResources),
153 (&Method::POST, ["ungroup-resources"]) => Some(Route::UngroupResources),
154 (&Method::POST, ["list-group-resources"]) => Some(Route::ListGroupResources),
155 (&Method::POST, ["resources", "search"]) => Some(Route::SearchResources),
156 (&Method::POST, ["get-group-configuration"]) => Some(Route::GetGroupConfiguration),
157 (&Method::POST, ["put-group-configuration"]) => Some(Route::PutGroupConfiguration),
158 (&Method::POST, ["get-account-settings"]) => Some(Route::GetAccountSettings),
159 (&Method::POST, ["update-account-settings"]) => Some(Route::UpdateAccountSettings),
160 (&Method::POST, ["list-grouping-statuses"]) => Some(Route::ListGroupingStatuses),
161 (&Method::POST, ["start-tag-sync-task"]) => Some(Route::StartTagSyncTask),
162 (&Method::POST, ["get-tag-sync-task"]) => Some(Route::GetTagSyncTask),
163 (&Method::POST, ["list-tag-sync-tasks"]) => Some(Route::ListTagSyncTasks),
164 (&Method::POST, ["cancel-tag-sync-task"]) => Some(Route::CancelTagSyncTask),
165 (&Method::GET, ["resources", arn, "tags"]) => Some(Route::GetTags(decode(arn))),
166 (&Method::PUT, ["resources", arn, "tags"]) => Some(Route::Tag(decode(arn))),
167 (&Method::PATCH, ["resources", arn, "tags"]) => Some(Route::Untag(decode(arn))),
168 _ => None,
169 }
170 }
171
172 fn create_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
175 let body = parse_json(&req.body)?;
176 let name = require_str(&body, "Name")?.to_string();
177 validate_group_name(&name)?;
178 let query = parse_resource_query(body.get("ResourceQuery"))?;
179 let configuration = body
180 .get("Configuration")
181 .and_then(|v| v.as_array())
182 .cloned()
183 .unwrap_or_default();
184 if query.is_none() && configuration.is_empty() {
185 return Err(bad_request(
186 "A group must be created with either a ResourceQuery or a Configuration.",
187 ));
188 }
189 let tags = parse_tags(body.get("Tags"));
190
191 let mut accounts = self.state.write();
192 let st = accounts.get_or_create(&req.account_id);
193 if st.groups.contains_key(&name) {
194 return Err(bad_request(&format!(
195 "A group with the name '{name}' already exists."
196 )));
197 }
198 let arn = group_arn(&req.region, &req.account_id, &name);
199 let group = ResourceGroup {
200 name: name.clone(),
201 arn,
202 description: opt_string(&body, "Description"),
203 query,
204 configuration,
205 tags: tags.clone(),
206 criticality: body
207 .get("Criticality")
208 .and_then(|v| v.as_i64())
209 .map(|n| n as i32),
210 owner: opt_string(&body, "Owner"),
211 display_name: opt_string(&body, "DisplayName"),
212 application_tag: BTreeMap::new(),
213 resources: BTreeSet::new(),
214 created_at: Utc::now(),
215 };
216 let out = json!({
217 "Group": group_json(&group),
218 "ResourceQuery": group.query.as_ref().map(resource_query_json),
219 "Tags": tags,
220 "GroupConfiguration": group_configuration_json(&group),
221 });
222 st.groups.insert(name, group);
223 Ok(AwsResponse::json_value(StatusCode::OK, out))
224 }
225
226 fn get_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
227 let body = parse_json(&req.body)?;
228 let key = group_key(&body)?;
229 let accounts = self.state.read();
230 let st = accounts.get(&req.account_id);
231 let group = st
232 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
233 .ok_or_else(|| not_found(&key))?;
234 Ok(AwsResponse::json_value(
235 StatusCode::OK,
236 json!({ "Group": group_json(group) }),
237 ))
238 }
239
240 fn get_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
241 let body = parse_json(&req.body)?;
242 let key = group_key(&body)?;
243 let accounts = self.state.read();
244 let st = accounts.get(&req.account_id);
245 let group = st
246 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
247 .ok_or_else(|| not_found(&key))?;
248 let query = group.query.as_ref().ok_or_else(|| {
249 bad_request("The group is not a resource-query group and has no query.")
250 })?;
251 Ok(AwsResponse::json_value(
252 StatusCode::OK,
253 json!({
254 "GroupQuery": {
255 "GroupName": group.name,
256 "ResourceQuery": resource_query_json(query),
257 }
258 }),
259 ))
260 }
261
262 fn update_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
263 let body = parse_json(&req.body)?;
264 let key = group_key(&body)?;
265 let mut accounts = self.state.write();
266 let st = accounts.get_or_create(&req.account_id);
267 let Some(name) = st.resolve_name(&key).map(String::from) else {
268 return Err(not_found(&key));
269 };
270 let group = st.groups.get_mut(&name).unwrap();
271 if let Some(d) = body.get("Description").and_then(|v| v.as_str()) {
272 group.description = Some(d.to_string());
273 }
274 if let Some(c) = body.get("Criticality").and_then(|v| v.as_i64()) {
275 group.criticality = Some(c as i32);
276 }
277 if let Some(o) = body.get("Owner").and_then(|v| v.as_str()) {
278 group.owner = Some(o.to_string());
279 }
280 if let Some(dn) = body.get("DisplayName").and_then(|v| v.as_str()) {
281 group.display_name = Some(dn.to_string());
282 }
283 Ok(AwsResponse::json_value(
284 StatusCode::OK,
285 json!({ "Group": group_json(group) }),
286 ))
287 }
288
289 fn update_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
290 let body = parse_json(&req.body)?;
291 let key = group_key(&body)?;
292 let query = parse_resource_query(body.get("ResourceQuery"))?
293 .ok_or_else(|| bad_request("ResourceQuery is required."))?;
294 let mut accounts = self.state.write();
295 let st = accounts.get_or_create(&req.account_id);
296 let Some(name) = st.resolve_name(&key).map(String::from) else {
297 return Err(not_found(&key));
298 };
299 let group = st.groups.get_mut(&name).unwrap();
300 group.query = Some(query.clone());
301 Ok(AwsResponse::json_value(
302 StatusCode::OK,
303 json!({
304 "GroupQuery": {
305 "GroupName": group.name,
306 "ResourceQuery": resource_query_json(&query),
307 }
308 }),
309 ))
310 }
311
312 fn delete_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
313 let body = parse_json(&req.body)?;
314 let key = group_key(&body)?;
315 let mut accounts = self.state.write();
316 let st = accounts.get_or_create(&req.account_id);
317 let Some(name) = st.resolve_name(&key).map(String::from) else {
318 return Err(not_found(&key));
319 };
320 let group = st.groups.remove(&name).unwrap();
321 Ok(AwsResponse::json_value(
322 StatusCode::OK,
323 json!({ "Group": group_json(&group) }),
324 ))
325 }
326
327 fn list_groups(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
328 let mut body = parse_json(&req.body)?;
331 inject_query_pagination(req, &mut body);
332 validate_max_results(&body)?;
333 validate_next_token(&body)?;
334 let accounts = self.state.read();
335 let groups: Vec<&ResourceGroup> = accounts
336 .get(&req.account_id)
337 .map(|s| s.groups.values().collect())
338 .unwrap_or_default();
339 let identifiers: Vec<Value> = groups.iter().map(|g| group_identifier_json(g)).collect();
340 let full: Vec<Value> = groups.iter().map(|g| group_json(g)).collect();
341 let (ident_page, next) = paginate(identifiers, &body);
342 let (full_page, _) = paginate(full, &body);
343 let mut out = json!({
344 "GroupIdentifiers": ident_page,
345 "Groups": full_page,
346 });
347 if let Some(nt) = next {
348 out["NextToken"] = json!(nt);
349 }
350 Ok(AwsResponse::json_value(StatusCode::OK, out))
351 }
352
353 fn group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
356 let body = parse_json(&req.body)?;
357 let key = require_str(&body, "Group")?.to_string();
358 let arns = string_list(body.get("ResourceArns"));
359 if arns.is_empty() {
360 return Err(bad_request("ResourceArns must not be empty."));
361 }
362 let mut accounts = self.state.write();
363 let st = accounts.get_or_create(&req.account_id);
364 let Some(name) = st.resolve_name(&key).map(String::from) else {
365 return Err(not_found(&key));
366 };
367 let group = st.groups.get_mut(&name).unwrap();
368 if group.query.is_some() {
369 return Err(bad_request(
370 "Resources cannot be added to a resource-query group; membership is computed from its query.",
371 ));
372 }
373 for a in &arns {
374 group.resources.insert(a.clone());
375 }
376 Ok(AwsResponse::json_value(
377 StatusCode::OK,
378 json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
379 ))
380 }
381
382 fn ungroup_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
383 let body = parse_json(&req.body)?;
384 let key = require_str(&body, "Group")?.to_string();
385 let arns = string_list(body.get("ResourceArns"));
386 let mut accounts = self.state.write();
387 let st = accounts.get_or_create(&req.account_id);
388 let Some(name) = st.resolve_name(&key).map(String::from) else {
389 return Err(not_found(&key));
390 };
391 let group = st.groups.get_mut(&name).unwrap();
392 if group.query.is_some() {
393 return Err(bad_request(
394 "Resources cannot be removed from a resource-query group; membership is computed from its query.",
395 ));
396 }
397 for a in &arns {
398 group.resources.remove(a);
399 }
400 Ok(AwsResponse::json_value(
401 StatusCode::OK,
402 json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
403 ))
404 }
405
406 fn list_group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
407 let body = parse_json(&req.body)?;
408 validate_max_results(&body)?;
409 validate_next_token(&body)?;
410 let key = group_key_field(&body, "GroupName", "Group")?;
411 let accounts = self.state.read();
412 let st = accounts.get(&req.account_id);
413 let group = st
414 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
415 .ok_or_else(|| not_found(&key))?;
416 let mut arns: BTreeSet<String> = group.resources.clone();
421 if let Some(rq) = &group.query {
422 for arn in self.evaluate_query(&req.account_id, &req.region, rq) {
423 arns.insert(arn);
424 }
425 }
426 let items: Vec<Value> = arns
427 .iter()
428 .map(|arn| {
429 json!({
430 "Identifier": resource_identifier_json(arn),
431 "Status": { "Name": "ACTIVE" },
432 })
433 })
434 .collect();
435 let idents: Vec<Value> = arns
436 .iter()
437 .map(|arn| resource_identifier_json(arn))
438 .collect();
439 let (items_page, next) = paginate(items, &body);
440 let (idents_page, _) = paginate(idents, &body);
441 let mut out = json!({
442 "Resources": items_page,
443 "ResourceIdentifiers": idents_page,
444 "QueryErrors": [],
445 });
446 if let Some(nt) = next {
447 out["NextToken"] = json!(nt);
448 }
449 Ok(AwsResponse::json_value(StatusCode::OK, out))
450 }
451
452 fn search_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
453 let body = parse_json(&req.body)?;
454 validate_max_results(&body)?;
455 validate_next_token(&body)?;
456 let rq = parse_resource_query(body.get("ResourceQuery"))?
457 .ok_or_else(|| bad_request("ResourceQuery is required."))?;
458 let arns = self.evaluate_query(&req.account_id, &req.region, &rq);
461 let idents: Vec<Value> = arns
462 .iter()
463 .map(|arn| resource_identifier_json(arn))
464 .collect();
465 let (idents_page, next) = paginate(idents, &body);
466 let mut out = json!({ "ResourceIdentifiers": idents_page, "QueryErrors": [] });
467 if let Some(nt) = next {
468 out["NextToken"] = json!(nt);
469 }
470 Ok(AwsResponse::json_value(StatusCode::OK, out))
471 }
472
473 fn evaluate_query(&self, account: &str, region: &str, rq: &ResourceQuery) -> Vec<String> {
478 if rq.type_ != "TAG_FILTERS_1_0" {
479 return Vec::new();
480 }
481 let Some(registry) = &self.tag_registry else {
482 return Vec::new();
483 };
484 let Ok(parsed) = serde_json::from_str::<Value>(&rq.query) else {
485 return Vec::new();
486 };
487 let type_filters = string_list(parsed.get("ResourceTypeFilters"));
488 let tag_filters: Vec<(String, Vec<String>)> = parsed
489 .get("TagFilters")
490 .and_then(|v| v.as_array())
491 .map(|arr| {
492 arr.iter()
493 .filter_map(|f| {
494 let key = f.get("Key").and_then(Value::as_str)?.to_string();
495 let values = string_list(f.get("Values"));
496 Some((key, values))
497 })
498 .collect()
499 })
500 .unwrap_or_default();
501 registry
502 .resources(account, Some(region))
503 .into_iter()
504 .filter(|r| type_query_matches(&type_filters, &r.resource_type))
505 .filter(|r| tag_filters_match(&tag_filters, &r.tags))
506 .map(|r| r.arn)
507 .collect()
508 }
509
510 fn get_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
513 let body = parse_json(&req.body)?;
514 let key = group_key_field(&body, "Group", "Group")?;
515 let accounts = self.state.read();
516 let st = accounts.get(&req.account_id);
517 let group = st
518 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
519 .ok_or_else(|| not_found(&key))?;
520 Ok(AwsResponse::json_value(
521 StatusCode::OK,
522 json!({ "GroupConfiguration": group_configuration_json(group) }),
523 ))
524 }
525
526 fn put_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
527 let body = parse_json(&req.body)?;
528 let key = require_str(&body, "Group")?.to_string();
529 let configuration = body
530 .get("Configuration")
531 .and_then(|v| v.as_array())
532 .cloned()
533 .unwrap_or_default();
534 let mut accounts = self.state.write();
535 let st = accounts.get_or_create(&req.account_id);
536 let Some(name) = st.resolve_name(&key).map(String::from) else {
537 return Err(not_found(&key));
538 };
539 st.groups.get_mut(&name).unwrap().configuration = configuration;
540 Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
541 }
542
543 fn get_tags(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
546 let accounts = self.state.read();
547 let st = accounts.get(&req.account_id);
548 let group = st
549 .and_then(|s| s.groups.values().find(|g| g.arn == arn))
550 .ok_or_else(|| not_found(arn))?;
551 Ok(AwsResponse::json_value(
552 StatusCode::OK,
553 json!({ "Arn": arn, "Tags": group.tags }),
554 ))
555 }
556
557 fn tag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
558 let body = parse_json(&req.body)?;
559 let tags = parse_tags(body.get("Tags"));
560 let mut accounts = self.state.write();
561 let st = accounts.get_or_create(&req.account_id);
562 let group = st
563 .groups
564 .values_mut()
565 .find(|g| g.arn == arn)
566 .ok_or_else(|| not_found(arn))?;
567 for (k, v) in &tags {
568 group.tags.insert(k.clone(), v.clone());
569 }
570 Ok(AwsResponse::json_value(
571 StatusCode::OK,
572 json!({ "Arn": arn, "Tags": tags }),
573 ))
574 }
575
576 fn untag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
577 let body = parse_json(&req.body)?;
578 let keys = string_list(body.get("Keys"));
579 let mut accounts = self.state.write();
580 let st = accounts.get_or_create(&req.account_id);
581 let group = st
582 .groups
583 .values_mut()
584 .find(|g| g.arn == arn)
585 .ok_or_else(|| not_found(arn))?;
586 for k in &keys {
587 group.tags.remove(k);
588 }
589 Ok(AwsResponse::json_value(
590 StatusCode::OK,
591 json!({ "Arn": arn, "Keys": keys }),
592 ))
593 }
594
595 fn get_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
598 let accounts = self.state.read();
599 let settings = accounts
600 .get(&req.account_id)
601 .map(|s| s.account_settings.clone())
602 .unwrap_or_default();
603 Ok(AwsResponse::json_value(
604 StatusCode::OK,
605 json!({ "AccountSettings": account_settings_json(&settings) }),
606 ))
607 }
608
609 fn update_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
610 let body = parse_json(&req.body)?;
611 let desired = body
612 .get("GroupLifecycleEventsDesiredStatus")
613 .and_then(|v| v.as_str());
614 let mut accounts = self.state.write();
615 let st = accounts.get_or_create(&req.account_id);
616 if let Some(d) = desired {
617 if d != "ACTIVE" && d != "INACTIVE" {
618 return Err(bad_request(
619 "GroupLifecycleEventsDesiredStatus must be ACTIVE or INACTIVE.",
620 ));
621 }
622 st.account_settings.desired_status = Some(d.to_string());
623 }
624 Ok(AwsResponse::json_value(
625 StatusCode::OK,
626 json!({ "AccountSettings": account_settings_json(&st.account_settings) }),
627 ))
628 }
629
630 fn list_grouping_statuses(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
631 let body = parse_json(&req.body)?;
632 validate_max_results(&body)?;
633 validate_next_token(&body)?;
634 let key = require_str(&body, "Group")?.to_string();
635 let accounts = self.state.read();
636 let st = accounts.get(&req.account_id);
637 let group = st
638 .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
639 .ok_or_else(|| bad_request(&format!("The specified group '{key}' was not found.")))?;
642 Ok(AwsResponse::json_value(
644 StatusCode::OK,
645 json!({ "Group": group.arn, "GroupingStatuses": [] }),
646 ))
647 }
648
649 fn start_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
652 let body = parse_json(&req.body)?;
653 let key = require_str(&body, "Group")?.to_string();
654 let role_arn = require_str(&body, "RoleArn")?.to_string();
655 let mut accounts = self.state.write();
656 let st = accounts.get_or_create(&req.account_id);
657 let Some(name) = st.resolve_name(&key).map(String::from) else {
658 return Err(not_found(&key));
659 };
660 let group_arn = st.groups.get(&name).unwrap().arn.clone();
661 let task_arn = tag_sync_task_arn(&group_arn);
662 let task = TagSyncTask {
663 task_arn: task_arn.clone(),
664 group_arn: group_arn.clone(),
665 group_name: name.clone(),
666 tag_key: opt_string(&body, "TagKey"),
667 tag_value: opt_string(&body, "TagValue"),
668 resource_query: parse_resource_query(body.get("ResourceQuery"))?,
669 role_arn: role_arn.clone(),
670 status: "ACTIVE".to_string(),
671 created_at: Utc::now(),
672 };
673 st.tag_sync_tasks.insert(task_arn.clone(), task.clone());
674 Ok(AwsResponse::json_value(
675 StatusCode::OK,
676 json!({
677 "GroupArn": group_arn,
678 "GroupName": name,
679 "TaskArn": task_arn,
680 "TagKey": task.tag_key,
681 "TagValue": task.tag_value,
682 "ResourceQuery": task.resource_query.as_ref().map(resource_query_json),
683 "RoleArn": role_arn,
684 }),
685 ))
686 }
687
688 fn get_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
689 let body = parse_json(&req.body)?;
690 let task_arn = require_str(&body, "TaskArn")?.to_string();
691 let accounts = self.state.read();
692 let task = accounts
693 .get(&req.account_id)
694 .and_then(|s| s.tag_sync_tasks.get(&task_arn))
695 .ok_or_else(|| not_found(&task_arn))?;
696 Ok(AwsResponse::json_value(
697 StatusCode::OK,
698 tag_sync_task_json(task),
699 ))
700 }
701
702 fn list_tag_sync_tasks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
703 let body = parse_json(&req.body)?;
704 validate_max_results(&body)?;
705 validate_next_token(&body)?;
706 let accounts = self.state.read();
707 let all: Vec<Value> = accounts
708 .get(&req.account_id)
709 .map(|s| s.tag_sync_tasks.values().map(tag_sync_task_json).collect())
710 .unwrap_or_default();
711 let (page, next) = paginate(all, &body);
712 let mut out = json!({ "TagSyncTasks": page });
713 if let Some(nt) = next {
714 out["NextToken"] = json!(nt);
715 }
716 Ok(AwsResponse::json_value(StatusCode::OK, out))
717 }
718
719 fn cancel_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
720 let body = parse_json(&req.body)?;
721 let task_arn = require_str(&body, "TaskArn")?.to_string();
722 let mut accounts = self.state.write();
723 let st = accounts.get_or_create(&req.account_id);
724 if st.tag_sync_tasks.remove(&task_arn).is_none() {
725 return Err(bad_request(&format!(
728 "The specified tag-sync task '{task_arn}' was not found."
729 )));
730 }
731 Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
732 }
733}
734
735#[async_trait]
736impl AwsService for ResourceGroupsService {
737 fn service_name(&self) -> &str {
738 "resource-groups"
739 }
740
741 fn supported_actions(&self) -> &[&str] {
742 RESOURCE_GROUPS_ACTIONS
743 }
744
745 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
746 let Some(route) = Self::resolve_route(&req) else {
747 return Err(AwsServiceError::aws_error(
748 StatusCode::NOT_FOUND,
749 "UnknownOperationException",
750 format!("Unknown operation: {} {}", req.method, req.raw_path),
751 ));
752 };
753 let mutates = route.mutates();
754 let result = match route {
755 Route::CreateGroup => self.create_group(&req),
756 Route::GetGroup => self.get_group(&req),
757 Route::GetGroupQuery => self.get_group_query(&req),
758 Route::UpdateGroup => self.update_group(&req),
759 Route::UpdateGroupQuery => self.update_group_query(&req),
760 Route::DeleteGroup => self.delete_group(&req),
761 Route::ListGroups => self.list_groups(&req),
762 Route::GroupResources => self.group_resources(&req),
763 Route::UngroupResources => self.ungroup_resources(&req),
764 Route::ListGroupResources => self.list_group_resources(&req),
765 Route::SearchResources => self.search_resources(&req),
766 Route::GetGroupConfiguration => self.get_group_configuration(&req),
767 Route::PutGroupConfiguration => self.put_group_configuration(&req),
768 Route::GetTags(arn) => self.get_tags(&req, &arn),
769 Route::Tag(arn) => self.tag(&req, &arn),
770 Route::Untag(arn) => self.untag(&req, &arn),
771 Route::GetAccountSettings => self.get_account_settings(&req),
772 Route::UpdateAccountSettings => self.update_account_settings(&req),
773 Route::ListGroupingStatuses => self.list_grouping_statuses(&req),
774 Route::StartTagSyncTask => self.start_tag_sync_task(&req),
775 Route::GetTagSyncTask => self.get_tag_sync_task(&req),
776 Route::ListTagSyncTasks => self.list_tag_sync_tasks(&req),
777 Route::CancelTagSyncTask => self.cancel_tag_sync_task(&req),
778 };
779 if mutates && result.is_ok() {
780 self.persist().await;
781 }
782 result
783 }
784}
785
786fn group_json(g: &ResourceGroup) -> Value {
791 let mut v = json!({ "GroupArn": g.arn, "Name": g.name });
792 if let Some(d) = &g.description {
793 v["Description"] = json!(d);
794 }
795 if let Some(c) = g.criticality {
796 v["Criticality"] = json!(c);
797 }
798 if let Some(o) = &g.owner {
799 v["Owner"] = json!(o);
800 }
801 if let Some(dn) = &g.display_name {
802 v["DisplayName"] = json!(dn);
803 }
804 if !g.application_tag.is_empty() {
805 v["ApplicationTag"] = json!(g.application_tag);
806 }
807 v
808}
809
810fn group_identifier_json(g: &ResourceGroup) -> Value {
811 let mut v = json!({ "GroupName": g.name, "GroupArn": g.arn });
812 if let Some(d) = &g.description {
813 v["Description"] = json!(d);
814 }
815 if let Some(c) = g.criticality {
816 v["Criticality"] = json!(c);
817 }
818 if let Some(o) = &g.owner {
819 v["Owner"] = json!(o);
820 }
821 if let Some(dn) = &g.display_name {
822 v["DisplayName"] = json!(dn);
823 }
824 v
825}
826
827fn resource_query_json(q: &ResourceQuery) -> Value {
828 json!({ "Type": q.type_, "Query": q.query })
829}
830
831fn group_configuration_json(g: &ResourceGroup) -> Value {
832 json!({
833 "Configuration": g.configuration,
834 "Status": "UPDATE_COMPLETE",
835 })
836}
837
838fn resource_identifier_json(arn: &str) -> Value {
839 let mut v = json!({ "ResourceArn": arn });
840 if let Some(t) = resource_type_from_arn(arn) {
841 v["ResourceType"] = json!(t);
842 }
843 v
844}
845
846fn account_settings_json(s: &AccountSettings) -> Value {
847 let desired = s
848 .desired_status
849 .clone()
850 .unwrap_or_else(|| "INACTIVE".into());
851 json!({
852 "GroupLifecycleEventsDesiredStatus": desired,
853 "GroupLifecycleEventsStatus": desired,
854 })
855}
856
857fn tag_sync_task_json(t: &TagSyncTask) -> Value {
858 json!({
859 "GroupArn": t.group_arn,
860 "GroupName": t.group_name,
861 "TaskArn": t.task_arn,
862 "TagKey": t.tag_key,
863 "TagValue": t.tag_value,
864 "ResourceQuery": t.resource_query.as_ref().map(resource_query_json),
865 "RoleArn": t.role_arn,
866 "Status": t.status,
867 "CreatedAt": t.created_at.timestamp() as f64,
868 })
869}
870
871fn parse_json(body: &[u8]) -> Result<Value, AwsServiceError> {
876 if body.is_empty() {
877 return Ok(json!({}));
878 }
879 serde_json::from_slice(body).map_err(|e| bad_request(&format!("invalid request body: {e}")))
880}
881
882fn require_str<'a>(body: &'a Value, field: &str) -> Result<&'a str, AwsServiceError> {
883 body.get(field)
884 .and_then(|v| v.as_str())
885 .filter(|s| !s.is_empty())
886 .ok_or_else(|| bad_request(&format!("{field} is required.")))
887}
888
889fn opt_string(body: &Value, field: &str) -> Option<String> {
890 body.get(field).and_then(|v| v.as_str()).map(String::from)
891}
892
893fn group_key(body: &Value) -> Result<String, AwsServiceError> {
896 group_key_field(body, "GroupName", "Group")
897}
898
899fn group_key_field(body: &Value, a: &str, b: &str) -> Result<String, AwsServiceError> {
900 body.get(a)
901 .or_else(|| body.get(b))
902 .and_then(|v| v.as_str())
903 .filter(|s| !s.is_empty())
904 .map(String::from)
905 .ok_or_else(|| bad_request("A group name or ARN is required."))
906}
907
908fn parse_resource_query(v: Option<&Value>) -> Result<Option<ResourceQuery>, AwsServiceError> {
909 let Some(q) = v else { return Ok(None) };
910 if q.is_null() {
911 return Ok(None);
912 }
913 let type_ = q
914 .get("Type")
915 .and_then(|v| v.as_str())
916 .ok_or_else(|| bad_request("ResourceQuery.Type is required."))?;
917 if type_ != "TAG_FILTERS_1_0" && type_ != "CLOUDFORMATION_STACK_1_0" {
918 return Err(bad_request(
919 "ResourceQuery.Type must be TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0.",
920 ));
921 }
922 let query = q
923 .get("Query")
924 .and_then(|v| v.as_str())
925 .ok_or_else(|| bad_request("ResourceQuery.Query is required."))?;
926 serde_json::from_str::<Value>(query)
928 .map_err(|e| bad_request(&format!("ResourceQuery.Query is not valid JSON: {e}")))?;
929 Ok(Some(ResourceQuery {
930 type_: type_.to_string(),
931 query: query.to_string(),
932 }))
933}
934
935fn split_service_type(t: &str) -> (String, Option<String>) {
938 let t = t.to_lowercase();
939 match t.split_once(':') {
940 Some((s, ty)) => (s.to_string(), Some(ty.to_string())),
941 None => (t, None),
942 }
943}
944
945fn normalize_type_filter(filter: &str) -> (String, Option<String>) {
949 if filter.contains("::") {
950 let parts: Vec<&str> = filter.split("::").collect();
951 match parts.as_slice() {
953 [_, service, ty] => (service.to_lowercase(), Some(ty.to_lowercase())),
954 [_, service] => (service.to_lowercase(), None),
955 _ => split_service_type(filter),
956 }
957 } else {
958 split_service_type(filter)
959 }
960}
961
962fn type_query_matches(filters: &[String], resource_type: &str) -> bool {
968 if filters.is_empty() || filters.iter().any(|f| f == "AWS::AllSupported") {
969 return true;
970 }
971 let (rs, rt) = split_service_type(resource_type);
972 filters.iter().any(|f| {
973 let (fs, ft) = normalize_type_filter(f);
974 fs == rs && (ft.is_none() || rt.is_none() || ft == rt)
975 })
976}
977
978fn tag_filters_match(filters: &[(String, Vec<String>)], tags: &BTreeMap<String, String>) -> bool {
981 filters.iter().all(|(key, values)| match tags.get(key) {
982 None => false,
983 Some(v) => values.is_empty() || values.iter().any(|x| x == v),
984 })
985}
986
987fn parse_tags(v: Option<&Value>) -> BTreeMap<String, String> {
988 v.and_then(|t| t.as_object())
989 .map(|o| {
990 o.iter()
991 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
992 .collect()
993 })
994 .unwrap_or_default()
995}
996
997fn string_list(v: Option<&Value>) -> Vec<String> {
998 v.and_then(|a| a.as_array())
999 .map(|a| {
1000 a.iter()
1001 .filter_map(|s| s.as_str().map(String::from))
1002 .collect()
1003 })
1004 .unwrap_or_default()
1005}
1006
1007fn validate_group_name(name: &str) -> Result<(), AwsServiceError> {
1008 if name.len() > 300
1009 || !name
1010 .chars()
1011 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
1012 {
1013 return Err(bad_request(
1014 "Group name must be 1-300 chars of [A-Za-z0-9._-].",
1015 ));
1016 }
1017 Ok(())
1018}
1019
1020fn validate_max_results(body: &Value) -> Result<(), AwsServiceError> {
1021 if let Some(v) = body.get("MaxResults") {
1022 let n = v
1023 .as_i64()
1024 .ok_or_else(|| bad_request("MaxResults must be an integer."))?;
1025 if !(1..=50).contains(&n) {
1026 return Err(bad_request("MaxResults must be between 1 and 50."));
1027 }
1028 }
1029 Ok(())
1030}
1031
1032fn validate_next_token(body: &Value) -> Result<(), AwsServiceError> {
1034 if let Some(t) = body.get("NextToken").and_then(|v| v.as_str()) {
1035 if t.len() > 8192 {
1036 return Err(bad_request("NextToken must be at most 8192 characters."));
1037 }
1038 }
1039 Ok(())
1040}
1041
1042fn inject_query_pagination(req: &AwsRequest, body: &mut Value) {
1045 if body.get("MaxResults").is_none() {
1046 if let Some(v) = req.query_params.get("maxResults") {
1047 body["MaxResults"] = v
1048 .parse::<i64>()
1049 .map(|n| json!(n))
1050 .unwrap_or_else(|_| json!(v));
1051 }
1052 }
1053 if body.get("NextToken").is_none() {
1054 if let Some(v) = req.query_params.get("nextToken") {
1055 body["NextToken"] = json!(v);
1056 }
1057 }
1058}
1059
1060fn paginate(items: Vec<Value>, body: &Value) -> (Vec<Value>, Option<String>) {
1063 let max = body
1064 .get("MaxResults")
1065 .and_then(|v| v.as_i64())
1066 .map(|n| n as usize)
1067 .unwrap_or(50);
1068 let start = match body.get("NextToken").and_then(|v| v.as_str()) {
1069 Some(t) => match t.parse::<usize>() {
1070 Ok(n) => n,
1071 Err(_) => return (Vec::new(), None),
1072 },
1073 None => 0,
1074 };
1075 let total = items.len();
1076 if start >= total {
1077 return (Vec::new(), None);
1078 }
1079 let end = start.saturating_add(max).min(total);
1080 let next = (end < total).then(|| end.to_string());
1081 (items[start..end].to_vec(), next)
1082}
1083
1084fn decode(seg: &str) -> String {
1085 percent_decode_str(seg).decode_utf8_lossy().into_owned()
1086}
1087
1088fn short_id() -> String {
1091 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
1092 let mut bytes = uuid::Uuid::new_v4().into_bytes().to_vec();
1093 bytes.extend_from_slice(&uuid::Uuid::new_v4().into_bytes()[..10]);
1094 bytes[..26]
1095 .iter()
1096 .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
1097 .collect()
1098}
1099
1100fn group_arn(region: &str, account: &str, name: &str) -> String {
1101 format!(
1102 "arn:aws:resource-groups:{region}:{account}:group/{name}/{}",
1103 short_id()
1104 )
1105}
1106
1107fn tag_sync_task_arn(group_arn: &str) -> String {
1110 format!("{group_arn}/tag-sync-task/{}", short_id())
1111}
1112
1113fn resource_type_from_arn(arn: &str) -> Option<String> {
1116 let parts: Vec<&str> = arn.split(':').collect();
1117 if parts.len() < 6 || parts[0] != "arn" {
1118 return None;
1119 }
1120 let service = parts[2];
1121 if service.is_empty() {
1122 return None;
1123 }
1124 let resource_seg = parts[5];
1125 let restype = resource_seg
1126 .split(['/', ':'])
1127 .next()
1128 .filter(|s| !s.is_empty())?;
1129 Some(format!(
1130 "AWS::{}::{}",
1131 titlecase(service),
1132 titlecase(restype)
1133 ))
1134}
1135
1136fn titlecase(s: &str) -> String {
1137 let mut c = s.chars();
1138 match c.next() {
1139 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1140 None => String::new(),
1141 }
1142}
1143
1144fn bad_request(msg: &str) -> AwsServiceError {
1145 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
1146}
1147
1148fn not_found(what: &str) -> AwsServiceError {
1149 AwsServiceError::aws_error(
1150 StatusCode::NOT_FOUND,
1151 "NotFoundException",
1152 format!("The specified group or resource '{what}' was not found."),
1153 )
1154}
1155
1156#[cfg(test)]
1157mod query_tests {
1158 use super::*;
1159 use fakecloud_core::multi_account::MultiAccountState;
1160 use fakecloud_core::tag_index::{TagProvider, TagProviderRegistry, TaggedResource};
1161 use parking_lot::RwLock;
1162 use std::collections::BTreeMap;
1163
1164 struct FakeProvider(Vec<TaggedResource>);
1166 impl TagProvider for FakeProvider {
1167 fn tagged_resources(&self, _account_id: &str) -> Vec<TaggedResource> {
1168 self.0.clone()
1169 }
1170 }
1171
1172 fn tagged(arn: &str, rtype: &str, tags: &[(&str, &str)]) -> TaggedResource {
1173 let map: BTreeMap<String, String> = tags
1174 .iter()
1175 .map(|(k, v)| (k.to_string(), v.to_string()))
1176 .collect();
1177 TaggedResource::new(arn, rtype, map)
1178 }
1179
1180 fn svc_with(resources: Vec<TaggedResource>) -> ResourceGroupsService {
1181 let registry = TagProviderRegistry::new();
1182 registry.register(Arc::new(FakeProvider(resources)));
1183 let state: SharedResourceGroupsState = Arc::new(RwLock::new(MultiAccountState::new(
1184 "123456789012",
1185 "us-east-1",
1186 "",
1187 )));
1188 ResourceGroupsService::new(state).with_tag_registry(registry)
1189 }
1190
1191 fn req(path: &str, body: Value) -> AwsRequest {
1192 AwsRequest {
1193 service: "resource-groups".into(),
1194 action: String::new(),
1195 region: "us-east-1".into(),
1196 account_id: "123456789012".into(),
1197 request_id: "test".into(),
1198 headers: http::HeaderMap::new(),
1199 query_params: std::collections::HashMap::new(),
1200 body: serde_json::to_vec(&body).unwrap().into(),
1201 body_stream: parking_lot::Mutex::new(None),
1202 path_segments: vec![],
1203 raw_path: path.into(),
1204 raw_query: String::new(),
1205 method: Method::POST,
1206 is_query_protocol: false,
1207 access_key_id: None,
1208 principal: None,
1209 }
1210 }
1211
1212 async fn body_of(s: &ResourceGroupsService, path: &str, body: Value) -> Value {
1213 let resp = s.handle(req(path, body)).await.unwrap();
1214 serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1215 }
1216
1217 fn tag_query(type_filters: Value, tag_filters: Value) -> Value {
1218 let q = json!({ "ResourceTypeFilters": type_filters, "TagFilters": tag_filters });
1219 json!({ "Type": "TAG_FILTERS_1_0", "Query": q.to_string() })
1220 }
1221
1222 #[tokio::test]
1223 async fn search_resources_evaluates_tag_query() {
1224 let s = svc_with(vec![
1225 tagged(
1226 "arn:aws:ec2:us-east-1:123456789012:instance/i-1",
1227 "ec2:instance",
1228 &[("stage", "test")],
1229 ),
1230 tagged(
1231 "arn:aws:ec2:us-east-1:123456789012:instance/i-2",
1232 "ec2:instance",
1233 &[("stage", "prod")],
1234 ),
1235 ]);
1236 let out = body_of(
1237 &s,
1238 "/resources/search",
1239 json!({
1240 "ResourceQuery": tag_query(
1241 json!(["AWS::AllSupported"]),
1242 json!([{ "Key": "stage", "Values": ["test"] }])
1243 )
1244 }),
1245 )
1246 .await;
1247 let ids = out["ResourceIdentifiers"].as_array().unwrap();
1248 assert_eq!(ids.len(), 1, "only the test-tagged instance: {out}");
1249 assert_eq!(
1250 ids[0]["ResourceArn"],
1251 "arn:aws:ec2:us-east-1:123456789012:instance/i-1"
1252 );
1253 }
1254
1255 #[tokio::test]
1256 async fn list_group_resources_evaluates_group_query_and_type_filter() {
1257 let s = svc_with(vec![
1258 tagged(
1259 "arn:aws:ec2:us-east-1:123456789012:instance/i-1",
1260 "ec2:instance",
1261 &[("team", "core")],
1262 ),
1263 tagged("arn:aws:s3:::my-bucket", "s3", &[("team", "core")]),
1264 ]);
1265 s.handle(req(
1267 "/groups",
1268 json!({
1269 "Name": "core-ec2",
1270 "ResourceQuery": tag_query(
1271 json!(["AWS::EC2::Instance"]),
1272 json!([{ "Key": "team", "Values": ["core"] }])
1273 )
1274 }),
1275 ))
1276 .await
1277 .unwrap();
1278
1279 let out = body_of(&s, "/list-group-resources", json!({ "Group": "core-ec2" })).await;
1280 let ids = out["ResourceIdentifiers"].as_array().unwrap();
1281 assert_eq!(ids.len(), 1, "only the EC2 instance matches: {out}");
1282 assert_eq!(
1283 ids[0]["ResourceArn"],
1284 "arn:aws:ec2:us-east-1:123456789012:instance/i-1"
1285 );
1286 assert_eq!(out["Resources"][0]["Status"]["Name"], "ACTIVE");
1288 }
1289}