alien-core 2.1.1

Deploy software into your customers' cloud accounts and keep it fully managed
Documentation
use crate::error::{ErrorData, Result};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
use crate::ResourceType;
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::fmt::Debug;

/// An Amazon OpenSearch Serverless collection (next generation).
///
/// # Experimental namespace
///
/// This is the first resource under the `experimental/` resource-type
/// namespace. Experimental resources are provider-specific (they do not
/// abstract over clouds), may change or be promoted to a portable resource
/// in a breaking way, and are only registered for the platforms they
/// support. `AwsOpenSearch` registers an AWS CloudFormation emitter only;
/// deploying it to any other platform fails with a typed
/// `ImportRegistrationMissing` error at generation time.
///
/// # What gets provisioned
///
/// The AWS emitter provisions next-generation OpenSearch Serverless:
/// a collection group (`Generation: NEXTGEN`, compute/storage decoupled,
/// scale-to-zero) plus a collection inside it, an AWS-owned-key encryption
/// configuration, a public network policy, and a data-access policy for
/// service-account roles granted `experimental/aws-opensearch/data-access`.
/// The collection endpoint is public but every request must be SigV4-signed
/// and pass both IAM (`aoss:APIAccessAll`) and the data-access policy.
///
/// # Naming
///
/// The physical collection (and collection group) name is
/// `{id}-{stack-suffix}` and must satisfy the AOSS name grammar, so `id`
/// must match `[a-z][a-z0-9-]*` and be at most 23 characters. The emitter
/// rejects ids that don't fit.
///
/// # Runtime access
///
/// Workers reach the collection over HTTPS with SigV4. The SigV4 signing
/// service name for OpenSearch Serverless is `aoss` (not `es`); the runtime
/// binding payload carries `"service": "aoss"` so clients sign correctly.
/// Requests with a body must also send an `x-amz-content-sha256` header
/// (the AOSS gateway rejects body-carrying requests without it with an
/// empty 403); official OpenSearch clients with an `aoss` signer handle
/// this automatically.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct AwsOpenSearch {
    /// Identifier for the collection. Becomes part of the physical collection
    /// name, so it must match `[a-z][a-z0-9-]*` and be at most 23 characters.
    #[builder(start_fn)]
    pub id: String,
    /// Workload type of the collection. Immutable once the resource exists
    /// (AWS only allows the type at collection creation). Default `Search`.
    #[builder(default)]
    #[serde(default)]
    pub collection_type: AwsOpenSearchCollectionType,
}

/// Workload type for an OpenSearch Serverless collection.
///
/// `TIMESERIES` is intentionally not exposed: next-generation serverless
/// scale-to-zero targets search and vector workloads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum AwsOpenSearchCollectionType {
    /// Full-text search collections (`SEARCH`).
    #[default]
    Search,
    /// Vector similarity search collections (`VECTORSEARCH`).
    VectorSearch,
}

impl AwsOpenSearch {
    /// The resource type identifier for AwsOpenSearch.
    ///
    /// The `experimental/` prefix marks the experimental namespace; see the
    /// struct-level docs for the convention.
    pub const RESOURCE_TYPE: ResourceType =
        ResourceType::from_static("experimental/aws-opensearch");

    /// Returns the collection's unique identifier.
    pub fn id(&self) -> &str {
        &self.id
    }
}

/// Outputs generated by a successfully provisioned AwsOpenSearch collection.
///
/// Next-generation collections expose no OpenSearch Dashboards endpoint
/// (the `DashboardEndpoint` attribute exists only for classic collections),
/// so only the data-plane endpoint and ARN are surfaced.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct AwsOpenSearchOutputs {
    /// Collection endpoint (`https://<collectionId>.aoss.<region>.on.aws`).
    /// Requests must be SigV4-signed with service name `aoss`.
    pub endpoint: String,
    /// ARN of the collection.
    pub collection_arn: String,
}

impl ResourceOutputsDefinition for AwsOpenSearchOutputs {
    fn get_resource_type(&self) -> ResourceType {
        AwsOpenSearch::RESOURCE_TYPE.clone()
    }

