diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 98e44ee..b84cdef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,13 @@ updates: directory: "/" schedule: interval: "weekly" + groups: + minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/src/resource/field_mapper.rs b/src/resource/field_mapper.rs index e06ee1f..9e519fd 100644 --- a/src/resource/field_mapper.rs +++ b/src/resource/field_mapper.rs @@ -68,10 +68,22 @@ fn apply_transform(value: &Value, transform: &str) -> Value { "array_to_csv" => transform_array_to_csv(value), "first_item" => transform_first_item(value), "private_zone_to_type" => transform_private_zone_to_type(value), + "route53_record_value" => transform_route53_record_value(value), + "route53_record_id" => transform_route53_record_id(value), _ => value.clone(), } } +/// Transform Route53 record to unique ID (Name#Type) +/// This creates a unique identifier since multiple records can have the same name with different types +/// Input: {"Name": "example.com", "Type": "A"} -> "example.com#A" +fn transform_route53_record_id(value: &Value) -> Value { + let name = value.get("Name").and_then(|v| v.as_str()).unwrap_or("-"); + let record_type = value.get("Type").and_then(|v| v.as_str()).unwrap_or("-"); + + Value::String(format!("{}#{}", name, record_type)) +} + /// Transform Route53 PrivateZone boolean to "Public"/"Private" fn transform_private_zone_to_type(value: &Value) -> Value { match value { @@ -84,6 +96,48 @@ fn transform_private_zone_to_type(value: &Value) -> Value { } } +/// Transform Route53 record to value string +/// Handles both ResourceRecords and AliasTarget +/// ResourceRecords: [{"Value": "192.0.2.1"}] -> "192.0.2.1" +/// AliasTarget: {"DNSName": "example.com"} -> "example.com" +fn transform_route53_record_value(value: &Value) -> Value { + // Check for AliasTarget first + if let Some(alias_target) = value.get("AliasTarget") { + let dns_name = alias_target + .get("DNSName") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + return Value::String(dns_name.to_string()); + } + + // Check for ResourceRecords + if let Some(resource_records) = value.get("ResourceRecords") { + if let Some(records) = resource_records.get("ResourceRecord") { + let arr = match records { + Value::Array(a) => a.clone(), + obj @ Value::Object(_) => vec![obj.clone()], + _ => return Value::String("-".to_string()), + }; + + let values: Vec = arr + .iter() + .filter_map(|item| { + item.get("Value") + .or_else(|| item.get("value")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + if !values.is_empty() { + return Value::String(values.join(", ")); + } + } + } + + Value::String("-".to_string()) +} + /// Transform AWS tag array to a key-value map /// /// Input: [{"key": "Name", "value": "MyInstance"}, {"Key": "Env", "Value": "prod"}] @@ -316,4 +370,107 @@ mod tests { assert_eq!(response["instances"].as_array().unwrap().len(), 2); assert_eq!(response["_next_token"], "token123"); } + + #[test] + fn test_transform_route53_record_value_with_single_resource_record() { + let record = json!({ + "ResourceRecords": { + "ResourceRecord": { + "Value": "192.0.2.1" + } + } + }); + + let result = transform_route53_record_value(&record); + assert_eq!(result, json!("192.0.2.1")); + } + + #[test] + fn test_transform_route53_record_value_with_multiple_resource_records() { + let record = json!({ + "ResourceRecords": { + "ResourceRecord": [ + {"Value": "192.0.2.1"}, + {"Value": "192.0.2.2"}, + {"Value": "192.0.2.3"} + ] + } + }); + + let result = transform_route53_record_value(&record); + assert_eq!(result, json!("192.0.2.1, 192.0.2.2, 192.0.2.3")); + } + + #[test] + fn test_transform_route53_record_value_with_alias_target() { + let record = json!({ + "AliasTarget": { + "DNSName": "elb-123.us-east-1.elb.amazonaws.com", + "HostedZoneId": "Z35SXDOTRQ7X7K", + "EvaluateTargetHealth": "false" + } + }); + + let result = transform_route53_record_value(&record); + assert_eq!(result, json!("elb-123.us-east-1.elb.amazonaws.com")); + } + + #[test] + fn test_transform_route53_record_value_with_empty_records() { + let record = json!({ + "ResourceRecords": { + "ResourceRecord": [] + } + }); + + let result = transform_route53_record_value(&record); + assert_eq!(result, json!("-")); + } + + #[test] + fn test_transform_route53_record_value_with_no_value() { + let record = json!({}); + + let result = transform_route53_record_value(&record); + assert_eq!(result, json!("-")); + } + + #[test] + fn test_transform_route53_record_id() { + let record = json!({ + "Name": "example.com.", + "Type": "A" + }); + + let result = transform_route53_record_id(&record); + assert_eq!(result, json!("example.com.#A")); + } + + #[test] + fn test_transform_route53_record_id_with_different_types() { + let a_record = json!({"Name": "example.com.", "Type": "A"}); + let aaaa_record = json!({"Name": "example.com.", "Type": "AAAA"}); + let mx_record = json!({"Name": "example.com.", "Type": "MX"}); + + assert_eq!( + transform_route53_record_id(&a_record), + json!("example.com.#A") + ); + assert_eq!( + transform_route53_record_id(&aaaa_record), + json!("example.com.#AAAA") + ); + assert_eq!( + transform_route53_record_id(&mx_record), + json!("example.com.#MX") + ); + } + + #[test] + fn test_transform_route53_record_id_with_missing_fields() { + let record = json!({}); + + let result = transform_route53_record_id(&record); + assert_eq!(result, json!("-#-")); + } } diff --git a/src/resources/route53.json b/src/resources/route53.json index adbb67b..e78fdf5 100644 --- a/src/resources/route53.json +++ b/src/resources/route53.json @@ -15,7 +15,9 @@ { "header": "TYPE", "json_path": "Config.PrivateZone", "width": 12 }, { "header": "RECORD COUNT", "json_path": "ResourceRecordSetCount", "width": 15 } ], - "sub_resources": [], + "sub_resources": [ + { "shortcut": "r", "display_name": "Records", "resource_key": "route53-records", "parent_id_field": "Id", "filter_param": "hosted_zone_id" } + ], "actions": [], "api_config": { "protocol": "rest-xml", @@ -29,6 +31,38 @@ "ResourceRecordSetCount": { "source": "/ResourceRecordSetCount", "default": "0" }, "Config.PrivateZone": { "source": "/Config/PrivateZone", "transform": "private_zone_to_type" } } + }, + "route53-records": { + "display_name": "Route53 Records", + "service": "route53", + "sdk_method": "list_resource_record_sets", + "sdk_method_params": {}, + "response_path": "resource_record_sets", + "id_field": "RecordId", + "name_field": "Name", + "is_global": true, + "requires_parent": true, + "columns": [ + { "header": "NAME", "json_path": "Name", "width": 50 }, + { "header": "TYPE", "json_path": "Type", "width": 10 }, + { "header": "TTL", "json_path": "TTL", "width": 8 }, + { "header": "VALUE", "json_path": "Value", "width": 60 } + ], + "sub_resources": [], + "actions": [], + "api_config": { + "protocol": "rest-xml", + "method": "GET", + "path": "/2013-04-01{hosted_zone_id}/rrset", + "response_root": "/ListResourceRecordSetsResponse/ResourceRecordSets/ResourceRecordSet" + }, + "field_mappings": { + "RecordId": { "source": "/", "transform": "route53_record_id" }, + "Name": { "source": "/Name", "default": "-" }, + "Type": { "source": "/Type", "default": "-" }, + "TTL": { "source": "/TTL", "default": "-" }, + "Value": { "source": "/", "transform": "route53_record_value" } + } } } }