Skip to main content

fakecloud_appconfig/
service.rs

1//! AWS AppConfig + AppConfig Data restJson1 service handler.
2//!
3//! One handler serves both AWS model-services (they share the SigV4 signing
4//! name `appconfig`). Control-plane operations live under `/applications`,
5//! `/deploymentstrategies`, `/extensions`, `/extensionassociations`,
6//! `/experimentdefinitions`, `/settings` and `/tags`; the AppConfig Data plane
7//! lives under `/configurationsessions` and `/configuration`. Requests are
8//! routed by method + path segments.
9//!
10//! Deployments settle to `COMPLETE` synchronously so a Describe waiter (and
11//! Terraform) observes the terminal state without a background engine.
12
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use http::header::{HeaderName, HeaderValue};
16use http::{Method, StatusCode};
17use serde_json::{json, Map, Value};
18use std::sync::Arc;
19use tokio::sync::Mutex as AsyncMutex;
20
21use fakecloud_core::pagination::paginate_checked;
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::state::{
26    application_arn, deployment_strategy_arn, environment_arn, extension_arn,
27    extension_association_arn, profile_arn, ApplicationRecord, DeploymentRecord, EnvironmentRecord,
28    ExperimentRecord, HostedVersionRecord, ProfileRecord, SessionRecord, SharedAppConfigState,
29    TagMap,
30};
31
32pub const APPCONFIG_ACTIONS: &[&str] = &[
33    // ---- appconfig control plane ----
34    "CreateApplication",
35    "CreateConfigurationProfile",
36    "CreateDeploymentStrategy",
37    "CreateEnvironment",
38    "CreateExperimentDefinition",
39    "CreateExtension",
40    "CreateExtensionAssociation",
41    "CreateHostedConfigurationVersion",
42    "DeleteApplication",
43    "DeleteConfigurationProfile",
44    "DeleteDeploymentStrategy",
45    "DeleteEnvironment",
46    "DeleteExperimentDefinition",
47    "DeleteExtension",
48    "DeleteExtensionAssociation",
49    "DeleteHostedConfigurationVersion",
50    "GetAccountSettings",
51    "GetApplication",
52    "GetConfiguration",
53    "GetConfigurationProfile",
54    "GetDeployment",
55    "GetDeploymentStrategy",
56    "GetEnvironment",
57    "GetExperimentDefinition",
58    "GetExperimentRun",
59    "GetExtension",
60    "GetExtensionAssociation",
61    "GetHostedConfigurationVersion",
62    "ListApplications",
63    "ListConfigurationProfiles",
64    "ListDeploymentStrategies",
65    "ListDeployments",
66    "ListEnvironments",
67    "ListExperimentDefinitions",
68    "ListExperimentRunEvents",
69    "ListExperimentRuns",
70    "ListExtensionAssociations",
71    "ListExtensions",
72    "ListHostedConfigurationVersions",
73    "ListTagsForResource",
74    "StartDeployment",
75    "StartExperimentRun",
76    "StopDeployment",
77    "StopExperimentRun",
78    "TagResource",
79    "UntagResource",
80    "UpdateAccountSettings",
81    "UpdateApplication",
82    "UpdateConfigurationProfile",
83    "UpdateDeploymentStrategy",
84    "UpdateEnvironment",
85    "UpdateExperimentDefinition",
86    "UpdateExperimentRun",
87    "UpdateExtension",
88    "UpdateExtensionAssociation",
89    "ValidateConfiguration",
90    // ---- appconfigdata data plane ----
91    "GetLatestConfiguration",
92    "StartConfigurationSession",
93];
94
95pub struct AppConfigService {
96    state: SharedAppConfigState,
97    snapshot_store: Option<Arc<dyn SnapshotStore>>,
98    snapshot_lock: Arc<AsyncMutex<()>>,
99}
100
101/// The decoded path labels for a route, in URI order.
102type Labels = Vec<String>;
103
104impl AppConfigService {
105    pub fn new(state: SharedAppConfigState) -> Self {
106        Self {
107            state,
108            snapshot_store: None,
109            snapshot_lock: Arc::new(AsyncMutex::new(())),
110        }
111    }
112
113    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
114        self.snapshot_store = Some(store);
115        self
116    }
117
118    async fn save_snapshot(&self) {
119        crate::persistence::save_snapshot(
120            &self.state,
121            self.snapshot_store.clone(),
122            &self.snapshot_lock,
123        )
124        .await;
125    }
126
127    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
128    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
129        let store = self.snapshot_store.clone()?;
130        let state = self.state.clone();
131        let lock = self.snapshot_lock.clone();
132        Some(Arc::new(move || {
133            let state = state.clone();
134            let store = store.clone();
135            let lock = lock.clone();
136            Box::pin(async move {
137                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
138            })
139        }))
140    }
141
142    /// Route a request to `(action, path-labels)` by method + path segments.
143    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Labels)> {
144        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
145        let trimmed = raw.strip_prefix('/').unwrap_or(raw);
146        let segs: Vec<&str> = if trimmed.is_empty() {
147            Vec::new()
148        } else {
149            trimmed.split('/').collect()
150        };
151        let m = &req.method;
152        let d = |s: &str| decode(s);
153        let one = |a| Some((a, Vec::new()));
154        macro_rules! l {
155            ($a:expr, $($x:expr),*) => { Some(($a, vec![$($x),*])) };
156        }
157        let patch = Method::PATCH;
158        match (m, segs.as_slice()) {
159            // ---- applications ----
160            (&Method::POST, ["applications"]) => one("CreateApplication"),
161            (&Method::GET, ["applications"]) => one("ListApplications"),
162            (&Method::GET, ["applications", app]) => l!("GetApplication", d(app)),
163            (mth, ["applications", app]) if *mth == patch => l!("UpdateApplication", d(app)),
164            (&Method::DELETE, ["applications", app]) => l!("DeleteApplication", d(app)),
165            // ---- configuration profiles ----
166            (&Method::POST, ["applications", app, "configurationprofiles"]) => {
167                l!("CreateConfigurationProfile", d(app))
168            }
169            (&Method::GET, ["applications", app, "configurationprofiles"]) => {
170                l!("ListConfigurationProfiles", d(app))
171            }
172            (&Method::GET, ["applications", app, "configurationprofiles", p]) => {
173                l!("GetConfigurationProfile", d(app), d(p))
174            }
175            (mth, ["applications", app, "configurationprofiles", p]) if *mth == patch => {
176                l!("UpdateConfigurationProfile", d(app), d(p))
177            }
178            (&Method::DELETE, ["applications", app, "configurationprofiles", p]) => {
179                l!("DeleteConfigurationProfile", d(app), d(p))
180            }
181            (&Method::POST, ["applications", app, "configurationprofiles", p, "validators"]) => {
182                l!("ValidateConfiguration", d(app), d(p))
183            }
184            // ---- hosted configuration versions ----
185            (
186                &Method::POST,
187                ["applications", app, "configurationprofiles", p, "hostedconfigurationversions"],
188            ) => l!("CreateHostedConfigurationVersion", d(app), d(p)),
189            (
190                &Method::GET,
191                ["applications", app, "configurationprofiles", p, "hostedconfigurationversions"],
192            ) => l!("ListHostedConfigurationVersions", d(app), d(p)),
193            (
194                &Method::GET,
195                ["applications", app, "configurationprofiles", p, "hostedconfigurationversions", v],
196            ) => l!("GetHostedConfigurationVersion", d(app), d(p), d(v)),
197            (
198                &Method::DELETE,
199                ["applications", app, "configurationprofiles", p, "hostedconfigurationversions", v],
200            ) => l!("DeleteHostedConfigurationVersion", d(app), d(p), d(v)),
201            // ---- environments ----
202            (&Method::POST, ["applications", app, "environments"]) => {
203                l!("CreateEnvironment", d(app))
204            }
205            (&Method::GET, ["applications", app, "environments"]) => l!("ListEnvironments", d(app)),
206            (&Method::GET, ["applications", app, "environments", e]) => {
207                l!("GetEnvironment", d(app), d(e))
208            }
209            (mth, ["applications", app, "environments", e]) if *mth == patch => {
210                l!("UpdateEnvironment", d(app), d(e))
211            }
212            (&Method::DELETE, ["applications", app, "environments", e]) => {
213                l!("DeleteEnvironment", d(app), d(e))
214            }
215            // ---- deployments ----
216            (&Method::POST, ["applications", app, "environments", e, "deployments"]) => {
217                l!("StartDeployment", d(app), d(e))
218            }
219            (&Method::GET, ["applications", app, "environments", e, "deployments"]) => {
220                l!("ListDeployments", d(app), d(e))
221            }
222            (&Method::GET, ["applications", app, "environments", e, "deployments", n]) => {
223                l!("GetDeployment", d(app), d(e), d(n))
224            }
225            (&Method::DELETE, ["applications", app, "environments", e, "deployments", n]) => {
226                l!("StopDeployment", d(app), d(e), d(n))
227            }
228            // ---- legacy GetConfiguration ----
229            (&Method::GET, ["applications", app, "environments", e, "configurations", c]) => {
230                l!("GetConfiguration", d(app), d(e), d(c))
231            }
232            // ---- deployment strategies ----
233            (&Method::POST, ["deploymentstrategies"]) => one("CreateDeploymentStrategy"),
234            (&Method::GET, ["deploymentstrategies"]) => one("ListDeploymentStrategies"),
235            (&Method::GET, ["deploymentstrategies", id]) => l!("GetDeploymentStrategy", d(id)),
236            (mth, ["deploymentstrategies", id]) if *mth == patch => {
237                l!("UpdateDeploymentStrategy", d(id))
238            }
239            // NOTE: AWS's model spells the DELETE path `deployementstrategies`.
240            (&Method::DELETE, ["deployementstrategies", id]) => {
241                l!("DeleteDeploymentStrategy", d(id))
242            }
243            // ---- extensions ----
244            (&Method::POST, ["extensions"]) => one("CreateExtension"),
245            (&Method::GET, ["extensions"]) => one("ListExtensions"),
246            (&Method::GET, ["extensions", id]) => l!("GetExtension", d(id)),
247            (mth, ["extensions", id]) if *mth == patch => l!("UpdateExtension", d(id)),
248            (&Method::DELETE, ["extensions", id]) => l!("DeleteExtension", d(id)),
249            // ---- extension associations ----
250            (&Method::POST, ["extensionassociations"]) => one("CreateExtensionAssociation"),
251            (&Method::GET, ["extensionassociations"]) => one("ListExtensionAssociations"),
252            (&Method::GET, ["extensionassociations", id]) => {
253                l!("GetExtensionAssociation", d(id))
254            }
255            (mth, ["extensionassociations", id]) if *mth == patch => {
256                l!("UpdateExtensionAssociation", d(id))
257            }
258            (&Method::DELETE, ["extensionassociations", id]) => {
259                l!("DeleteExtensionAssociation", d(id))
260            }
261            // ---- experiment definitions ----
262            (&Method::GET, ["experimentdefinitions"]) => one("ListExperimentDefinitions"),
263            (&Method::POST, ["applications", app, "experimentdefinitions"]) => {
264                l!("CreateExperimentDefinition", d(app))
265            }
266            (&Method::GET, ["applications", app, "experimentdefinitions", id]) => {
267                l!("GetExperimentDefinition", d(app), d(id))
268            }
269            (mth, ["applications", app, "experimentdefinitions", id]) if *mth == patch => {
270                l!("UpdateExperimentDefinition", d(app), d(id))
271            }
272            (&Method::DELETE, ["applications", app, "experimentdefinitions", id]) => {
273                l!("DeleteExperimentDefinition", d(app), d(id))
274            }
275            // ---- experiment runs ----
276            (
277                &Method::POST,
278                ["applications", app, "experimentdefinitions", id, "experimentruns"],
279            ) => {
280                l!("StartExperimentRun", d(app), d(id))
281            }
282            (
283                &Method::GET,
284                ["applications", app, "experimentdefinitions", id, "experimentruns"],
285            ) => {
286                l!("ListExperimentRuns", d(app), d(id))
287            }
288            (
289                &Method::GET,
290                ["applications", app, "experimentdefinitions", id, "experimentruns", run],
291            ) => l!("GetExperimentRun", d(app), d(id), d(run)),
292            (
293                mth,
294                ["applications", app, "experimentdefinitions", id, "experimentruns", run, "update"],
295            ) if *mth == patch => l!("UpdateExperimentRun", d(app), d(id), d(run)),
296            (
297                mth,
298                ["applications", app, "experimentdefinitions", id, "experimentruns", run, "stop"],
299            ) if *mth == patch => l!("StopExperimentRun", d(app), d(id), d(run)),
300            (
301                &Method::GET,
302                ["applications", app, "experimentdefinitions", id, "experimentruns", run, "events"],
303            ) => l!("ListExperimentRunEvents", d(app), d(id), d(run)),
304            // ---- account settings ----
305            (&Method::GET, ["settings"]) => one("GetAccountSettings"),
306            (mth, ["settings"]) if *mth == patch => one("UpdateAccountSettings"),
307            // ---- tags ----
308            (&Method::GET, ["tags", arn]) => l!("ListTagsForResource", d(arn)),
309            (&Method::POST, ["tags", arn]) => l!("TagResource", d(arn)),
310            (&Method::DELETE, ["tags", arn]) => l!("UntagResource", d(arn)),
311            // ---- appconfigdata data plane ----
312            (&Method::POST, ["configurationsessions"]) => one("StartConfigurationSession"),
313            (&Method::GET, ["configuration"]) => one("GetLatestConfiguration"),
314            _ => None,
315        }
316    }
317}
318
319const MUTATING: &[&str] = &[
320    "CreateApplication",
321    "CreateConfigurationProfile",
322    "CreateDeploymentStrategy",
323    "CreateEnvironment",
324    "CreateExperimentDefinition",
325    "CreateExtension",
326    "CreateExtensionAssociation",
327    "CreateHostedConfigurationVersion",
328    "DeleteApplication",
329    "DeleteConfigurationProfile",
330    "DeleteDeploymentStrategy",
331    "DeleteEnvironment",
332    "DeleteExperimentDefinition",
333    "DeleteExtension",
334    "DeleteExtensionAssociation",
335    "DeleteHostedConfigurationVersion",
336    "StartDeployment",
337    "StartExperimentRun",
338    "StopDeployment",
339    "StopExperimentRun",
340    "TagResource",
341    "UntagResource",
342    "UpdateAccountSettings",
343    "UpdateApplication",
344    "UpdateConfigurationProfile",
345    "UpdateDeploymentStrategy",
346    "UpdateEnvironment",
347    "UpdateExperimentDefinition",
348    "UpdateExperimentRun",
349    "UpdateExtension",
350    "UpdateExtensionAssociation",
351    "StartConfigurationSession",
352];
353
354#[async_trait]
355impl AwsService for AppConfigService {
356    fn service_name(&self) -> &str {
357        "appconfig"
358    }
359
360    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
361        let (action, labels) = Self::resolve_action(&req).ok_or_else(|| {
362            AwsServiceError::aws_error(
363                StatusCode::NOT_FOUND,
364                "UnknownOperationException",
365                format!("Unknown operation: {} {}", req.method, req.raw_path),
366            )
367        })?;
368
369        let result = self.dispatch(action, &labels, &req);
370
371        if MUTATING.contains(&action)
372            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
373        {
374            self.save_snapshot().await;
375        }
376        result
377    }
378
379    fn supported_actions(&self) -> &[&str] {
380        APPCONFIG_ACTIONS
381    }
382}
383
384impl AppConfigService {
385    #[allow(clippy::too_many_lines)]
386    fn dispatch(
387        &self,
388        action: &str,
389        l: &[String],
390        req: &AwsRequest,
391    ) -> Result<AwsResponse, AwsServiceError> {
392        crate::validation::validate(action, l, req)?;
393        match action {
394            // applications
395            "CreateApplication" => self.create_application(req),
396            "GetApplication" => self.get_application(req, &l[0]),
397            "UpdateApplication" => self.update_application(req, &l[0]),
398            "DeleteApplication" => self.delete_application(req, &l[0]),
399            "ListApplications" => self.list_applications(req),
400            // configuration profiles
401            "CreateConfigurationProfile" => self.create_profile(req, &l[0]),
402            "GetConfigurationProfile" => self.get_profile(req, &l[0], &l[1]),
403            "UpdateConfigurationProfile" => self.update_profile(req, &l[0], &l[1]),
404            "DeleteConfigurationProfile" => self.delete_profile(req, &l[0], &l[1]),
405            "ListConfigurationProfiles" => self.list_profiles(req, &l[0]),
406            "ValidateConfiguration" => self.validate_configuration(req, &l[0], &l[1]),
407            // hosted configuration versions
408            "CreateHostedConfigurationVersion" => self.create_hosted_version(req, &l[0], &l[1]),
409            "GetHostedConfigurationVersion" => self.get_hosted_version(req, &l[0], &l[1], &l[2]),
410            "DeleteHostedConfigurationVersion" => {
411                self.delete_hosted_version(req, &l[0], &l[1], &l[2])
412            }
413            "ListHostedConfigurationVersions" => self.list_hosted_versions(req, &l[0], &l[1]),
414            // environments
415            "CreateEnvironment" => self.create_environment(req, &l[0]),
416            "GetEnvironment" => self.get_environment(req, &l[0], &l[1]),
417            "UpdateEnvironment" => self.update_environment(req, &l[0], &l[1]),
418            "DeleteEnvironment" => self.delete_environment(req, &l[0], &l[1]),
419            "ListEnvironments" => self.list_environments(req, &l[0]),
420            // deployments
421            "StartDeployment" => self.start_deployment(req, &l[0], &l[1]),
422            "GetDeployment" => self.get_deployment(req, &l[0], &l[1], &l[2]),
423            "StopDeployment" => self.stop_deployment(req, &l[0], &l[1], &l[2]),
424            "ListDeployments" => self.list_deployments(req, &l[0], &l[1]),
425            "GetConfiguration" => self.get_configuration(req, &l[0], &l[1], &l[2]),
426            // deployment strategies
427            "CreateDeploymentStrategy" => self.create_deployment_strategy(req),
428            "GetDeploymentStrategy" => self.get_deployment_strategy(req, &l[0]),
429            "UpdateDeploymentStrategy" => self.update_deployment_strategy(req, &l[0]),
430            "DeleteDeploymentStrategy" => self.delete_deployment_strategy(req, &l[0]),
431            "ListDeploymentStrategies" => self.list_deployment_strategies(req),
432            // extensions
433            "CreateExtension" => self.create_extension(req),
434            "GetExtension" => self.get_extension(req, &l[0]),
435            "UpdateExtension" => self.update_extension(req, &l[0]),
436            "DeleteExtension" => self.delete_extension(req, &l[0]),
437            "ListExtensions" => self.list_extensions(req),
438            // extension associations
439            "CreateExtensionAssociation" => self.create_extension_association(req),
440            "GetExtensionAssociation" => self.get_extension_association(req, &l[0]),
441            "UpdateExtensionAssociation" => self.update_extension_association(req, &l[0]),
442            "DeleteExtensionAssociation" => self.delete_extension_association(req, &l[0]),
443            "ListExtensionAssociations" => self.list_extension_associations(req),
444            // experiments
445            "CreateExperimentDefinition" => self.create_experiment_definition(req, &l[0]),
446            "GetExperimentDefinition" => self.get_experiment_definition(req, &l[0], &l[1]),
447            "UpdateExperimentDefinition" => self.update_experiment_definition(req, &l[0], &l[1]),
448            "DeleteExperimentDefinition" => self.delete_experiment_definition(req, &l[0], &l[1]),
449            "ListExperimentDefinitions" => self.list_experiment_definitions(req),
450            "StartExperimentRun" => self.start_experiment_run(req, &l[0], &l[1]),
451            "GetExperimentRun" => self.get_experiment_run(req, &l[0], &l[1], &l[2]),
452            "UpdateExperimentRun" => self.update_experiment_run(req, &l[0], &l[1], &l[2]),
453            "StopExperimentRun" => self.stop_experiment_run(req, &l[0], &l[1], &l[2]),
454            "ListExperimentRuns" => self.list_experiment_runs(req, &l[0], &l[1]),
455            "ListExperimentRunEvents" => self.list_experiment_run_events(req, &l[0], &l[1], &l[2]),
456            // account settings
457            "GetAccountSettings" => self.get_account_settings(req),
458            "UpdateAccountSettings" => self.update_account_settings(req),
459            // tags
460            "ListTagsForResource" => self.list_tags(req, &l[0]),
461            "TagResource" => self.tag_resource(req, &l[0]),
462            "UntagResource" => self.untag_resource(req, &l[0]),
463            // appconfigdata
464            "StartConfigurationSession" => self.start_configuration_session(req),
465            "GetLatestConfiguration" => self.get_latest_configuration(req),
466            _ => Err(AwsServiceError::action_not_implemented("appconfig", action)),
467        }
468    }
469}
470
471include!("handlers.rs");