    fn as_any(&self) -> &dyn Any {
        self
    }

    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
        Box::new(self.clone())
    }

    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
        other.as_any().downcast_ref::<AwsOpenSearchOutputs>() == Some(self)
    }

    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self)
    }
}

impl ResourceDefinition for AwsOpenSearch {
    fn get_resource_type(&self) -> ResourceType {
        Self::RESOURCE_TYPE
    }

    fn id(&self) -> &str {
        &self.id
    }

    fn get_dependencies(&self) -> Vec<ResourceRef> {
        Vec::new()
    }

    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
        let new_search = new_config
            .as_any()
            .downcast_ref::<AwsOpenSearch>()
            .ok_or_else(|| {
                AlienError::new(ErrorData::UnexpectedResourceType {
                    resource_id: self.id.clone(),
                    expected: Self::RESOURCE_TYPE,
                    actual: new_config.get_resource_type(),
                })
            })?;

        if self.id != new_search.id {
            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
                resource_id: self.id.clone(),
                reason: "the 'id' field is immutable".to_string(),
            }));
        }

        // AWS only accepts the collection type at creation; changing it would
        // silently require replacing the collection and dropping every index.
        if self.collection_type != new_search.collection_type {
            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
                resource_id: self.id.clone(),
                reason: "the 'collectionType' field is immutable once the resource exists"
                    .to_string(),
            }));
        }

        Ok(())
    }

    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }

    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
        Box::new(self.clone())
    }

    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
        other.as_any().downcast_ref::<AwsOpenSearch>() == Some(self)
    }

    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_defaults_to_search() {
        let search = AwsOpenSearch::new("search".to_string()).build();
        assert_eq!(search.id, "search");
        assert_eq!(search.collection_type, AwsOpenSearchCollectionType::Search);
    }

    #[test]
    fn resource_type_uses_experimental_namespace() {
        assert_eq!(
            AwsOpenSearch::RESOURCE_TYPE.as_ref(),
            "experimental/aws-opensearch"
        );
    }

    #[test]
    fn validate_update_rejects_id_change() {
        let original = AwsOpenSearch::new("search".to_string()).build();
        let renamed = AwsOpenSearch::new("other".to_string()).build();
        let err = original
            .validate_update(&renamed)
            .expect_err("changing the id must be rejected");
        assert!(err.to_string().contains("'id' field is immutable"));
    }

    #[test]
    fn validate_update_rejects_collection_type_change() {
        let search = AwsOpenSearch::new("search".to_string()).build();
        let vector = AwsOpenSearch::new("search".to_string())
            .collection_type(AwsOpenSearchCollectionType::VectorSearch)
            .build();

        let err = search
            .validate_update(&vector)
            .expect_err("changing the collection type must be rejected");
        assert!(err
            .to_string()
            .contains("'collectionType' field is immutable"));
        // A no-op update is allowed.
        assert!(vector.validate_update(&vector).is_ok());
    }

    #[test]
    fn serializes_with_camel_case_and_roundtrips() {
        let search = AwsOpenSearch::new("vectors".to_string())
            .collection_type(AwsOpenSearchCollectionType::VectorSearch)
            .build();
        let json = serde_json::to_value(&search).unwrap();
        assert_eq!(json["collectionType"], "vectorSearch");

        let roundtrip: AwsOpenSearch = serde_json::from_value(json).unwrap();
        assert_eq!(search, roundtrip);
    }

    #[test]
    fn outputs_roundtrip() {
        let outputs = AwsOpenSearchOutputs {
            endpoint: "https://abc123.aoss.us-east-1.on.aws".to_string(),
            collection_arn: "arn:aws:aoss:us-east-1:123456789012:collection/abc123".to_string(),
        };
        let json = serde_json::to_string(&outputs).unwrap();
        let deserialized: AwsOpenSearchOutputs = serde_json::from_str(&json).unwrap();
        assert_eq!(outputs, deserialized);
    }
}