Skip to main content

awsim_route53/
lib.rs

1mod geo;
2mod operations;
3mod state;
4
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use awsim_core::{
9    AccountRegionStore, AwsError, Protocol, RequestContext, RouteDefinition, ServiceHandler,
10};
11use serde_json::Value;
12use tracing::debug;
13
14use state::Route53State;
15
16/// The AWSim Route53 service handler.
17///
18/// Route53 is a global service — state is stored per account under the "global" region key.
19pub struct Route53Service {
20    store: AccountRegionStore<Route53State>,
21}
22
23impl Route53Service {
24    pub fn new() -> Self {
25        Self {
26            store: AccountRegionStore::new(),
27        }
28    }
29
30    fn get_state(&self, ctx: &RequestContext) -> Arc<Route53State> {
31        // Route53 is global — ignore region.
32        self.store.get(&ctx.account_id, "global")
33    }
34}
35
36impl Default for Route53Service {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42#[async_trait]
43impl ServiceHandler for Route53Service {
44    fn service_name(&self) -> &str {
45        "route53"
46    }
47
48    fn signing_name(&self) -> &str {
49        "route53"
50    }
51
52    fn protocol(&self) -> Protocol {
53        Protocol::RestXml
54    }
55
56    fn routes(&self) -> Vec<RouteDefinition> {
57        vec![
58            // Hosted Zones
59            RouteDefinition {
60                method: "POST",
61                path_pattern: "/2013-04-01/hostedzone",
62                operation: "CreateHostedZone",
63                required_query_param: None,
64            },
65            RouteDefinition {
66                method: "GET",
67                path_pattern: "/2013-04-01/hostedzone",
68                operation: "ListHostedZones",
69                required_query_param: None,
70            },
71            RouteDefinition {
72                method: "GET",
73                path_pattern: "/2013-04-01/hostedzone/{Id}",
74                operation: "GetHostedZone",
75                required_query_param: None,
76            },
77            RouteDefinition {
78                method: "DELETE",
79                path_pattern: "/2013-04-01/hostedzone/{Id}",
80                operation: "DeleteHostedZone",
81                required_query_param: None,
82            },
83            RouteDefinition {
84                method: "GET",
85                path_pattern: "/2013-04-01/hostedzonesbyname",
86                operation: "ListHostedZonesByName",
87                required_query_param: None,
88            },
89            RouteDefinition {
90                method: "GET",
91                path_pattern: "/2013-04-01/hostedzonecount",
92                operation: "GetHostedZoneCount",
93                required_query_param: None,
94            },
95            RouteDefinition {
96                method: "GET",
97                path_pattern: "/2013-04-01/hostedzonesbyvpc",
98                operation: "ListHostedZonesByVPC",
99                required_query_param: None,
100            },
101            // DNSSEC
102            RouteDefinition {
103                method: "GET",
104                path_pattern: "/2013-04-01/hostedzone/{Id}/dnssec",
105                operation: "GetDNSSEC",
106                required_query_param: None,
107            },
108            // Record Sets
109            RouteDefinition {
110                method: "POST",
111                path_pattern: "/2013-04-01/hostedzone/{Id}/rrset",
112                operation: "ChangeResourceRecordSets",
113                required_query_param: None,
114            },
115            RouteDefinition {
116                method: "GET",
117                path_pattern: "/2013-04-01/hostedzone/{Id}/rrset",
118                operation: "ListResourceRecordSets",
119                required_query_param: None,
120            },
121            // Health Checks
122            RouteDefinition {
123                method: "POST",
124                path_pattern: "/2013-04-01/healthcheck",
125                operation: "CreateHealthCheck",
126                required_query_param: None,
127            },
128            RouteDefinition {
129                method: "GET",
130                path_pattern: "/2013-04-01/healthcheck",
131                operation: "ListHealthChecks",
132                required_query_param: None,
133            },
134            RouteDefinition {
135                method: "DELETE",
136                path_pattern: "/2013-04-01/healthcheck/{Id}",
137                operation: "DeleteHealthCheck",
138                required_query_param: None,
139            },
140            RouteDefinition {
141                method: "POST",
142                path_pattern: "/2013-04-01/healthcheck/{Id}",
143                operation: "UpdateHealthCheck",
144                required_query_param: None,
145            },
146            RouteDefinition {
147                method: "GET",
148                path_pattern: "/2013-04-01/healthcheckcount",
149                operation: "GetHealthCheckCount",
150                required_query_param: None,
151            },
152            RouteDefinition {
153                method: "GET",
154                path_pattern: "/2013-04-01/healthcheck/{Id}/status",
155                operation: "GetHealthCheckStatus",
156                required_query_param: None,
157            },
158            // DNS testing
159            RouteDefinition {
160                method: "GET",
161                path_pattern: "/2013-04-01/testdnsanswer",
162                operation: "TestDNSAnswer",
163                required_query_param: None,
164            },
165            // Checker IP ranges
166            RouteDefinition {
167                method: "GET",
168                path_pattern: "/2013-04-01/checkeripranges",
169                operation: "GetCheckerIpRanges",
170                required_query_param: None,
171            },
172            // Query Logging
173            RouteDefinition {
174                method: "POST",
175                path_pattern: "/2013-04-01/queryloggingconfig",
176                operation: "CreateQueryLoggingConfig",
177                required_query_param: None,
178            },
179            RouteDefinition {
180                method: "GET",
181                path_pattern: "/2013-04-01/queryloggingconfig",
182                operation: "ListQueryLoggingConfigs",
183                required_query_param: None,
184            },
185            RouteDefinition {
186                method: "DELETE",
187                path_pattern: "/2013-04-01/queryloggingconfig/{Id}",
188                operation: "DeleteQueryLoggingConfig",
189                required_query_param: None,
190            },
191            // Tags
192            RouteDefinition {
193                method: "POST",
194                path_pattern: "/2013-04-01/tags/{ResourceType}/{ResourceId}",
195                operation: "ChangeTagsForResource",
196                required_query_param: None,
197            },
198            RouteDefinition {
199                method: "GET",
200                path_pattern: "/2013-04-01/tags/{ResourceType}/{ResourceId}",
201                operation: "ListTagsForResource",
202                required_query_param: None,
203            },
204            RouteDefinition {
205                method: "POST",
206                path_pattern: "/2013-04-01/tags/{ResourceType}",
207                operation: "ListTagsForResources",
208                required_query_param: None,
209            },
210            RouteDefinition {
211                method: "GET",
212                path_pattern: "/2013-04-01/change/{Id}",
213                operation: "GetChange",
214                required_query_param: None,
215            },
216            RouteDefinition {
217                method: "GET",
218                path_pattern: "/2013-04-01/healthcheck/{HealthCheckId}",
219                operation: "GetHealthCheck",
220                required_query_param: None,
221            },
222            RouteDefinition {
223                method: "GET",
224                path_pattern: "/2013-04-01/queryloggingconfig/{Id}",
225                operation: "GetQueryLoggingConfig",
226                required_query_param: None,
227            },
228            RouteDefinition {
229                method: "GET",
230                path_pattern: "/2013-04-01/geolocation",
231                operation: "GetGeoLocation",
232                required_query_param: None,
233            },
234            RouteDefinition {
235                method: "GET",
236                path_pattern: "/2013-04-01/geolocations",
237                operation: "ListGeoLocations",
238                required_query_param: None,
239            },
240            RouteDefinition {
241                method: "GET",
242                path_pattern: "/2013-04-01/delegationset",
243                operation: "ListReusableDelegationSets",
244                required_query_param: None,
245            },
246            RouteDefinition {
247                method: "POST",
248                path_pattern: "/2013-04-01/delegationset",
249                operation: "CreateReusableDelegationSet",
250                required_query_param: None,
251            },
252            RouteDefinition {
253                method: "POST",
254                path_pattern: "/2013-04-01/trafficpolicy",
255                operation: "CreateTrafficPolicy",
256                required_query_param: None,
257            },
258            RouteDefinition {
259                method: "GET",
260                path_pattern: "/2013-04-01/trafficpolicies",
261                operation: "ListTrafficPolicies",
262                required_query_param: None,
263            },
264            RouteDefinition {
265                method: "GET",
266                path_pattern: "/2013-04-01/trafficpolicy/{Id}/{Version}",
267                operation: "GetTrafficPolicy",
268                required_query_param: None,
269            },
270            RouteDefinition {
271                method: "DELETE",
272                path_pattern: "/2013-04-01/trafficpolicy/{Id}/{Version}",
273                operation: "DeleteTrafficPolicy",
274                required_query_param: None,
275            },
276            RouteDefinition {
277                method: "POST",
278                path_pattern: "/2013-04-01/hostedzone/{Id}/associatevpc",
279                operation: "AssociateVPCWithHostedZone",
280                required_query_param: None,
281            },
282            RouteDefinition {
283                method: "POST",
284                path_pattern: "/2013-04-01/hostedzone/{Id}/disassociatevpc",
285                operation: "DisassociateVPCFromHostedZone",
286                required_query_param: None,
287            },
288        ]
289    }
290
291    async fn handle(
292        &self,
293        operation: &str,
294        input: Value,
295        ctx: &RequestContext,
296    ) -> Result<Value, AwsError> {
297        debug!(operation = %operation, "Route53 operation");
298        let state = self.get_state(ctx);
299
300        match operation {
301            // Hosted Zones
302            "CreateHostedZone" => operations::zones::create_hosted_zone(&state, &input, ctx),
303            "GetHostedZone" => operations::zones::get_hosted_zone(&state, &input, ctx),
304            "ListHostedZones" => operations::zones::list_hosted_zones(&state, &input, ctx),
305            "DeleteHostedZone" => operations::zones::delete_hosted_zone(&state, &input, ctx),
306            "ListHostedZonesByName" => {
307                operations::zones::list_hosted_zones_by_name(&state, &input, ctx)
308            }
309            "GetHostedZoneCount" => operations::extra::get_hosted_zone_count(&state, &input, ctx),
310            "ListHostedZonesByVPC" => {
311                operations::extra::list_hosted_zones_by_vpc(&state, &input, ctx)
312            }
313            "GetDNSSEC" => operations::extra::get_dnssec(&state, &input, ctx),
314
315            // Record Sets
316            "ChangeResourceRecordSets" => {
317                operations::records::change_resource_record_sets(&state, &input, ctx)
318            }
319            "ListResourceRecordSets" => {
320                operations::records::list_resource_record_sets(&state, &input, ctx)
321            }
322
323            // Health Checks
324            "CreateHealthCheck" => {
325                operations::health_checks::create_health_check(&state, &input, ctx)
326            }
327            "ListHealthChecks" => {
328                operations::health_checks::list_health_checks(&state, &input, ctx)
329            }
330            "DeleteHealthCheck" => {
331                operations::health_checks::delete_health_check(&state, &input, ctx)
332            }
333            "GetHealthCheckCount" => {
334                operations::health_checks::get_health_check_count(&state, &input, ctx)
335            }
336            "GetHealthCheckStatus" => {
337                operations::health_checks::get_health_check_status(&state, &input, ctx)
338            }
339            "UpdateHealthCheck" => {
340                operations::health_checks::update_health_check(&state, &input, ctx)
341            }
342
343            // DNS testing
344            "TestDNSAnswer" => operations::extra::test_dns_answer(&state, &input, ctx),
345
346            // Checker IP ranges
347            "GetCheckerIpRanges" => operations::extra::get_checker_ip_ranges(&state, &input, ctx),
348
349            // Query Logging
350            "CreateQueryLoggingConfig" => {
351                operations::extra::create_query_logging_config(&state, &input, ctx)
352            }
353            "DeleteQueryLoggingConfig" => {
354                operations::extra::delete_query_logging_config(&state, &input, ctx)
355            }
356            "ListQueryLoggingConfigs" => {
357                operations::extra::list_query_logging_configs(&state, &input, ctx)
358            }
359
360            // Tags
361            "ChangeTagsForResource" => {
362                operations::tags::change_tags_for_resource(&state, &input, ctx)
363            }
364            "ListTagsForResource" => operations::tags::list_tags_for_resource(&state, &input, ctx),
365            "ListTagsForResources" => {
366                operations::more::list_tags_for_resources(&state, &input, ctx)
367            }
368
369            "GetChange" => operations::more::get_change(&state, &input, ctx),
370            "GetHealthCheck" => operations::more::get_health_check(&state, &input, ctx),
371            "GetQueryLoggingConfig" => {
372                operations::more::get_query_logging_config(&state, &input, ctx)
373            }
374            "GetGeoLocation" => operations::more::get_geo_location(&state, &input, ctx),
375            "ListGeoLocations" => operations::more::list_geo_locations(&state, &input, ctx),
376            "ListReusableDelegationSets" => {
377                operations::more::list_reusable_delegation_sets(&state, &input, ctx)
378            }
379            "CreateReusableDelegationSet" => {
380                operations::more::create_reusable_delegation_set(&state, &input, ctx)
381            }
382            "CreateTrafficPolicy" => operations::more::create_traffic_policy(&state, &input, ctx),
383            "GetTrafficPolicy" => operations::more::get_traffic_policy(&state, &input, ctx),
384            "ListTrafficPolicies" => operations::more::list_traffic_policies(&state, &input, ctx),
385            "DeleteTrafficPolicy" => operations::more::delete_traffic_policy(&state, &input, ctx),
386            "AssociateVPCWithHostedZone" => {
387                operations::more::associate_vpc_with_hosted_zone(&state, &input, ctx)
388            }
389            "DisassociateVPCFromHostedZone" => {
390                operations::more::disassociate_vpc_from_hosted_zone(&state, &input, ctx)
391            }
392
393            _ => Err(AwsError::unknown_operation(operation)),
394        }
395    }
396
397    fn snapshot(&self) -> Option<Vec<u8>> {
398        let entries: Vec<(String, String, state::Route53StateSnapshot)> = self
399            .store
400            .iter_all()
401            .into_iter()
402            .map(|((account, region), st)| (account, region, st.to_snapshot()))
403            .collect();
404        serde_json::to_vec(&entries).ok()
405    }
406
407    fn restore(&self, data: &[u8]) -> Result<(), String> {
408        let entries: Vec<(String, String, state::Route53StateSnapshot)> =
409            serde_json::from_slice(data).map_err(|e| e.to_string())?;
410        for (account, region, snap) in entries {
411            self.store
412                .get(&account, &region)
413                .restore_from_snapshot(snap);
414        }
415        Ok(())
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use serde_json::json;
423
424    fn ctx() -> RequestContext {
425        RequestContext::new("route53", "us-east-1")
426    }
427
428    fn block_on<F: std::future::Future>(f: F) -> F::Output {
429        use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
430        fn noop_clone(_: *const ()) -> RawWaker {
431            noop_raw_waker()
432        }
433        fn noop(_: *const ()) {}
434        fn noop_raw_waker() -> RawWaker {
435            static VTABLE: RawWakerVTable = RawWakerVTable::new(noop_clone, noop, noop, noop);
436            RawWaker::new(std::ptr::null(), &VTABLE)
437        }
438        let waker = unsafe { Waker::from_raw(noop_raw_waker()) };
439        let mut cx = Context::from_waker(&waker);
440        let mut fut = std::pin::pin!(f);
441        loop {
442            match fut.as_mut().poll(&mut cx) {
443                Poll::Ready(v) => return v,
444                Poll::Pending => {}
445            }
446        }
447    }
448
449    #[test]
450    fn create_private_zone_requires_vpc() {
451        let svc = Route53Service::new();
452        let ctx = ctx();
453        let err = block_on(svc.handle(
454            "CreateHostedZone",
455            json!({
456                "Name": "example.com.",
457                "CallerReference": "r1",
458                "HostedZoneConfig": { "PrivateZone": true }
459            }),
460            &ctx,
461        ))
462        .unwrap_err();
463        assert_eq!(err.code, "InvalidInput");
464    }
465
466    #[test]
467    fn create_private_zone_with_vpc_round_trips() {
468        let svc = Route53Service::new();
469        let ctx = ctx();
470        let resp = block_on(svc.handle(
471            "CreateHostedZone",
472            json!({
473                "Name": "internal.example.com.",
474                "CallerReference": "r2",
475                "HostedZoneConfig": { "PrivateZone": true },
476                "VPC": { "VPCId": "vpc-1", "VPCRegion": "us-east-1" }
477            }),
478            &ctx,
479        ))
480        .unwrap();
481        assert_eq!(resp["HostedZone"]["Config"]["PrivateZone"], true);
482
483        let id = resp["HostedZone"]["Id"].as_str().unwrap().to_string();
484        let got = block_on(svc.handle("GetHostedZone", json!({ "Id": id }), &ctx)).unwrap();
485        assert_eq!(got["HostedZone"]["Config"]["PrivateZone"], true);
486        assert_eq!(got["VPCs"][0]["VPCId"], "vpc-1");
487    }
488
489    #[test]
490    fn create_public_zone_rejects_vpc() {
491        let svc = Route53Service::new();
492        let ctx = ctx();
493        let err = block_on(svc.handle(
494            "CreateHostedZone",
495            json!({
496                "Name": "public.example.com.",
497                "CallerReference": "r3",
498                "VPC": { "VPCId": "vpc-9", "VPCRegion": "us-east-1" }
499            }),
500            &ctx,
501        ))
502        .unwrap_err();
503        assert_eq!(err.code, "InvalidInput");
504    }
505
506    #[test]
507    fn snapshot_round_trips_hosted_zones_and_query_logs() {
508        let svc = Route53Service::new();
509        let ctx = ctx();
510        block_on(svc.handle(
511            "CreateHostedZone",
512            json!({
513                "Name": "snap.example.com.",
514                "CallerReference": "snap-1",
515            }),
516            &ctx,
517        ))
518        .unwrap();
519        let bytes = svc.snapshot().expect("encode");
520        let restored = Route53Service::new();
521        restored.restore(&bytes).expect("decode");
522        let listed = block_on(restored.handle("ListHostedZones", json!({}), &ctx)).unwrap();
523        let zones = listed["HostedZones"].as_array().unwrap();
524        assert!(zones.iter().any(|z| z["Name"] == "snap.example.com."));
525    }
526}