Skip to main content

alien_core/resources/
aws_open_search.rs

1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
3use crate::ResourceType;
4use alien_error::AlienError;
5use bon::Builder;
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8use std::fmt::Debug;
9
10/// An Amazon OpenSearch Serverless collection (next generation).
11///
12/// # Experimental namespace
13///
14/// This is the first resource under the `experimental/` resource-type
15/// namespace. Experimental resources are provider-specific (they do not
16/// abstract over clouds), may change or be promoted to a portable resource
17/// in a breaking way, and are only registered for the platforms they
18/// support. `AwsOpenSearch` registers an AWS CloudFormation emitter only;
19/// deploying it to any other platform fails with a typed
20/// `ImportRegistrationMissing` error at generation time.
21///
22/// # What gets provisioned
23///
24/// The AWS emitter provisions next-generation OpenSearch Serverless:
25/// a collection group (`Generation: NEXTGEN`, compute/storage decoupled,
26/// scale-to-zero) plus a collection inside it, an AWS-owned-key encryption
27/// configuration, a public network policy, and a data-access policy for
28/// service-account roles granted `experimental/aws-opensearch/data-access`.
29/// The collection endpoint is public but every request must be SigV4-signed
30/// and pass both IAM (`aoss:APIAccessAll`) and the data-access policy.
31///
32/// # Naming
33///
34/// The physical collection (and collection group) name is
35/// `{id}-{stack-suffix}` and must satisfy the AOSS name grammar, so `id`
36/// must match `[a-z][a-z0-9-]*` and be at most 23 characters. The emitter
37/// rejects ids that don't fit.
38///
39/// # Runtime access
40///
41/// Workers reach the collection over HTTPS with SigV4. The SigV4 signing
42/// service name for OpenSearch Serverless is `aoss` (not `es`); the runtime
43/// binding payload carries `"service": "aoss"` so clients sign correctly.
44/// Requests with a body must also send an `x-amz-content-sha256` header
45/// (the AOSS gateway rejects body-carrying requests without it with an
46/// empty 403); official OpenSearch clients with an `aoss` signer handle
47/// this automatically.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
49#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51#[builder(start_fn = new)]
52pub struct AwsOpenSearch {
53    /// Identifier for the collection. Becomes part of the physical collection
54    /// name, so it must match `[a-z][a-z0-9-]*` and be at most 23 characters.
55    #[builder(start_fn)]
56    pub id: String,
57    /// Workload type of the collection. Immutable once the resource exists
58    /// (AWS only allows the type at collection creation). Default `Search`.
59    #[builder(default)]
60    #[serde(default)]
61    pub collection_type: AwsOpenSearchCollectionType,
62}
63
64/// Workload type for an OpenSearch Serverless collection.
65///
66/// `TIMESERIES` is intentionally not exposed: next-generation serverless
67/// scale-to-zero targets search and vector workloads.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[serde(rename_all = "camelCase")]
71pub enum AwsOpenSearchCollectionType {
72    /// Full-text search collections (`SEARCH`).
73    #[default]
74    Search,
75    /// Vector similarity search collections (`VECTORSEARCH`).
76    VectorSearch,
77}
78
79impl AwsOpenSearch {
80    /// The resource type identifier for AwsOpenSearch.
81    ///
82    /// The `experimental/` prefix marks the experimental namespace; see the
83    /// struct-level docs for the convention.
84    pub const RESOURCE_TYPE: ResourceType =
85        ResourceType::from_static("experimental/aws-opensearch");
86
87    /// Returns the collection's unique identifier.
88    pub fn id(&self) -> &str {
89        &self.id
90    }
91}
92
93/// Outputs generated by a successfully provisioned AwsOpenSearch collection.
94///
95/// Next-generation collections expose no OpenSearch Dashboards endpoint
96/// (the `DashboardEndpoint` attribute exists only for classic collections),
97/// so only the data-plane endpoint and ARN are surfaced.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
100#[serde(rename_all = "camelCase")]
101pub struct AwsOpenSearchOutputs {
102    /// Collection endpoint (`https://<collectionId>.aoss.<region>.on.aws`).
103    /// Requests must be SigV4-signed with service name `aoss`.
104    pub endpoint: String,
105    /// ARN of the collection.
106    pub collection_arn: String,
107}
108
109impl ResourceOutputsDefinition for AwsOpenSearchOutputs {
110    fn get_resource_type(&self) -> ResourceType {
111        AwsOpenSearch::RESOURCE_TYPE.clone()
112    }
113
114    fn as_any(&self) -> &dyn Any {
115        self
116    }
117
118    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
119        Box::new(self.clone())
120    }
121
122    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
123        other.as_any().downcast_ref::<AwsOpenSearchOutputs>() == Some(self)
124    }
125
126    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
127        serde_json::to_value(self)
128    }
129}
130
131impl ResourceDefinition for AwsOpenSearch {
132    fn get_resource_type(&self) -> ResourceType {
133        Self::RESOURCE_TYPE
134    }
135
136    fn id(&self) -> &str {
137        &self.id
138    }
139
140    fn get_dependencies(&self) -> Vec<ResourceRef> {
141        Vec::new()
142    }
143
144    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
145        let new_search = new_config
146            .as_any()
147            .downcast_ref::<AwsOpenSearch>()
148            .ok_or_else(|| {
149                AlienError::new(ErrorData::UnexpectedResourceType {
150                    resource_id: self.id.clone(),
151                    expected: Self::RESOURCE_TYPE,
152                    actual: new_config.get_resource_type(),
153                })
154            })?;
155
156        if self.id != new_search.id {
157            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
158                resource_id: self.id.clone(),
159                reason: "the 'id' field is immutable".to_string(),
160            }));
161        }
162
163        // AWS only accepts the collection type at creation; changing it would
164        // silently require replacing the collection and dropping every index.
165        if self.collection_type != new_search.collection_type {
166            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
167                resource_id: self.id.clone(),
168                reason: "the 'collectionType' field is immutable once the resource exists"
169                    .to_string(),
170            }));
171        }
172
173        Ok(())
174    }
175
176    fn as_any(&self) -> &dyn Any {
177        self
178    }
179
180    fn as_any_mut(&mut self) -> &mut dyn Any {
181        self
182    }
183
184    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
185        Box::new(self.clone())
186    }
187
188    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
189        other.as_any().downcast_ref::<AwsOpenSearch>() == Some(self)
190    }
191
192    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
193        serde_json::to_value(self)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn builder_defaults_to_search() {
203        let search = AwsOpenSearch::new("search".to_string()).build();
204        assert_eq!(search.id, "search");
205        assert_eq!(search.collection_type, AwsOpenSearchCollectionType::Search);
206    }
207
208    #[test]
209    fn resource_type_uses_experimental_namespace() {
210        assert_eq!(
211            AwsOpenSearch::RESOURCE_TYPE.as_ref(),
212            "experimental/aws-opensearch"
213        );
214    }
215
216    #[test]
217    fn validate_update_rejects_id_change() {
218        let original = AwsOpenSearch::new("search".to_string()).build();
219        let renamed = AwsOpenSearch::new("other".to_string()).build();
220        let err = original
221            .validate_update(&renamed)
222            .expect_err("changing the id must be rejected");
223        assert!(err.to_string().contains("'id' field is immutable"));
224    }
225
226    #[test]
227    fn validate_update_rejects_collection_type_change() {
228        let search = AwsOpenSearch::new("search".to_string()).build();
229        let vector = AwsOpenSearch::new("search".to_string())
230            .collection_type(AwsOpenSearchCollectionType::VectorSearch)
231            .build();
232
233        let err = search
234            .validate_update(&vector)
235            .expect_err("changing the collection type must be rejected");
236        assert!(err
237            .to_string()
238            .contains("'collectionType' field is immutable"));
239        // A no-op update is allowed.
240        assert!(vector.validate_update(&vector).is_ok());
241    }
242
243    #[test]
244    fn serializes_with_camel_case_and_roundtrips() {
245        let search = AwsOpenSearch::new("vectors".to_string())
246            .collection_type(AwsOpenSearchCollectionType::VectorSearch)
247            .build();
248        let json = serde_json::to_value(&search).unwrap();
249        assert_eq!(json["collectionType"], "vectorSearch");
250
251        let roundtrip: AwsOpenSearch = serde_json::from_value(json).unwrap();
252        assert_eq!(search, roundtrip);
253    }
254
255    #[test]
256    fn outputs_roundtrip() {
257        let outputs = AwsOpenSearchOutputs {
258            endpoint: "https://abc123.aoss.us-east-1.on.aws".to_string(),
259            collection_arn: "arn:aws:aoss:us-east-1:123456789012:collection/abc123".to_string(),
260        };
261        let json = serde_json::to_string(&outputs).unwrap();
262        let deserialized: AwsOpenSearchOutputs = serde_json::from_str(&json).unwrap();
263        assert_eq!(outputs, deserialized);
264    }
265}