Skip to main content

alien_core/import/data/
mod.rs

1//! Typed ImportData payloads by cloud and resource.
2
3use serde::{Deserialize, Deserializer};
4
5pub mod aws;
6pub mod azure;
7pub mod gcp;
8pub mod kubernetes_cluster;
9
10pub use aws::{
11    AwsAiImportData, AwsArtifactRegistryImportData, AwsBuildImportData,
12    AwsComputeClusterImportData, AwsEmailDkimTokenImportData, AwsEmailDomainImportData,
13    AwsEmailImportData, AwsKeyImportData, AwsKvImportData, AwsNetworkImportData,
14    AwsOpenSearchImportData, AwsPostgresImportData, AwsQueueImportData,
15    AwsRemoteBindingsImportData, AwsRemoteStackManagementImportData, AwsSandboxImportData,
16    AwsServiceAccountImportData, AwsStorageImportData, AwsVaultImportData, AwsWorkerImportData,
17};
18pub use azure::{
19    AzureAiImportData, AzureArtifactRegistryImportData, AzureBuildImportData,
20    AzureComputeClusterImportData, AzureContainerAppsEnvironmentImportData,
21    AzureFlexibleServerPostgresImportData, AzureKeyImportData, AzureKvImportData,
22    AzureNetworkImportData, AzureQueueImportData, AzureRemoteBindingsImportData,
23    AzureRemoteStackManagementImportData, AzureResourceGroupImportData, AzureSandboxImportData,
24    AzureServiceAccountImportData, AzureServiceActivationImportData,
25    AzureServiceBusNamespaceImportData, AzureStorageAccountImportData, AzureStorageImportData,
26    AzureVaultImportData, AzureWorkerImportData,
27};
28pub use gcp::{
29    GcpAgentPlatformEngineImportData, GcpAiImportData, GcpArtifactRegistryImportData,
30    GcpBuildImportData, GcpComputeClusterImportData, GcpKeyImportData, GcpKvImportData,
31    GcpNetworkImportData, GcpPostgresImportData, GcpQueueImportData, GcpRemoteBindingsImportData,
32    GcpRemoteStackManagementImportData, GcpSandboxImportData, GcpServiceAccountImportData,
33    GcpServiceActivationImportData, GcpStorageImportData, GcpVaultImportData, GcpWorkerImportData,
34};
35pub use kubernetes_cluster::{
36    AzureApplicationGatewayForContainersBootstrap, KubernetesClusterImportData,
37};
38
39pub(crate) fn deserialize_bool_from_bool_or_string<'de, D>(
40    deserializer: D,
41) -> Result<bool, D::Error>
42where
43    D: Deserializer<'de>,
44{
45    bool_from_bool_or_string(serde_json::Value::deserialize(deserializer)?)
46}
47
48/// Optional form of [`deserialize_bool_from_bool_or_string`]: `null` stays `None`. Pair it with
49/// `#[serde(default)]` so an absent field is also `None`.
50pub(crate) fn deserialize_optional_bool_from_bool_or_string<'de, D>(
51    deserializer: D,
52) -> Result<Option<bool>, D::Error>
53where
54    D: Deserializer<'de>,
55{
56    match serde_json::Value::deserialize(deserializer)? {
57        serde_json::Value::Null => Ok(None),
58        value => bool_from_bool_or_string(value).map(Some),
59    }
60}
61
62fn bool_from_bool_or_string<E: serde::de::Error>(value: serde_json::Value) -> Result<bool, E> {
63    match value {
64        serde_json::Value::Bool(value) => Ok(value),
65        serde_json::Value::String(value) if value.eq_ignore_ascii_case("true") => Ok(true),
66        serde_json::Value::String(value) if value.eq_ignore_ascii_case("false") => Ok(false),
67        other => Err(E::custom(format!(
68            "expected boolean or boolean string, got {other}"
69        ))),
70    }
71}
72
73/// Same reason as the boolean helper: CloudFormation stringifies the leaves of a custom
74/// resource's properties, so a declared port list arrives as `["3000"]` rather than `[3000]`.
75pub(crate) fn deserialize_u16_vec_from_numbers_or_strings<'de, D>(
76    deserializer: D,
77) -> Result<Vec<u16>, D::Error>
78where
79    D: Deserializer<'de>,
80{
81    let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
82    values
83        .into_iter()
84        .map(|value| match value {
85            serde_json::Value::Number(number) => number
86                .as_u64()
87                .and_then(|n| u16::try_from(n).ok())
88                .ok_or_else(|| serde::de::Error::custom(format!("port {number} is out of range"))),
89            serde_json::Value::String(text) => text.parse::<u16>().map_err(|_| {
90                serde::de::Error::custom(format!("expected a port number, got {text:?}"))
91            }),
92            other => Err(serde::de::Error::custom(format!(
93                "expected a port number or numeric string, got {other}"
94            ))),
95        })
96        .collect()
97}
98
99#[cfg(all(test, feature = "jsonschema"))]
100mod schema_snapshots {
101    use super::*;
102    use indexmap::IndexMap;
103
104    fn schema<T: schemars::JsonSchema>() -> serde_json::Value {
105        serde_json::to_value(schemars::schema_for!(T)).expect("schema should serialize")
106    }
107
108    #[test]
109    fn import_data_schema_snapshot() {
110        let schemas = IndexMap::from([
111            ("aws_ai", schema::<AwsAiImportData>()),
112            (
113                "aws_artifact_registry",
114                schema::<AwsArtifactRegistryImportData>(),
115            ),
116            ("aws_build", schema::<AwsBuildImportData>()),
117            (
118                "aws_compute_cluster",
119                schema::<AwsComputeClusterImportData>(),
120            ),
121            ("aws_email", schema::<AwsEmailImportData>()),
122            ("aws_function", schema::<AwsWorkerImportData>()),
123            ("aws_kv", schema::<AwsKvImportData>()),
124            ("aws_key", schema::<AwsKeyImportData>()),
125            ("aws_sandbox", schema::<AwsSandboxImportData>()),
126            ("aws_network", schema::<AwsNetworkImportData>()),
127            ("aws_open_search", schema::<AwsOpenSearchImportData>()),
128            ("aws_postgres", schema::<AwsPostgresImportData>()),
129            ("aws_queue", schema::<AwsQueueImportData>()),
130            (
131                "aws_remote_stack_management",
132                schema::<AwsRemoteStackManagementImportData>(),
133            ),
134            (
135                "aws_service_account",
136                schema::<AwsServiceAccountImportData>(),
137            ),
138            ("aws_storage", schema::<AwsStorageImportData>()),
139            ("aws_vault", schema::<AwsVaultImportData>()),
140            ("azure_ai", schema::<AzureAiImportData>()),
141            (
142                "azure_artifact_registry",
143                schema::<AzureArtifactRegistryImportData>(),
144            ),
145            ("azure_build", schema::<AzureBuildImportData>()),
146            (
147                "azure_container_apps_environment",
148                schema::<AzureContainerAppsEnvironmentImportData>(),
149            ),
150            (
151                "azure_compute_cluster",
152                schema::<AzureComputeClusterImportData>(),
153            ),
154            ("azure_function", schema::<AzureWorkerImportData>()),
155            ("azure_kv", schema::<AzureKvImportData>()),
156            ("azure_key", schema::<AzureKeyImportData>()),
157            ("azure_sandbox", schema::<AzureSandboxImportData>()),
158            ("azure_network", schema::<AzureNetworkImportData>()),
159            (
160                "azure_postgres",
161                schema::<AzureFlexibleServerPostgresImportData>(),
162            ),
163            ("azure_queue", schema::<AzureQueueImportData>()),
164            (
165                "azure_remote_stack_management",
166                schema::<AzureRemoteStackManagementImportData>(),
167            ),
168            (
169                "azure_resource_group",
170                schema::<AzureResourceGroupImportData>(),
171            ),
172            (
173                "azure_service_account",
174                schema::<AzureServiceAccountImportData>(),
175            ),
176            (
177                "azure_service_activation",
178                schema::<AzureServiceActivationImportData>(),
179            ),
180            (
181                "azure_service_bus_namespace",
182                schema::<AzureServiceBusNamespaceImportData>(),
183            ),
184            ("azure_storage", schema::<AzureStorageImportData>()),
185            (
186                "azure_storage_account",
187                schema::<AzureStorageAccountImportData>(),
188            ),
189            ("azure_vault", schema::<AzureVaultImportData>()),
190            (
191                "gcp_agent_platform_engine",
192                schema::<GcpAgentPlatformEngineImportData>(),
193            ),
194            ("gcp_ai", schema::<GcpAiImportData>()),
195            (
196                "gcp_artifact_registry",
197                schema::<GcpArtifactRegistryImportData>(),
198            ),
199            ("gcp_build", schema::<GcpBuildImportData>()),
200            (
201                "gcp_compute_cluster",
202                schema::<GcpComputeClusterImportData>(),
203            ),
204            ("gcp_function", schema::<GcpWorkerImportData>()),
205            ("gcp_kv", schema::<GcpKvImportData>()),
206            ("gcp_key", schema::<GcpKeyImportData>()),
207            ("gcp_network", schema::<GcpNetworkImportData>()),
208            ("gcp_postgres", schema::<GcpPostgresImportData>()),
209            ("gcp_queue", schema::<GcpQueueImportData>()),
210            (
211                "gcp_remote_stack_management",
212                schema::<GcpRemoteStackManagementImportData>(),
213            ),
214            (
215                "kubernetes_cluster",
216                schema::<KubernetesClusterImportData>(),
217            ),
218            (
219                "gcp_service_account",
220                schema::<GcpServiceAccountImportData>(),
221            ),
222            (
223                "gcp_service_activation",
224                schema::<GcpServiceActivationImportData>(),
225            ),
226            ("gcp_sandbox", schema::<GcpSandboxImportData>()),
227            ("gcp_storage", schema::<GcpStorageImportData>()),
228            ("gcp_vault", schema::<GcpVaultImportData>()),
229        ]);
230
231        insta::assert_json_snapshot!("import_data_schemas", schemas);
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use serde_json::json;
239
240    #[test]
241    fn aws_import_data_accepts_cloudformation_stringified_leaves() {
242        let network: AwsNetworkImportData = serde_json::from_value(json!({
243            "vpcId": "vpc-123",
244            "cidrBlock": null,
245            "internetGatewayId": null,
246            "natGatewayId": null,
247            "eipAllocationId": null,
248            "publicSubnetIds": ["subnet-public"],
249            "privateSubnetIds": ["subnet-private"],
250            "publicRouteTableId": null,
251            "privateRouteTableId": null,
252            "securityGroupId": "sg-123",
253            "availabilityZones": [],
254            "isByoVpc": "true",
255        }))
256        .expect("network import data should parse");
257        assert!(network.is_byo_vpc);
258
259        let remote_stack_management: AwsRemoteStackManagementImportData =
260            serde_json::from_value(json!({
261                "roleName": "alien-manager",
262                "roleArn": "arn:aws:iam::123456789012:role/alien-manager",
263                "managementPermissionsApplied": "true",
264            }))
265            .expect("remote stack management import data should parse");
266        assert!(remote_stack_management.management_permissions_applied);
267
268        let service_account: AwsServiceAccountImportData = serde_json::from_value(json!({
269            "roleName": "alien-worker",
270            "roleArn": "arn:aws:iam::123456789012:role/alien-worker",
271            "stackPermissionsApplied": "false",
272        }))
273        .expect("service account import data should parse");
274        assert!(!service_account.stack_permissions_applied);
275
276        // CloudFormation stringifies every leaf of a custom resource's properties, so this is the
277        // shape the importer actually receives — a sandbox declared with open egress and a preview
278        // port reaches it as strings, and rejecting them fails the customer's stack at setup.
279        let sandbox: AwsSandboxImportData = serde_json::from_value(json!({
280            "imageIdentifier": "runner",
281            "imageArn": "arn:aws:lambda:us-east-2:123456789012:microvm-image/runner",
282            "imageVersion": "1",
283            "egressConnectorArns": [],
284            "allowEgress": "true",
285            "previewPorts": ["3000", "8080"],
286        }))
287        .expect("sandbox import data should parse CloudFormation's stringified leaves");
288        assert!(sandbox.allow_egress);
289        assert_eq!(sandbox.preview_ports, vec![3000, 8080]);
290
291        let native: AwsSandboxImportData = serde_json::from_value(json!({
292            "imageIdentifier": "runner",
293            "imageArn": "arn:aws:lambda:us-east-2:123456789012:microvm-image/runner",
294            "imageVersion": "1",
295            "egressConnectorArns": [],
296            "allowEgress": false,
297            "previewPorts": [3000],
298        }))
299        .expect("sandbox import data should still parse native JSON types");
300        assert!(!native.allow_egress);
301        assert_eq!(native.preview_ports, vec![3000]);
302    }
303
304    /// CloudFormation delivers every boolean property as a string, so a setup that declares a
305    /// customer encryption key or a CloudFormation-created EKS cluster registers with "true" or
306    /// "false" here. Rejecting the string form fails registration and rolls the customer's stack back.
307    #[test]
308    fn stringified_key_and_kubernetes_cluster_booleans_parse() {
309        let key = |probe: serde_json::Value| -> Result<AwsKeyImportData, serde_json::Error> {
310            let mut data = json!({ "keyArn": "arn:aws:kms:us-east-1:123456789012:key/k-1" });
311            if !probe.is_null() {
312                data["availabilityProbe"] = probe;
313            }
314            serde_json::from_value(data)
315        };
316        assert!(key(json!("true")).expect("string true").availability_probe);
317        assert!(
318            !key(json!("false"))
319                .expect("string false")
320                .availability_probe
321        );
322        assert!(key(json!(true)).expect("native true").availability_probe);
323        assert!(!key(json!(false)).expect("native false").availability_probe);
324        assert!(
325            !key(serde_json::Value::Null)
326                .expect("an absent probe defaults to false")
327                .availability_probe
328        );
329        assert!(
330            key(json!("yes")).is_err(),
331            "a non-boolean string must be rejected"
332        );
333        assert!(key(json!(1)).is_err(), "a number must be rejected");
334
335        let cluster = |ready: Option<serde_json::Value>| -> Result<
336            KubernetesClusterImportData,
337            serde_json::Error,
338        > {
339            let mut data = json!({
340                "provider": "eks",
341                "ownership": "managed",
342                "namespace": "alien",
343                "clusterName": "acme",
344            });
345            if let Some(ready) = ready {
346                data["cloudMetadataReady"] = ready;
347            }
348            serde_json::from_value(data)
349        };
350        assert_eq!(
351            cluster(Some(json!("true")))
352                .expect("string true")
353                .cloud_metadata_ready,
354            Some(true)
355        );
356        assert_eq!(
357            cluster(Some(json!("false")))
358                .expect("string false")
359                .cloud_metadata_ready,
360            Some(false)
361        );
362        assert_eq!(
363            cluster(Some(json!(true)))
364                .expect("native true")
365                .cloud_metadata_ready,
366            Some(true)
367        );
368        assert_eq!(
369            cluster(Some(json!(false)))
370                .expect("native false")
371                .cloud_metadata_ready,
372            Some(false)
373        );
374        assert_eq!(
375            cluster(Some(serde_json::Value::Null))
376                .expect("null")
377                .cloud_metadata_ready,
378            None
379        );
380        assert_eq!(cluster(None).expect("absent").cloud_metadata_ready, None);
381        assert!(
382            cluster(Some(json!("ready"))).is_err(),
383            "a non-boolean string must be rejected"
384        );
385    }
386
387    /// A runtime-provisioned sandbox registers without an image; the build role and bundle take its
388    /// place. A contract that silently drops those fields as unknown fails only after `terraform
389    /// apply` or stack creation has already succeeded, in the customer's own account.
390    #[test]
391    fn a_runtime_provisioned_sandbox_registers_without_an_image() {
392        let runtime: AwsSandboxImportData = serde_json::from_value(json!({
393            "egressConnectorArns": ["arn:aws:lambda:us-east-2:123456789012:network-connector/c-1"],
394            "allowEgress": "false",
395            "previewPorts": ["8080"],
396            "buildRoleArn": "arn:aws:iam::123456789012:role/acme-agents-build",
397            "bundleUri": "s3://acme-artifacts-us-east-2/agents/bundle.zip",
398        }))
399        .expect("a runtime-provisioned sandbox must register without image fields");
400
401        assert_eq!(runtime.image_arn, None);
402        assert_eq!(runtime.image_identifier, None);
403        assert_eq!(runtime.image_version, None);
404        assert_eq!(
405            runtime.build_role_arn.as_deref(),
406            Some("arn:aws:iam::123456789012:role/acme-agents-build")
407        );
408        assert_eq!(
409            runtime.bundle_uri.as_deref(),
410            Some("s3://acme-artifacts-us-east-2/agents/bundle.zip")
411        );
412        assert_eq!(runtime.preview_ports, vec![8080]);
413
414        // The setup-built shape carries no build inputs, so the two are distinguishable rather
415        // than merely both accepted.
416        let frozen: AwsSandboxImportData = serde_json::from_value(json!({
417            "imageIdentifier": "runner",
418            "imageArn": "arn:aws:lambda:us-east-2:123456789012:microvm-image/runner",
419            "imageVersion": "1.0",
420        }))
421        .expect("the setup-built shape still parses");
422        assert_eq!(frozen.build_role_arn, None);
423        assert_eq!(frozen.bundle_uri, None);
424        assert_eq!(frozen.image_version.as_deref(), Some("1.0"));
425    }
426}