@@ -11,6 +11,29 @@ use super::{extract_log_group_from_arn, resolve_log_group_name};
1111
1212use crate :: state:: LogGroup ;
1313
14+ /// Ordered grouping key for `ListAggregateLogGroupSummaries`:
15+ /// (dataSource.Name, dataSource.Type, optional dataSource.Format).
16+ type DataSourceGroupKey = ( String , String , Option < String > ) ;
17+
18+ /// Derive a data-source name for a log group from its name.
19+ ///
20+ /// fakecloud does not model per-log-group telemetry data sources, so the only
21+ /// real signal available is the log group name. AWS-managed log groups follow
22+ /// the `/aws/<service>/...` convention that identifies the originating service
23+ /// (e.g. `/aws/lambda`, `/aws/vpc`); everything else is grouped by its leading
24+ /// name segment. This is what `ListAggregateLogGroupSummaries` groups on.
25+ fn derive_log_group_data_source ( name : & str ) -> String {
26+ let segs: Vec < & str > = name. split ( '/' ) . filter ( |s| !s. is_empty ( ) ) . collect ( ) ;
27+ if segs. is_empty ( ) {
28+ return "custom" . to_string ( ) ;
29+ }
30+ if segs[ 0 ] == "aws" && segs. len ( ) >= 2 {
31+ format ! ( "/aws/{}" , segs[ 1 ] )
32+ } else {
33+ segs[ 0 ] . to_string ( )
34+ }
35+ }
36+
1437impl LogsService {
1538 // ---- Log Groups ----
1639
@@ -533,10 +556,81 @@ impl LogsService {
533556 129 ,
534557 ) ?;
535558 validate_optional_string_length ( "nextToken" , body[ "nextToken" ] . as_str ( ) , 1 , 4096 ) ?;
536- // Stub: return empty summaries
559+
560+ let group_by = body[ "groupBy" ] . as_str ( ) . unwrap_or ( "" ) ;
561+ let include_format = group_by == "DATA_SOURCE_NAME_TYPE_AND_FORMAT" ;
562+ let class_filter = body[ "logGroupClass" ] . as_str ( ) ;
563+ let pattern = body[ "logGroupNamePattern" ] . as_str ( ) . unwrap_or ( "" ) ;
564+ let limit = body[ "limit" ] . as_i64 ( ) . unwrap_or ( 50 ) as usize ;
565+ let next_token = body[ "nextToken" ] . as_str ( ) ;
566+
567+ let accounts = self . state . read ( ) ;
568+ let empty = crate :: state:: LogsState :: new ( & req. account_id , & req. region ) ;
569+ let state = accounts. get ( & req. account_id ) . unwrap_or ( & empty) ;
570+
571+ // Aggregate the actual stored log groups by their derived data-source
572+ // characteristics. fakecloud stores raw plaintext events with no OCSF
573+ // transformation, so every group's Type is a plain LogGroup and its
574+ // Format is Plain; the Name is derived from the log group name. Under
575+ // both groupBy modes this collapses to grouping by data-source name.
576+ let mut counts: std:: collections:: BTreeMap < DataSourceGroupKey , i64 > =
577+ std:: collections:: BTreeMap :: new ( ) ;
578+ for g in state. log_groups . values ( ) {
579+ let class = g. log_group_class . as_deref ( ) . unwrap_or ( "STANDARD" ) ;
580+ if let Some ( f) = class_filter {
581+ if class != f {
582+ continue ;
583+ }
584+ }
585+ if !pattern. is_empty ( ) && !g. name . contains ( pattern) {
586+ continue ;
587+ }
588+ let ds_name = derive_log_group_data_source ( & g. name ) ;
589+ let key = (
590+ ds_name,
591+ "LogGroup" . to_string ( ) ,
592+ include_format. then ( || "Plain" . to_string ( ) ) ,
593+ ) ;
594+ * counts. entry ( key) . or_insert ( 0 ) += 1 ;
595+ }
596+
597+ let all: Vec < ( DataSourceGroupKey , i64 ) > = counts. into_iter ( ) . collect ( ) ;
598+
599+ // Opaque integer offset token. An unresolvable/garbage token ends the
600+ // listing (empty page, no token) rather than restarting at offset 0,
601+ // which would loop a client that resumes while a token is present.
602+ let start = match next_token {
603+ Some ( t) => t. parse :: < usize > ( ) . unwrap_or ( usize:: MAX ) ,
604+ None => 0 ,
605+ }
606+ . min ( all. len ( ) ) ;
607+ let end = ( start + limit) . min ( all. len ( ) ) ;
608+
609+ let summaries: Vec < Value > = all[ start..end]
610+ . iter ( )
611+ . map ( |( ( ds_name, ds_type, ds_format) , count) | {
612+ let mut ids = vec ! [
613+ json!( { "key" : "dataSource.Name" , "value" : ds_name } ) ,
614+ json!( { "key" : "dataSource.Type" , "value" : ds_type } ) ,
615+ ] ;
616+ if let Some ( fmt) = ds_format {
617+ ids. push ( json ! ( { "key" : "dataSource.Format" , "value" : fmt } ) ) ;
618+ }
619+ json ! ( {
620+ "logGroupCount" : count,
621+ "groupingIdentifiers" : ids,
622+ } )
623+ } )
624+ . collect ( ) ;
625+
626+ let mut result = json ! ( { "aggregateLogGroupSummaries" : summaries } ) ;
627+ if end < all. len ( ) {
628+ result[ "nextToken" ] = json ! ( end. to_string( ) ) ;
629+ }
630+
537631 Ok ( AwsResponse :: json (
538632 StatusCode :: OK ,
539- serde_json:: to_string ( & json ! ( { "aggregateLogGroupSummaries" : [ ] } ) ) . unwrap ( ) ,
633+ serde_json:: to_string ( & result ) . unwrap ( ) ,
540634 ) )
541635 }
542636
@@ -601,7 +695,14 @@ impl LogsService {
601695 json ! ( {
602696 "logGroupName" : g. name,
603697 "logGroupArn" : log_group_arn,
604- "logGroupClass" : "STANDARD" ,
698+ // Render the group's actual stored class, matching
699+ // DescribeLogGroups. CreateLogGroup persists
700+ // INFREQUENT_ACCESS / DELIVERY, so hardcoding STANDARD
701+ // reported the wrong class for those groups.
702+ "logGroupClass" : g
703+ . log_group_class
704+ . as_deref( )
705+ . unwrap_or( "STANDARD" ) ,
605706 } )
606707 } )
607708 . collect ( ) ;
@@ -900,4 +1001,167 @@ mod tests {
9001001 let req = make_request ( "ListAggregateLogGroupSummaries" , json ! ( { } ) ) ;
9011002 assert ! ( svc. list_aggregate_log_group_summaries( & req) . is_err( ) ) ;
9021003 }
1004+
1005+ fn create_group_with_class ( svc : & crate :: LogsService , name : & str , class : & str ) {
1006+ let req = make_request (
1007+ "CreateLogGroup" ,
1008+ json ! ( { "logGroupName" : name, "logGroupClass" : class } ) ,
1009+ ) ;
1010+ svc. create_log_group ( & req) . unwrap ( ) ;
1011+ }
1012+
1013+ #[ test]
1014+ fn list_log_groups_reports_stored_class ( ) {
1015+ let svc = make_service ( ) ;
1016+ create_group_with_class ( & svc, "/infrequent" , "INFREQUENT_ACCESS" ) ;
1017+ create_group ( & svc, "/standard" ) ;
1018+
1019+ let req = make_request ( "ListLogGroups" , json ! ( { } ) ) ;
1020+ let resp = svc. list_log_groups ( & req) . unwrap ( ) ;
1021+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1022+ let by_name: std:: collections:: HashMap < & str , & str > = body[ "logGroups" ]
1023+ . as_array ( )
1024+ . unwrap ( )
1025+ . iter ( )
1026+ . map ( |g| {
1027+ (
1028+ g[ "logGroupName" ] . as_str ( ) . unwrap ( ) ,
1029+ g[ "logGroupClass" ] . as_str ( ) . unwrap ( ) ,
1030+ )
1031+ } )
1032+ . collect ( ) ;
1033+ assert_eq ! ( by_name[ "/infrequent" ] , "INFREQUENT_ACCESS" ) ;
1034+ assert_eq ! ( by_name[ "/standard" ] , "STANDARD" ) ;
1035+ }
1036+
1037+ #[ test]
1038+ fn list_aggregate_log_group_summaries_aggregates_created_groups ( ) {
1039+ let svc = make_service ( ) ;
1040+ create_group ( & svc, "/aws/lambda/fn-a" ) ;
1041+ create_group ( & svc, "/aws/lambda/fn-b" ) ;
1042+ create_group ( & svc, "/aws/vpc/flowlogs" ) ;
1043+ create_group ( & svc, "myapp/web" ) ;
1044+
1045+ let req = make_request (
1046+ "ListAggregateLogGroupSummaries" ,
1047+ json ! ( { "groupBy" : "DATA_SOURCE_NAME_AND_TYPE" } ) ,
1048+ ) ;
1049+ let resp = svc. list_aggregate_log_group_summaries ( & req) . unwrap ( ) ;
1050+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1051+ let summaries = body[ "aggregateLogGroupSummaries" ] . as_array ( ) . unwrap ( ) ;
1052+
1053+ // Three data sources: /aws/lambda (2), /aws/vpc (1), myapp (1).
1054+ assert_eq ! ( summaries. len( ) , 3 ) ;
1055+ let total: i64 = summaries
1056+ . iter ( )
1057+ . map ( |s| s[ "logGroupCount" ] . as_i64 ( ) . unwrap ( ) )
1058+ . sum ( ) ;
1059+ assert_eq ! ( total, 4 ) ;
1060+
1061+ let find = |name : & str | -> i64 {
1062+ summaries
1063+ . iter ( )
1064+ . find ( |s| {
1065+ s[ "groupingIdentifiers" ]
1066+ . as_array ( )
1067+ . unwrap ( )
1068+ . iter ( )
1069+ . any ( |id| id[ "key" ] == "dataSource.Name" && id[ "value" ] == name)
1070+ } )
1071+ . map ( |s| s[ "logGroupCount" ] . as_i64 ( ) . unwrap ( ) )
1072+ . unwrap_or ( 0 )
1073+ } ;
1074+ assert_eq ! ( find( "/aws/lambda" ) , 2 ) ;
1075+ assert_eq ! ( find( "/aws/vpc" ) , 1 ) ;
1076+ assert_eq ! ( find( "myapp" ) , 1 ) ;
1077+
1078+ // NAME_AND_TYPE must not emit a Format identifier.
1079+ assert ! ( !summaries[ 0 ] [ "groupingIdentifiers" ]
1080+ . as_array( )
1081+ . unwrap( )
1082+ . iter( )
1083+ . any( |id| id[ "key" ] == "dataSource.Format" ) ) ;
1084+ }
1085+
1086+ #[ test]
1087+ fn list_aggregate_log_group_summaries_format_and_class_filter ( ) {
1088+ let svc = make_service ( ) ;
1089+ create_group_with_class ( & svc, "/aws/lambda/fn" , "INFREQUENT_ACCESS" ) ;
1090+ create_group ( & svc, "/aws/lambda/other" ) ; // STANDARD
1091+
1092+ // Filter to INFREQUENT_ACCESS only -> one group.
1093+ let req = make_request (
1094+ "ListAggregateLogGroupSummaries" ,
1095+ json ! ( {
1096+ "groupBy" : "DATA_SOURCE_NAME_TYPE_AND_FORMAT" ,
1097+ "logGroupClass" : "INFREQUENT_ACCESS" ,
1098+ } ) ,
1099+ ) ;
1100+ let resp = svc. list_aggregate_log_group_summaries ( & req) . unwrap ( ) ;
1101+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1102+ let summaries = body[ "aggregateLogGroupSummaries" ] . as_array ( ) . unwrap ( ) ;
1103+ assert_eq ! ( summaries. len( ) , 1 ) ;
1104+ assert_eq ! ( summaries[ 0 ] [ "logGroupCount" ] , json!( 1 ) ) ;
1105+ // FORMAT groupBy emits a dataSource.Format identifier.
1106+ assert ! ( summaries[ 0 ] [ "groupingIdentifiers" ]
1107+ . as_array( )
1108+ . unwrap( )
1109+ . iter( )
1110+ . any( |id| id[ "key" ] == "dataSource.Format" ) ) ;
1111+ }
1112+
1113+ #[ test]
1114+ fn list_aggregate_log_group_summaries_paginates ( ) {
1115+ let svc = make_service ( ) ;
1116+ create_group ( & svc, "/aws/a/x" ) ;
1117+ create_group ( & svc, "/aws/b/x" ) ;
1118+ create_group ( & svc, "/aws/c/x" ) ;
1119+
1120+ let req = make_request (
1121+ "ListAggregateLogGroupSummaries" ,
1122+ json ! ( { "groupBy" : "DATA_SOURCE_NAME_AND_TYPE" , "limit" : 2 } ) ,
1123+ ) ;
1124+ let resp = svc. list_aggregate_log_group_summaries ( & req) . unwrap ( ) ;
1125+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1126+ assert_eq ! (
1127+ body[ "aggregateLogGroupSummaries" ] . as_array( ) . unwrap( ) . len( ) ,
1128+ 2
1129+ ) ;
1130+ let token = body[ "nextToken" ] . as_str ( ) . expect ( "nextToken on page 1" ) ;
1131+
1132+ let req2 = make_request (
1133+ "ListAggregateLogGroupSummaries" ,
1134+ json ! ( { "groupBy" : "DATA_SOURCE_NAME_AND_TYPE" , "limit" : 2 , "nextToken" : token } ) ,
1135+ ) ;
1136+ let resp2 = svc. list_aggregate_log_group_summaries ( & req2) . unwrap ( ) ;
1137+ let body2: Value = serde_json:: from_slice ( resp2. body . expect_bytes ( ) ) . unwrap ( ) ;
1138+ assert_eq ! (
1139+ body2[ "aggregateLogGroupSummaries" ]
1140+ . as_array( )
1141+ . unwrap( )
1142+ . len( ) ,
1143+ 1
1144+ ) ;
1145+ assert ! ( body2[ "nextToken" ] . is_null( ) ) ;
1146+ }
1147+
1148+ #[ test]
1149+ fn list_aggregate_log_group_summaries_garbage_token_ends_listing ( ) {
1150+ let svc = make_service ( ) ;
1151+ create_group ( & svc, "/aws/a/x" ) ;
1152+ create_group ( & svc, "/aws/b/x" ) ;
1153+
1154+ // A non-integer token must end the listing rather than restart page 1.
1155+ let req = make_request (
1156+ "ListAggregateLogGroupSummaries" ,
1157+ json ! ( { "groupBy" : "DATA_SOURCE_NAME_AND_TYPE" , "nextToken" : "not-a-number" } ) ,
1158+ ) ;
1159+ let resp = svc. list_aggregate_log_group_summaries ( & req) . unwrap ( ) ;
1160+ let body: Value = serde_json:: from_slice ( resp. body . expect_bytes ( ) ) . unwrap ( ) ;
1161+ assert_eq ! (
1162+ body[ "aggregateLogGroupSummaries" ] . as_array( ) . unwrap( ) . len( ) ,
1163+ 0
1164+ ) ;
1165+ assert ! ( body[ "nextToken" ] . is_null( ) ) ;
1166+ }
9031167}
0 commit comments