diff --git a/crates/fakecloud-bedrock-agent-runtime/src/service.rs b/crates/fakecloud-bedrock-agent-runtime/src/service.rs index 884f0611d..a11acdbc9 100644 --- a/crates/fakecloud-bedrock-agent-runtime/src/service.rs +++ b/crates/fakecloud-bedrock-agent-runtime/src/service.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use async_trait::async_trait; use chrono::Utc; @@ -355,49 +355,79 @@ fn validate_str( } } -// Cached regexes for the most common patterns -fn re_session_identifier() -> Regex { - Regex::new(r"^(arn:aws(-[^:]+)?:bedrock:[a-z0-9-]+:[0-9]{12}:session/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})|([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$").unwrap() +// Identifier patterns, compiled once on first use. They mirror the Smithy +// model's patterns byte-for-byte (ECMA semantics), so keep them verbatim. +fn re_session_identifier() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(arn:aws(-[^:]+)?:bedrock:[a-z0-9-]+:[0-9]{12}:session/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})|([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$").unwrap() + }); + &RE } -fn re_agent_id() -> Regex { - Regex::new(r"^[0-9a-zA-Z]+$").unwrap() +fn re_agent_id() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z]+$").unwrap()); + &RE } -fn re_session_id() -> Regex { - Regex::new(r"^[0-9a-zA-Z._:-]+$").unwrap() +fn re_session_id() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z._:-]+$").unwrap()); + &RE } -fn re_uuid() -> Regex { - Regex::new(r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$").unwrap() +fn re_uuid() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$").unwrap() + }); + &RE } -fn re_memory_id() -> Regex { - Regex::new(r"^[0-9a-zA-Z._:-]+$").unwrap() +fn re_memory_id() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z._:-]+$").unwrap()); + &RE } -fn re_flow_execution_id() -> Regex { +fn re_flow_execution_id() -> &'static Regex { // FlowExecutionIdentifier: max 2048, no pattern in model - Regex::new(r"^[\x21-\x7e]+$").unwrap() + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^[\x21-\x7e]+$").unwrap()); + &RE } -fn re_taggable_arn() -> Regex { +fn re_taggable_arn() -> &'static Regex { // TaggableResourcesArn: lenient; reject literal {placeholder} - Regex::new(r"^arn:[a-zA-Z0-9-]+:[a-zA-Z0-9-]+:[a-z0-9-]*:[0-9]{12}:.+$").unwrap() + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^arn:[a-zA-Z0-9-]+:[a-zA-Z0-9-]+:[a-z0-9-]*:[0-9]{12}:.+$").unwrap() + }); + &RE } -fn re_flow_identifier() -> Regex { - Regex::new( - r"^(arn:aws:bedrock:[a-z0-9-]{1,20}:[0-9]{12}:flow/[0-9a-zA-Z]{10})|([0-9a-zA-Z]{10})$", - ) - .unwrap() +fn re_flow_identifier() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"^(arn:aws:bedrock:[a-z0-9-]{1,20}:[0-9]{12}:flow/[0-9a-zA-Z]{10})|([0-9a-zA-Z]{10})$", + ) + .unwrap() + }); + &RE } -fn re_flow_alias_identifier() -> Regex { - Regex::new(r"^(arn:aws:bedrock:[a-z0-9-]{1,20}:[0-9]{12}:flow/[0-9a-zA-Z]{10}/alias/[0-9a-zA-Z]{10})|(\bTSTALIASID\b|[0-9a-zA-Z]+)$").unwrap() +fn re_flow_alias_identifier() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(arn:aws:bedrock:[a-z0-9-]{1,20}:[0-9]{12}:flow/[0-9a-zA-Z]{10}/alias/[0-9a-zA-Z]{10})|(\bTSTALIASID\b|[0-9a-zA-Z]+)$").unwrap() + }); + &RE } -fn re_no_whitespace() -> Regex { +fn re_no_whitespace() -> &'static Regex { // NextToken: ^\S*$ - Regex::new(r"^\S*$").unwrap() + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^\S*$").unwrap()); + &RE } -fn re_aws_arn() -> Regex { +fn re_aws_arn() -> &'static Regex { // Generic AWS resource ARN - Regex::new(r"^arn:aws(-[^:]+)?:[a-zA-Z0-9-]+:[a-z0-9-]*:[0-9]{12}:.+$").unwrap() + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^arn:aws(-[^:]+)?:[a-zA-Z0-9-]+:[a-z0-9-]*:[0-9]{12}:.+$").unwrap() + }); + &RE +} +fn re_flow_execution_name() -> &'static Regex { + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9-]+$").unwrap()); + &RE } -fn re_flow_execution_name() -> Regex { - Regex::new(r"^[a-zA-Z0-9-]+$").unwrap() +fn re_knowledge_base_id() -> &'static Regex { + // KnowledgeBaseId: alphanumeric; the length bound is checked separately. + static RE: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z]+$").unwrap()); + &RE } /// Validate optional integer field against a range. @@ -429,7 +459,7 @@ fn validate_next_token(req: &AwsRequest, body: &Value) -> Result<(), AwsServiceE validate_str( Some(&t), "nextToken", - Some(&re_no_whitespace()), + Some(re_no_whitespace()), Some(1), Some(2048), false, @@ -539,7 +569,7 @@ async fn handle_invoke_agent( validate_str( agent_id.as_deref(), "agentId", - Some(&re_agent_id()), + Some(re_agent_id()), Some(1), Some(10), true, @@ -547,7 +577,7 @@ async fn handle_invoke_agent( validate_str( agent_alias_id.as_deref(), "agentAliasId", - Some(&re_agent_id()), + Some(re_agent_id()), Some(1), Some(10), true, @@ -555,7 +585,7 @@ async fn handle_invoke_agent( validate_str( session_id.as_deref(), "sessionId", - Some(&re_session_id()), + Some(re_session_id()), Some(2), Some(100), true, @@ -565,7 +595,7 @@ async fn handle_invoke_agent( validate_str( Some(m), "memoryId", - Some(&re_memory_id()), + Some(re_memory_id()), Some(2), Some(100), false, @@ -582,7 +612,7 @@ async fn handle_invoke_agent( validate_str( Some(s), "sourceArn", - Some(&re_aws_arn()), + Some(re_aws_arn()), Some(1), Some(2048), false, @@ -653,7 +683,7 @@ async fn handle_invoke_flow( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -661,7 +691,7 @@ async fn handle_invoke_flow( validate_str( flow_alias_id.as_deref(), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), true, @@ -675,7 +705,7 @@ async fn handle_invoke_flow( validate_str( Some(s), "executionId", - Some(&re_session_id()), + Some(re_session_id()), Some(2), Some(100), false, @@ -749,7 +779,7 @@ async fn handle_invoke_inline_agent( validate_str( session_id.as_deref(), "sessionId", - Some(&re_session_id()), + Some(re_session_id()), Some(2), Some(100), true, @@ -879,7 +909,7 @@ async fn handle_retrieve( validate_str( kb_id.as_deref(), "knowledgeBaseId", - Some(&Regex::new(r"^[0-9a-zA-Z]+$").unwrap()), + Some(re_knowledge_base_id()), Some(0), Some(10), true, @@ -950,7 +980,7 @@ async fn handle_retrieve_and_generate( validate_str( Some(s), "sessionId", - Some(&re_session_id()), + Some(re_session_id()), Some(2), Some(100), false, @@ -1021,7 +1051,7 @@ async fn handle_retrieve_and_generate_stream( validate_str( Some(s), "sessionId", - Some(&re_session_id()), + Some(re_session_id()), Some(2), Some(100), false, @@ -1156,7 +1186,7 @@ async fn handle_delete_session( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1194,7 +1224,7 @@ async fn handle_end_session( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1242,7 +1272,7 @@ async fn handle_get_session( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1318,7 +1348,7 @@ async fn handle_update_session( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1372,7 +1402,7 @@ async fn handle_create_invocation( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1442,7 +1472,7 @@ async fn handle_get_invocation_step( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1451,7 +1481,7 @@ async fn handle_get_invocation_step( validate_str( step_id.as_deref(), "invocationStepId", - Some(&re_uuid()), + Some(re_uuid()), None, None, true, @@ -1460,7 +1490,7 @@ async fn handle_get_invocation_step( validate_str( invocation_id.as_deref(), "invocationIdentifier", - Some(&re_uuid()), + Some(re_uuid()), None, None, true, @@ -1503,7 +1533,7 @@ async fn handle_list_invocation_steps( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1564,7 +1594,7 @@ async fn handle_list_invocations( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1611,7 +1641,7 @@ async fn handle_put_invocation_step( validate_str( session_ident.as_deref(), "sessionIdentifier", - Some(&re_session_identifier()), + Some(re_session_identifier()), None, None, true, @@ -1620,7 +1650,7 @@ async fn handle_put_invocation_step( validate_str( invocation_id.as_deref(), "invocationIdentifier", - Some(&re_uuid()), + Some(re_uuid()), None, None, true, @@ -1695,7 +1725,7 @@ async fn handle_get_flow_execution( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -1703,7 +1733,7 @@ async fn handle_get_flow_execution( validate_str( flow_alias_id.as_deref(), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), true, @@ -1711,7 +1741,7 @@ async fn handle_get_flow_execution( validate_str( exec_id.as_deref(), "executionIdentifier", - Some(&re_flow_execution_id()), + Some(re_flow_execution_id()), None, Some(2048), true, @@ -1761,7 +1791,7 @@ async fn handle_list_flow_execution_events( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -1769,7 +1799,7 @@ async fn handle_list_flow_execution_events( validate_str( flow_alias_id.as_deref(), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), true, @@ -1777,7 +1807,7 @@ async fn handle_list_flow_execution_events( validate_str( exec_id.as_deref(), "executionIdentifier", - Some(&re_flow_execution_id()), + Some(re_flow_execution_id()), None, Some(2048), true, @@ -1809,7 +1839,7 @@ async fn handle_list_flow_executions( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -1819,7 +1849,7 @@ async fn handle_list_flow_executions( validate_str( Some(a), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), false, @@ -1868,7 +1898,7 @@ async fn handle_start_flow_execution( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -1876,7 +1906,7 @@ async fn handle_start_flow_execution( validate_str( flow_alias_id.as_deref(), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), true, @@ -1889,7 +1919,7 @@ async fn handle_start_flow_execution( validate_str( Some(n), "flowExecutionName", - Some(&re_flow_execution_name()), + Some(re_flow_execution_name()), Some(1), Some(36), false, @@ -1935,7 +1965,7 @@ async fn handle_stop_flow_execution( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -1943,7 +1973,7 @@ async fn handle_stop_flow_execution( validate_str( flow_alias_id.as_deref(), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), true, @@ -1951,7 +1981,7 @@ async fn handle_stop_flow_execution( validate_str( exec_id.as_deref(), "executionIdentifier", - Some(&re_flow_execution_id()), + Some(re_flow_execution_id()), None, Some(2048), true, @@ -1997,7 +2027,7 @@ async fn handle_get_execution_flow_snapshot( validate_str( flow_id.as_deref(), "flowIdentifier", - Some(&re_flow_identifier()), + Some(re_flow_identifier()), Some(1), Some(2048), true, @@ -2005,7 +2035,7 @@ async fn handle_get_execution_flow_snapshot( validate_str( flow_alias_id.as_deref(), "flowAliasIdentifier", - Some(&re_flow_alias_identifier()), + Some(re_flow_alias_identifier()), Some(1), Some(2048), true, @@ -2013,7 +2043,7 @@ async fn handle_get_execution_flow_snapshot( validate_str( exec_id.as_deref(), "executionIdentifier", - Some(&re_flow_execution_id()), + Some(re_flow_execution_id()), None, Some(2048), true, @@ -2128,7 +2158,7 @@ async fn handle_delete_agent_memory( validate_str( agent_id.as_deref(), "agentId", - Some(&re_agent_id()), + Some(re_agent_id()), Some(1), Some(10), true, @@ -2136,7 +2166,7 @@ async fn handle_delete_agent_memory( validate_str( agent_alias_id.as_deref(), "agentAliasId", - Some(&re_agent_id()), + Some(re_agent_id()), Some(1), Some(10), true, @@ -2155,7 +2185,7 @@ async fn handle_delete_agent_memory( validate_str( Some(m), "memoryId", - Some(&re_memory_id()), + Some(re_memory_id()), Some(2), Some(100), false, @@ -2165,7 +2195,7 @@ async fn handle_delete_agent_memory( validate_str( Some(s), "sessionId", - Some(&re_session_id()), + Some(re_session_id()), Some(2), Some(100), false, @@ -2185,7 +2215,7 @@ async fn handle_get_agent_memory( validate_str( agent_id.as_deref(), "agentId", - Some(&re_agent_id()), + Some(re_agent_id()), Some(1), Some(10), true, @@ -2193,7 +2223,7 @@ async fn handle_get_agent_memory( validate_str( agent_alias_id.as_deref(), "agentAliasId", - Some(&re_agent_id()), + Some(re_agent_id()), Some(1), Some(10), true, @@ -2213,7 +2243,7 @@ async fn handle_get_agent_memory( validate_str( Some(&memory_id), "memoryId", - Some(&re_memory_id()), + Some(re_memory_id()), Some(2), Some(100), true, @@ -2235,7 +2265,7 @@ async fn handle_tag_resource( validate_str( resource_arn.as_deref(), "resourceArn", - Some(&re_taggable_arn()), + Some(re_taggable_arn()), Some(1), Some(1011), true, @@ -2268,7 +2298,7 @@ async fn handle_untag_resource( validate_str( resource_arn.as_deref(), "resourceArn", - Some(&re_taggable_arn()), + Some(re_taggable_arn()), Some(1), Some(1011), true, @@ -2336,7 +2366,7 @@ async fn handle_list_tags_for_resource( validate_str( resource_arn.as_deref(), "resourceArn", - Some(&re_taggable_arn()), + Some(re_taggable_arn()), Some(1), Some(1011), true, diff --git a/crates/fakecloud-eventbridge/src/helpers.rs b/crates/fakecloud-eventbridge/src/helpers.rs index 3cf550145..fa9e4890e 100644 --- a/crates/fakecloud-eventbridge/src/helpers.rs +++ b/crates/fakecloud-eventbridge/src/helpers.rs @@ -1123,7 +1123,7 @@ pub(crate) fn deliver_to_logs( .entry(group_name.to_string()) .or_insert_with(|| fakecloud_logs::LogGroup { name: group_name.to_string(), - arn: fakecloud_logs::log_group_arn(®ion, &account_id, group_name), + arn: fakecloud_logs::log_group_stored_arn(®ion, &account_id, group_name), creation_time: ts_millis, retention_in_days: None, kms_key_id: None, @@ -1145,6 +1145,7 @@ pub(crate) fn deliver_to_logs( return; } + let stream_arn = group.stream_arn("events"); let stream = group .log_streams .entry(stream_name.clone()) @@ -1152,7 +1153,7 @@ pub(crate) fn deliver_to_logs( persistence_id: uuid::Uuid::new_v4().to_string(), last_sequence: 0, name: stream_name, - arn: format!("{}:log-stream:events", group.arn), + arn: stream_arn, creation_time: ts_millis, first_event_timestamp: None, last_event_timestamp: None, @@ -1577,5 +1578,15 @@ mod logs_persist_tests { "group ARN must carry the target region+account: {}", group.arn ); + // Stored in the same `:*` form CreateLogGroup stores, and the stream + // ARN hangs off the suffix-less group ARN. + assert_eq!( + group.arn, + "arn:aws:logs:eu-central-1:999999999999:log-group:/eb/xacct:*" + ); + assert_eq!( + group.log_streams["events"].arn, + "arn:aws:logs:eu-central-1:999999999999:log-group:/eb/xacct:log-stream:events" + ); } } diff --git a/crates/fakecloud-eventbridge/src/service_archives_replays.rs b/crates/fakecloud-eventbridge/src/service_archives_replays.rs index 2e559e046..36d2c9217 100644 --- a/crates/fakecloud-eventbridge/src/service_archives_replays.rs +++ b/crates/fakecloud-eventbridge/src/service_archives_replays.rs @@ -89,7 +89,7 @@ impl EventBridgeService { // Create the archive rule let rule_name = format!("Events-Archive-{name}"); - let rule_arn = rule_arn(&req.region, &state.account_id, "default", &rule_name); + let rule_arn = rule_arn(&req.region, &state.account_id, &bus_name, &rule_name); // Merge archive event pattern with replay-name filter let rule_event_pattern = { let mut merged = if let Some(ref ep) = event_pattern { diff --git a/crates/fakecloud-eventbridge/src/service_tests.rs b/crates/fakecloud-eventbridge/src/service_tests.rs index 1f138ec50..3bdf660da 100644 --- a/crates/fakecloud-eventbridge/src/service_tests.rs +++ b/crates/fakecloud-eventbridge/src/service_tests.rs @@ -3563,6 +3563,47 @@ fn create_archive_missing_source_arn_errors() { assert!(svc.create_archive(&req).is_err()); } +#[test] +fn archive_rule_on_custom_bus_carries_bus_in_arn() { + let svc = make_service(); + create_event_bus(&svc, "orders"); + svc.create_archive(&make_request( + "CreateArchive", + json!({ + "ArchiveName": "orders-arc", + "EventSourceArn": "arn:aws:events:us-east-1:123456789012:event-bus/orders" + }), + )) + .unwrap(); + create_archive(&svc, "default-arc"); + + let resp = svc + .describe_rule(&make_request( + "DescribeRule", + json!({"Name": "Events-Archive-orders-arc", "EventBusName": "orders"}), + )) + .unwrap(); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!( + body["Arn"], + "arn:aws:events:us-east-1:123456789012:rule/orders/Events-Archive-orders-arc" + ); + assert_eq!(body["EventBusName"], "orders"); + + // An archive on the default bus keeps the bus-less rule ARN. + let resp = svc + .describe_rule(&make_request( + "DescribeRule", + json!({"Name": "Events-Archive-default-arc"}), + )) + .unwrap(); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!( + body["Arn"], + "arn:aws:events:us-east-1:123456789012:rule/Events-Archive-default-arc" + ); +} + #[test] fn delete_archive_missing_errors() { let svc = make_service(); diff --git a/crates/fakecloud-logs/src/ingest.rs b/crates/fakecloud-logs/src/ingest.rs index 7dc6b2694..be4e980db 100644 --- a/crates/fakecloud-logs/src/ingest.rs +++ b/crates/fakecloud-logs/src/ingest.rs @@ -41,7 +41,7 @@ pub fn append_events( .entry(group_name.to_string()) .or_insert_with(|| LogGroup { name: group_name.to_string(), - arn: crate::state::log_group_arn(region, account_id, group_name), + arn: crate::state::log_group_stored_arn(region, account_id, group_name), creation_time: now, retention_in_days: None, kms_key_id: None, @@ -55,6 +55,7 @@ pub fn append_events( deletion_protection: false, log_group_class: Some("STANDARD".into()), }); + let stream_arn = group.stream_arn(stream_name); let stream = group .log_streams .entry(stream_name.to_string()) @@ -62,7 +63,7 @@ pub fn append_events( persistence_id: uuid::Uuid::new_v4().to_string(), last_sequence: 0, name: stream_name.to_string(), - arn: format!("{}:log-stream:{}", group.arn, stream_name), + arn: stream_arn, creation_time: now, first_event_timestamp: None, last_event_timestamp: None, diff --git a/crates/fakecloud-logs/src/lib.rs b/crates/fakecloud-logs/src/lib.rs index d5c644bf0..12483a317 100644 --- a/crates/fakecloud-logs/src/lib.rs +++ b/crates/fakecloud-logs/src/lib.rs @@ -8,9 +8,9 @@ pub(crate) mod validation; pub use service::{infer_delivery_destination_type, save_logs_state, LogsService}; pub use state::{ - log_group_arn, Delivery, DeliveryDestination, DeliverySource, Destination, LogAnomaly, - LogEvent, LogGroup, LogStream, LogsSnapshot, LogsState, MetricFilter, MetricTransformation, - QueryDefinition, ResourcePolicy, SharedLogsState, SubscriptionFilter, + log_group_arn, log_group_stored_arn, Delivery, DeliveryDestination, DeliverySource, + Destination, LogAnomaly, LogEvent, LogGroup, LogStream, LogsSnapshot, LogsState, MetricFilter, + MetricTransformation, QueryDefinition, ResourcePolicy, SharedLogsState, SubscriptionFilter, LOGS_SNAPSHOT_SCHEMA_VERSION, VALID_RETENTION_DAYS, }; diff --git a/crates/fakecloud-logs/src/service/groups.rs b/crates/fakecloud-logs/src/service/groups.rs index 406b158c1..2b7fa15e5 100644 --- a/crates/fakecloud-logs/src/service/groups.rs +++ b/crates/fakecloud-logs/src/service/groups.rs @@ -66,10 +66,7 @@ impl LogsService { )); } - let arn = format!( - "{}:*", - crate::state::log_group_arn(&req.region, &state.account_id, &name) - ); + let arn = crate::state::log_group_stored_arn(&req.region, &state.account_id, &name); let now = Utc::now().timestamp_millis(); let tags = body["tags"] @@ -208,7 +205,7 @@ impl LogsService { let log_groups: Vec = page .iter() .map(|g| { - let log_group_arn = g.arn.trim_end_matches(":*").to_string(); + let log_group_arn = g.log_group_arn(); let metric_filter_count = state .metric_filters .iter() @@ -216,7 +213,7 @@ impl LogsService { .count(); let mut obj = json!({ "logGroupName": g.name, - "arn": g.arn, + "arn": g.wildcard_arn(), "logGroupArn": log_group_arn, "creationTime": g.creation_time, "storedBytes": g.stored_bytes, @@ -706,7 +703,7 @@ impl LogsService { let log_groups: Vec = page .iter() .map(|g| { - let log_group_arn = g.arn.trim_end_matches(":*").to_string(); + let log_group_arn = g.log_group_arn(); json!({ "logGroupName": g.name, "logGroupArn": log_group_arn, @@ -764,6 +761,75 @@ mod tests { assert!(names.contains(&"/app/api")); } + #[test] + fn created_and_implicit_log_groups_describe_identically() { + let svc = make_service(); + create_group(&svc, "explicit"); + let event = [crate::ingest::IngestEvent { + timestamp_ms: chrono::Utc::now().timestamp_millis(), + message: "m".into(), + }]; + // Ingest into the created group, and into a group ingest creates. + for group in ["explicit", "implicit"] { + crate::ingest::append_events( + &svc.state, + "123456789012", + "us-east-1", + group, + "s", + &event, + ); + } + + let resp = svc + .describe_log_groups(&make_request("DescribeLogGroups", json!({}))) + .unwrap(); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + let groups = body["logGroups"].as_array().unwrap(); + assert_eq!(groups.len(), 2); + for g in groups { + let name = g["logGroupName"].as_str().unwrap(); + let base = format!("arn:aws:logs:us-east-1:123456789012:log-group:{name}"); + assert_eq!(g["arn"], format!("{base}:*"), "{name}"); + assert_eq!(g["logGroupArn"], base, "{name}"); + + let resp = svc + .describe_log_streams(&make_request( + "DescribeLogStreams", + json!({ "logGroupName": name }), + )) + .unwrap(); + let streams: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!( + streams["logStreams"][0]["arn"], + format!("{base}:log-stream:s"), + "{name}" + ); + } + let strip = |g: &Value| { + let mut g = g.clone(); + let obj = g.as_object_mut().unwrap(); + for k in [ + "logGroupName", + "arn", + "logGroupArn", + "creationTime", + "storedBytes", + ] { + obj.remove(k); + } + g + }; + assert_eq!(strip(&groups[0]), strip(&groups[1])); + + let mas = svc.state.read(); + let state = mas.default_ref(); + assert_eq!( + state.log_groups["implicit"].arn, + "arn:aws:logs:us-east-1:123456789012:log-group:implicit:*" + ); + } + #[test] fn describe_log_groups_pattern_empty_returns_all() { let svc = make_service(); diff --git a/crates/fakecloud-logs/src/service/policies.rs b/crates/fakecloud-logs/src/service/policies.rs index f67f4311a..14cd65843 100644 --- a/crates/fakecloud-logs/src/service/policies.rs +++ b/crates/fakecloud-logs/src/service/policies.rs @@ -566,7 +566,7 @@ impl LogsService { // the index-policy `logGroupIdentifier` reports it without the suffix so // the resource's `log_group_name` round-trips (otherwise the read sees a // `:*`-suffixed identifier and forces replacement). - let log_group_identifier = group.arn.trim_end_matches(":*").to_string(); + let log_group_identifier = group.log_group_arn().to_string(); let result = json!({ "indexPolicy": { "policyName": policy_name, @@ -609,7 +609,7 @@ impl LogsService { id.to_string() }; if let Some(group) = state.log_groups.get(&group_name) { - let log_group_identifier = group.arn.trim_end_matches(":*"); + let log_group_identifier = group.log_group_arn(); for p in &group.index_policies { policies.push(json!({ "policyName": p.policy_name, @@ -744,7 +744,7 @@ impl LogsService { for f in fields { if let Some(name) = f.as_str() { field_indexes.push(json!({ - "logGroupIdentifier": group.arn, + "logGroupIdentifier": group.log_group_arn(), "fieldIndexName": name, "lastScanTime": p.last_updated_time, "firstEventTime": p.last_updated_time, @@ -855,7 +855,7 @@ impl LogsService { })?; let mut result = json!({ - "logGroupIdentifier": group.arn, + "logGroupIdentifier": group.log_group_arn(), }); if let Some(ref t) = group.transformer { result["transformerConfig"] = t.transformer_config.clone(); diff --git a/crates/fakecloud-logs/src/service/streams.rs b/crates/fakecloud-logs/src/service/streams.rs index d7eb9a45e..7796910eb 100644 --- a/crates/fakecloud-logs/src/service/streams.rs +++ b/crates/fakecloud-logs/src/service/streams.rs @@ -546,14 +546,18 @@ impl LogsService { // Collect delivery pipeline info: find active deliveries whose source // resource ARN matches this log group's ARN. - let group_arn = group.arn.clone(); + let group_arn = group.log_group_arn().to_string(); let delivery_targets: Vec = state .deliveries .values() .filter_map(|d| { // Check if the delivery source references this log group if let Some(source) = state.delivery_sources.get(&d.delivery_source_name) { - if source.resource_arns.contains(&group_arn) { + if source + .resource_arns + .iter() + .any(|a| a.strip_suffix(":*").unwrap_or(a) == group_arn) + { // Find the destination's S3 bucket configuration if let Some(dest) = state .delivery_destinations diff --git a/crates/fakecloud-logs/src/service/syslog.rs b/crates/fakecloud-logs/src/service/syslog.rs index ce0238517..edac1f4cb 100644 --- a/crates/fakecloud-logs/src/service/syslog.rs +++ b/crates/fakecloud-logs/src/service/syslog.rs @@ -67,7 +67,7 @@ impl LogsService { format!("The specified log group does not exist: {group_name}"), ) })?; - let log_group_arn = group.arn.clone(); + let log_group_arn = group.log_group_arn().to_string(); let created_at = state .syslog_configurations diff --git a/crates/fakecloud-logs/src/state.rs b/crates/fakecloud-logs/src/state.rs index 059b923a4..1f66373c7 100644 --- a/crates/fakecloud-logs/src/state.rs +++ b/crates/fakecloud-logs/src/state.rs @@ -181,6 +181,15 @@ pub fn log_group_arn(region: &str, account_id: &str, name: &str) -> String { .to_string() } +/// The ARN stored on [`LogGroup::arn`]: [`log_group_arn`] plus the trailing +/// `:*`, the form `DescribeLogGroups` reports on `arn`. Every path that creates +/// a log group (CreateLogGroup, implicit creation on ingest or EventBridge +/// delivery) stores this form so the group describes the same way regardless +/// of how it came to exist. +pub fn log_group_stored_arn(region: &str, account_id: &str, name: &str) -> String { + format!("{}:*", log_group_arn(region, account_id, name)) +} + #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct LogGroup { pub name: String, @@ -545,6 +554,23 @@ impl LogsState { } impl LogGroup { + /// The group's ARN without the trailing `:*` (the `logGroupArn` form, and + /// the prefix of its log streams' ARNs). Tolerates a stored ARN that lacks + /// the suffix, as snapshots from before the stored form was unified do. + pub fn log_group_arn(&self) -> &str { + self.arn.strip_suffix(":*").unwrap_or(&self.arn) + } + + /// The group's ARN with the trailing `:*` (the `DescribeLogGroups` `arn`). + pub fn wildcard_arn(&self) -> String { + format!("{}:*", self.log_group_arn()) + } + + /// The ARN of log stream `stream_name` in this group. + pub fn stream_arn(&self, stream_name: &str) -> String { + format!("{}:log-stream:{stream_name}", self.log_group_arn()) + } + pub(crate) fn metadata(&self) -> Self { Self { name: self.name.clone(), diff --git a/crates/fakecloud-server/src/kinesis_lambda_poller.rs b/crates/fakecloud-server/src/kinesis_lambda_poller.rs index 448dfa5e8..32b868fa6 100644 --- a/crates/fakecloud-server/src/kinesis_lambda_poller.rs +++ b/crates/fakecloud-server/src/kinesis_lambda_poller.rs @@ -212,27 +212,7 @@ impl KinesisLambdaPoller { // count toward batch size and are discarded". let record_jsons: Vec = records .iter() - .map(|record| { - json!({ - "awsRegion": "us-east-1", - "eventID": format!("{}:{}", shard_id, record.sequence_number), - "eventName": "aws:kinesis:record", - "eventSource": "aws:kinesis", - "eventSourceARN": mapping.stream_arn, - "eventVersion": "1.0", - "invokeIdentityArn": format!( - "arn:{}:iam::123456789012:role/lambda-role", - fakecloud_aws::arn::partition_of(&mapping.stream_arn) - ), - "kinesis": { - "approximateArrivalTimestamp": record.approximate_arrival_timestamp.timestamp_millis() as f64 / 1000.0, - "data": base64::engine::general_purpose::STANDARD.encode(&record.data), - "kinesisSchemaVersion": "1.0", - "partitionKey": record.partition_key, - "sequenceNumber": record.sequence_number, - } - }) - }) + .map(|record| kinesis_event_record(record, &shard_id, &mapping.stream_arn)) .collect(); let matched: Vec = if mapping.filter.is_empty() { @@ -336,6 +316,36 @@ impl KinesisLambdaPoller { } } +/// Build the Lambda event record for one Kinesis record. `awsRegion` is the +/// stream's region, taken from its ARN (the region the records were read +/// from), not a fixed default. +fn kinesis_event_record( + record: &fakecloud_kinesis::KinesisRecord, + shard_id: &str, + stream_arn: &str, +) -> Value { + let region = stream_arn.split(':').nth(3).unwrap_or_default(); + json!({ + "awsRegion": region, + "eventID": format!("{}:{}", shard_id, record.sequence_number), + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": stream_arn, + "eventVersion": "1.0", + "invokeIdentityArn": format!( + "arn:{}:iam::123456789012:role/lambda-role", + fakecloud_aws::arn::partition_of(stream_arn) + ), + "kinesis": { + "approximateArrivalTimestamp": record.approximate_arrival_timestamp.timestamp_millis() as f64 / 1000.0, + "data": base64::engine::general_purpose::STANDARD.encode(&record.data), + "kinesisSchemaVersion": "1.0", + "partitionKey": record.partition_key, + "sequenceNumber": record.sequence_number, + } + }) +} + /// Parse the Lambda response body as `{"batchItemFailures":[{"itemIdentifier":""}]}` /// and return the index in `batch_seqs` of the first failed sequence /// number. Returns `None` when the body doesn't decode, the failures @@ -393,6 +403,29 @@ mod tests { assert!(first_failed_index(body, &seqs).is_none()); } + #[test] + fn event_record_carries_stream_region() { + let record = fakecloud_kinesis::KinesisRecord { + sequence_number: "49590338271490256608559692538361571095921575989136588898".into(), + partition_key: "pk".into(), + data: b"hello".to_vec(), + approximate_arrival_timestamp: Utc::now(), + }; + let arn = "arn:aws:kinesis:eu-west-2:111122223333:stream/orders"; + let ev = kinesis_event_record(&record, "shardId-000000000000", arn); + assert_eq!(ev["awsRegion"], "eu-west-2"); + assert_eq!(ev["eventSourceARN"], arn); + assert_eq!( + ev["eventID"], + "shardId-000000000000:49590338271490256608559692538361571095921575989136588898" + ); + assert_eq!(ev["kinesis"]["data"], "aGVsbG8="); + + let cn = "arn:aws-cn:kinesis:cn-north-1:111122223333:stream/orders"; + let ev = kinesis_event_record(&record, "shardId-000000000000", cn); + assert_eq!(ev["awsRegion"], "cn-north-1"); + } + #[test] fn first_failed_index_invalid_json_returns_none() { let body = b"not json";