Skip to main content

fakecloud_cloudfront/
extras2_service.rs

1//! Handlers for CloudFront Batch 6b: Connection Groups, Domain ops,
2//! Managed Certificate Details, UpdateDistributionWithStagingConfig.
3
4use chrono::Utc;
5use http::{HeaderMap, StatusCode};
6
7use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
8
9use crate::extras2::{
10    CreateConnectionGroupRequest, StoredConnectionGroup, UpdateConnectionGroupRequest,
11};
12use crate::policies::{
13    not_found, precondition_failed, require_if_match, rfc3339, route_id, xml_with_etag,
14};
15use crate::router::Route;
16use crate::service::{
17    aws_error, esc, extract_body_field, generate_id_with_prefix, invalid_argument, xml_response,
18    CloudFrontService, DEFAULT_ACCOUNT,
19};
20use crate::xml_io;
21
22const NS: &str = crate::NAMESPACE;
23const XML_DECL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
24
25// ─── Connection Group ─────────────────────────────────────────────────
26
27impl CloudFrontService {
28    pub(crate) fn create_connection_group(
29        &self,
30        req: &AwsRequest,
31    ) -> Result<AwsResponse, AwsServiceError> {
32        let cfg: CreateConnectionGroupRequest = xml_io::from_xml_root(&req.body).map_err(|e| {
33            invalid_argument(format!("invalid CreateConnectionGroupRequest XML: {e}"))
34        })?;
35        if cfg.name.is_empty() {
36            return Err(invalid_argument("Name is required"));
37        }
38        let mut state = self.state.write();
39        let account = state
40            .accounts
41            .entry(DEFAULT_ACCOUNT.to_string())
42            .or_default();
43        if account
44            .connection_groups
45            .values()
46            .any(|g| g.name == cfg.name)
47        {
48            return Err(aws_error(
49                StatusCode::CONFLICT,
50                "EntityAlreadyExists",
51                format!("ConnectionGroup {} already exists", cfg.name),
52            ));
53        }
54        let id = generate_id_with_prefix("CG");
55        let arn = format!(
56            "arn:aws:cloudfront::{}:connection-group/{}",
57            DEFAULT_ACCOUNT, id
58        );
59        let routing_endpoint = format!("{}.cloudfront.net", id.to_lowercase());
60        let etag = generate_id_with_prefix("E");
61        let now = Utc::now();
62        let stored = StoredConnectionGroup {
63            id: id.clone(),
64            name: cfg.name,
65            arn,
66            routing_endpoint,
67            status: "InProgress".to_string(),
68            etag: etag.clone(),
69            created_time: now,
70            last_modified_time: now,
71            ipv6_enabled: cfg.ipv6_enabled.unwrap_or(true),
72            anycast_ip_list_id: cfg.anycast_ip_list_id,
73            enabled: cfg.enabled.unwrap_or(true),
74            is_default: false,
75        };
76        account.connection_groups.insert(id.clone(), stored.clone());
77        drop(state);
78        self.schedule_connection_group_deploy(id.clone());
79        let body = render_connection_group(&stored);
80        Ok(xml_with_etag(StatusCode::CREATED, body, &etag, Some(&id)))
81    }
82
83    pub(crate) fn get_connection_group(
84        &self,
85        route: &Route,
86    ) -> Result<AwsResponse, AwsServiceError> {
87        let id = route_id(route, "ConnectionGroup")?;
88        let state = self.state.read();
89        let g = state
90            .accounts
91            .get(DEFAULT_ACCOUNT)
92            .and_then(|a| {
93                a.connection_groups
94                    .get(&id)
95                    .cloned()
96                    .or_else(|| a.connection_groups.values().find(|g| g.name == id).cloned())
97            })
98            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
99        drop(state);
100        let body = render_connection_group(&g);
101        Ok(xml_with_etag(StatusCode::OK, body, &g.etag, None))
102    }
103
104    pub(crate) fn get_connection_group_by_routing_endpoint(
105        &self,
106        req: &AwsRequest,
107    ) -> Result<AwsResponse, AwsServiceError> {
108        let routing_endpoint = req
109            .query_params
110            .get("RoutingEndpoint")
111            .cloned()
112            .ok_or_else(|| invalid_argument("RoutingEndpoint query parameter is required"))?;
113        let state = self.state.read();
114        let g = state
115            .accounts
116            .get(DEFAULT_ACCOUNT)
117            .and_then(|a| {
118                a.connection_groups
119                    .values()
120                    .find(|g| g.routing_endpoint == routing_endpoint)
121                    .cloned()
122            })
123            .ok_or_else(|| not_found("ConnectionGroup", &routing_endpoint))?;
124        drop(state);
125        let body = render_connection_group(&g);
126        Ok(xml_with_etag(StatusCode::OK, body, &g.etag, None))
127    }
128
129    pub(crate) fn update_connection_group(
130        &self,
131        req: &AwsRequest,
132        route: &Route,
133    ) -> Result<AwsResponse, AwsServiceError> {
134        let id = route_id(route, "ConnectionGroup")?;
135        let if_match = require_if_match(req)?;
136        let cfg: UpdateConnectionGroupRequest = xml_io::from_xml_root(&req.body).map_err(|e| {
137            invalid_argument(format!("invalid UpdateConnectionGroupRequest XML: {e}"))
138        })?;
139        let mut state = self.state.write();
140        let account = state
141            .accounts
142            .get_mut(DEFAULT_ACCOUNT)
143            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
144        let g = account
145            .connection_groups
146            .get_mut(&id)
147            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
148        if g.etag != if_match {
149            return Err(precondition_failed());
150        }
151        if let Some(v) = cfg.ipv6_enabled {
152            g.ipv6_enabled = v;
153        }
154        if let Some(v) = cfg.anycast_ip_list_id {
155            g.anycast_ip_list_id = Some(v);
156        }
157        if let Some(v) = cfg.enabled {
158            g.enabled = v;
159        }
160        g.etag = generate_id_with_prefix("E");
161        g.last_modified_time = Utc::now();
162        // UpdateConnectionGroup re-propagates to the edge; mirror the
163        // Distribution lifecycle by flipping back to InProgress.
164        g.status = "InProgress".to_string();
165        let snap = g.clone();
166        drop(state);
167        self.schedule_connection_group_deploy(id.clone());
168        let body = render_connection_group(&snap);
169        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
170    }
171
172    pub(crate) fn delete_connection_group(
173        &self,
174        req: &AwsRequest,
175        route: &Route,
176    ) -> Result<AwsResponse, AwsServiceError> {
177        let id = route_id(route, "ConnectionGroup")?;
178        let if_match = require_if_match(req)?;
179        let mut state = self.state.write();
180        let account = state
181            .accounts
182            .get_mut(DEFAULT_ACCOUNT)
183            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
184        let g = account
185            .connection_groups
186            .get(&id)
187            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
188        if g.etag != if_match {
189            return Err(precondition_failed());
190        }
191        if g.enabled {
192            return Err(aws_error(
193                StatusCode::PRECONDITION_FAILED,
194                "ResourceInUse",
195                "ConnectionGroup must be disabled before delete",
196            ));
197        }
198        let arn = g.arn.clone();
199        account.connection_groups.remove(&id);
200        account.tags.remove(&arn);
201        drop(state);
202        Ok(crate::policies::empty(StatusCode::NO_CONTENT))
203    }
204
205    pub(crate) fn list_connection_groups(
206        &self,
207        _req: &AwsRequest,
208    ) -> Result<AwsResponse, AwsServiceError> {
209        let state = self.state.read();
210        let mut items: Vec<StoredConnectionGroup> = state
211            .accounts
212            .get(DEFAULT_ACCOUNT)
213            .map(|a| a.connection_groups.values().cloned().collect())
214            .unwrap_or_default();
215        drop(state);
216        items.sort_by(|a, b| a.id.cmp(&b.id));
217
218        let mut body = String::with_capacity(512);
219        body.push_str(XML_DECL);
220        body.push_str(&format!("<ListConnectionGroupsResult xmlns=\"{NS}\">"));
221        body.push_str("<ConnectionGroups>");
222        for g in &items {
223            body.push_str("<ConnectionGroupSummary>");
224            push_connection_group_inner(&mut body, g);
225            body.push_str("</ConnectionGroupSummary>");
226        }
227        body.push_str("</ConnectionGroups>");
228        body.push_str("</ListConnectionGroupsResult>");
229        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
230    }
231}
232
233// ─── Domain ops + cert + staging ──────────────────────────────────────
234
235impl CloudFrontService {
236    pub(crate) fn list_domain_conflicts(
237        &self,
238        req: &AwsRequest,
239    ) -> Result<AwsResponse, AwsServiceError> {
240        // Domain and DomainControlValidationResource are required in the
241        // Smithy model. Reject probe negatives that omit either so the op
242        // returns the declared InvalidArgument instead of an empty 200.
243        let domain = extract_body_field(&req.body, "Domain");
244        if domain.as_deref().unwrap_or("").is_empty() {
245            return Err(invalid_argument("Domain is required"));
246        }
247        let dcv = extract_body_field(&req.body, "DomainControlValidationResource");
248        if dcv.is_none() {
249            return Err(invalid_argument(
250                "DomainControlValidationResource is required",
251            ));
252        }
253        let mut body = String::with_capacity(256);
254        body.push_str(XML_DECL);
255        body.push_str(&format!("<ListDomainConflictsResult xmlns=\"{NS}\">"));
256        body.push_str("<DomainConflicts/>");
257        body.push_str("</ListDomainConflictsResult>");
258        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
259    }
260
261    pub(crate) fn update_domain_association(
262        &self,
263        req: &AwsRequest,
264    ) -> Result<AwsResponse, AwsServiceError> {
265        let parsed: UpdateDomainAssociationBody =
266            xml_io::from_xml_root(&req.body).map_err(|e| {
267                invalid_argument(format!("invalid UpdateDomainAssociationRequest XML: {e}"))
268            })?;
269        if parsed.domain.is_empty() {
270            return Err(invalid_argument("Domain is required"));
271        }
272        let target = parsed
273            .target_resource
274            .as_ref()
275            .and_then(|t| {
276                t.distribution_id
277                    .clone()
278                    .or_else(|| t.distribution_tenant_id.clone())
279            })
280            .unwrap_or_default();
281        if target.is_empty() {
282            return Err(invalid_argument(
283                "TargetResource must specify DistributionId or DistributionTenantId",
284            ));
285        }
286        let etag = generate_id_with_prefix("E");
287        let mut body = String::with_capacity(256);
288        body.push_str(XML_DECL);
289        body.push_str(&format!("<UpdateDomainAssociationResult xmlns=\"{NS}\">"));
290        body.push_str(&format!("<Domain>{}</Domain>", esc(&parsed.domain)));
291        body.push_str(&format!("<ResourceId>{}</ResourceId>", esc(&target)));
292        body.push_str("</UpdateDomainAssociationResult>");
293        Ok(xml_with_etag(StatusCode::OK, body, &etag, None))
294    }
295
296    pub(crate) fn verify_dns_configuration(
297        &self,
298        req: &AwsRequest,
299    ) -> Result<AwsResponse, AwsServiceError> {
300        let parsed: VerifyDnsConfigurationBody = xml_io::from_xml_root(&req.body).map_err(|e| {
301            invalid_argument(format!("invalid VerifyDnsConfigurationRequest XML: {e}"))
302        })?;
303        if parsed.identifier.is_empty() {
304            return Err(invalid_argument("Identifier is required"));
305        }
306        let mut body = String::with_capacity(256);
307        body.push_str(XML_DECL);
308        body.push_str(&format!("<VerifyDnsConfigurationResult xmlns=\"{NS}\">"));
309        body.push_str("<DnsConfigurationList>");
310        if let Some(d) = &parsed.domain {
311            body.push_str("<DnsConfiguration>");
312            body.push_str(&format!("<Domain>{}</Domain>", esc(d)));
313            body.push_str("<Reason>fakecloud</Reason>");
314            body.push_str("<Status>valid-configuration</Status>");
315            body.push_str("</DnsConfiguration>");
316        }
317        body.push_str("</DnsConfigurationList>");
318        body.push_str("</VerifyDnsConfigurationResult>");
319        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
320    }
321
322    pub(crate) fn get_managed_certificate_details(
323        &self,
324        route: &Route,
325    ) -> Result<AwsResponse, AwsServiceError> {
326        let id = route_id(route, "ManagedCertificate")?;
327        // When the conformance probe omits the required Identifier label,
328        // the URI template substitution leaves the literal `{Identifier}` in
329        // place. Treat that (and any empty value) as a missing cert: the op's
330        // declared error shapes are AccessDenied / EntityNotFound, so return
331        // EntityNotFound rather than a fabricated 200.
332        if crate::service::is_placeholder_label(&id) {
333            return Err(aws_error(
334                StatusCode::NOT_FOUND,
335                "EntityNotFound",
336                format!("ManagedCertificate not found: {id}"),
337            ));
338        }
339        let mut body = String::with_capacity(256);
340        body.push_str(XML_DECL);
341        body.push_str(&format!("<ManagedCertificateDetails xmlns=\"{NS}\">"));
342        body.push_str(&format!(
343            "<CertificateArn>{}</CertificateArn>",
344            esc(&format!(
345                "arn:aws:acm:us-east-1:{}:certificate/{}",
346                DEFAULT_ACCOUNT, id
347            ))
348        ));
349        body.push_str("<CertificateStatus>issued</CertificateStatus>");
350        body.push_str("<ValidationTokenHost>cloudfront</ValidationTokenHost>");
351        body.push_str("</ManagedCertificateDetails>");
352        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
353    }
354
355    pub(crate) fn update_distribution_with_staging_config(
356        &self,
357        req: &AwsRequest,
358        route: &Route,
359    ) -> Result<AwsResponse, AwsServiceError> {
360        let id = route_id(route, "Distribution")?;
361        let if_match = require_if_match(req)?;
362        let staging_id = req
363            .query_params
364            .get("StagingDistributionId")
365            .cloned()
366            .ok_or_else(|| invalid_argument("StagingDistributionId query parameter is required"))?;
367        let mut state = self.state.write();
368        let account = state
369            .accounts
370            .get_mut(DEFAULT_ACCOUNT)
371            .ok_or_else(|| not_found("Distribution", &id))?;
372        let staging_config = account
373            .distributions
374            .get(&staging_id)
375            .ok_or_else(|| not_found("Distribution", &staging_id))?
376            .config
377            .clone();
378        let dist = account
379            .distributions
380            .get_mut(&id)
381            .ok_or_else(|| not_found("Distribution", &id))?;
382        if dist.etag != if_match {
383            return Err(precondition_failed());
384        }
385        // Promote: the staging distribution's configuration becomes the
386        // primary distribution's live config. AWS copies the config wholesale
387        // and the resulting primary is no longer a staging distribution.
388        // Previously this only bumped the ETag, leaving the old config live.
389        let mut promoted = staging_config;
390        promoted.staging = Some(false);
391        dist.config = promoted;
392        dist.etag = generate_id_with_prefix("E");
393        dist.last_modified_time = Utc::now();
394        let snap = dist.clone();
395        drop(state);
396        let body = crate::service::build_distribution_xml(&snap);
397        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
398    }
399}
400
401// ─── Helpers ──────────────────────────────────────────────────────────
402
403#[derive(Debug, serde::Deserialize, Default)]
404#[serde(rename_all = "PascalCase")]
405struct UpdateDomainAssociationBody {
406    pub domain: String,
407    #[serde(default)]
408    pub target_resource: Option<DistributionResourceId>,
409}
410
411#[derive(Debug, serde::Deserialize, Default)]
412#[serde(rename_all = "PascalCase")]
413struct DistributionResourceId {
414    #[serde(default)]
415    pub distribution_id: Option<String>,
416    #[serde(default)]
417    pub distribution_tenant_id: Option<String>,
418}
419
420#[derive(Debug, serde::Deserialize, Default)]
421#[serde(rename_all = "PascalCase")]
422struct VerifyDnsConfigurationBody {
423    pub identifier: String,
424    #[serde(default)]
425    pub domain: Option<String>,
426}
427
428fn render_connection_group(g: &StoredConnectionGroup) -> String {
429    let mut out = String::with_capacity(512);
430    out.push_str(XML_DECL);
431    out.push_str(&format!("<ConnectionGroup xmlns=\"{NS}\">"));
432    push_connection_group_inner(&mut out, g);
433    out.push_str("</ConnectionGroup>");
434    out
435}
436
437fn push_connection_group_inner(out: &mut String, g: &StoredConnectionGroup) {
438    out.push_str(&format!("<Id>{}</Id>", esc(&g.id)));
439    out.push_str(&format!("<Name>{}</Name>", esc(&g.name)));
440    out.push_str(&format!("<Arn>{}</Arn>", esc(&g.arn)));
441    out.push_str(&format!(
442        "<RoutingEndpoint>{}</RoutingEndpoint>",
443        esc(&g.routing_endpoint)
444    ));
445    out.push_str(&format!(
446        "<CreatedTime>{}</CreatedTime>",
447        rfc3339(&g.created_time)
448    ));
449    out.push_str(&format!(
450        "<LastModifiedTime>{}</LastModifiedTime>",
451        rfc3339(&g.last_modified_time)
452    ));
453    out.push_str(&format!("<Ipv6Enabled>{}</Ipv6Enabled>", g.ipv6_enabled));
454    if let Some(a) = &g.anycast_ip_list_id {
455        out.push_str(&format!("<AnycastIpListId>{}</AnycastIpListId>", esc(a)));
456    }
457    out.push_str(&format!("<Status>{}</Status>", esc(&g.status)));
458    out.push_str(&format!("<Enabled>{}</Enabled>", g.enabled));
459    out.push_str(&format!("<IsDefault>{}</IsDefault>", g.is_default));
460}