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
16pub 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 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 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 RouteDefinition {
103 method: "GET",
104 path_pattern: "/2013-04-01/hostedzone/{Id}/dnssec",
105 operation: "GetDNSSEC",
106 required_query_param: None,
107 },
108 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 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 RouteDefinition {
160 method: "GET",
161 path_pattern: "/2013-04-01/testdnsanswer",
162 operation: "TestDNSAnswer",
163 required_query_param: None,
164 },
165 RouteDefinition {
167 method: "GET",
168 path_pattern: "/2013-04-01/checkeripranges",
169 operation: "GetCheckerIpRanges",
170 required_query_param: None,
171 },
172 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 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 "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 "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 "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 "TestDNSAnswer" => operations::extra::test_dns_answer(&state, &input, ctx),
345
346 "GetCheckerIpRanges" => operations::extra::get_checker_ip_ranges(&state, &input, ctx),
348
349 "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 "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, ®ion)
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}