alien-core 3.3.55

Deploy software into your customers' cloud accounts and keep it fully managed
Documentation
//! Network resource for cloud-agnostic VPC/VNet infrastructure.
//!
//! The Network resource provides cloud-agnostic networking infrastructure that can be
//! shared across multiple resource types. Users configure network settings in StackSettings,
//! and the NetworkMutation preflight auto-generates the Network resource.

use crate::error::{ErrorData, Result};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
use crate::stack_settings::NetworkSettings;
use crate::{ResourceType, Stack};
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::fmt::Debug;

/// The flow log on a VPC that Alien creates on AWS. The CloudFormation and Terraform network
/// emitters and the direct-setup network controller all build their flow log from this one
/// definition, and a parity test compares the rendered templates with it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AwsVpcFlowLogSettings {
    /// Accepted and rejected traffic.
    pub traffic_type: &'static str,
    pub log_destination_type: &'static str,
    /// Seconds a flow is aggregated into one record. 600 is the AWS default and the cheapest.
    pub max_aggregation_interval: u32,
    /// Days the log group keeps records. Compliance checks expect one year of log retention.
    pub retention_in_days: i32,
    /// The service that assumes the delivery role.
    pub delivery_service: &'static str,
    /// What the delivery role may do on its own log group, and nothing else.
    pub delivery_actions: &'static [&'static str],
}

pub const AWS_VPC_FLOW_LOG: AwsVpcFlowLogSettings = AwsVpcFlowLogSettings {
    traffic_type: "ALL",
    log_destination_type: "cloud-watch-logs",
    max_aggregation_interval: 600,
    retention_in_days: 365,
    delivery_service: "vpc-flow-logs.amazonaws.com",
    delivery_actions: &[
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams",
    ],
};

/// Represents cloud-agnostic networking infrastructure (VPC, VNet, subnets, etc.).
///
/// This resource is **always auto-generated** by the NetworkMutation preflight based on
/// `StackSettings.network`. Users don't define Network resources directly in `alien.ts`.
#[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 Network {
    /// Unique identifier for the network resource (typically "default-network")
    #[builder(start_fn)]
    pub id: String,

    /// Network configuration (copied from StackSettings)
    pub settings: NetworkSettings,
}

impl Network {
    /// The resource type identifier for Network
    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("network");

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

    /// Settings for generating setup from a stack whose `StackSettings.network` is empty.
    ///
    /// The NetworkMutation preflight then adds a create-new network for the resources that need
    /// one. Setup builds that network, so it must offer the same inputs as when create-new is
    /// passed as the settings. A network declared any other way (the default VPC, an existing VPC)
    /// stays fixed, so this returns `None` for it.
    pub fn preflight_settings(stack: &Stack) -> Option<NetworkSettings> {
        stack
            .resources()
            .find_map(|(_, entry)| entry.config.downcast_ref::<Network>())
            .filter(|network| matches!(network.settings, NetworkSettings::Create { .. }))
            .map(|network| network.settings.clone())
    }
}

/// Outputs generated by a successfully provisioned Network.
///
/// Network outputs are **cloud-agnostic** and primarily for observability/debugging.
/// Resources that need platform-specific details (VPC ID, subnet IDs) use `require_dependency`
/// to access the controller state directly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct NetworkOutputs {
    /// Human-readable network identifier (VPC ID, VNet name, etc.)
    pub network_id: String,

    /// Number of availability zones / regions used
    pub availability_zones: u8,

    /// Whether public subnets exist
    pub has_public_subnets: bool,

    /// Whether NAT gateway exists
    pub has_nat_gateway: bool,

    /// Public addresses used by the deployment's NAT. Empty when unknown, including
    /// bring-your-own networks whose egress is managed outside this deployment.
    #[serde(default)]
    pub egress_public_ips: Vec<String>,

    /// CIDR block (if created by Alien, None for BYO-VPC)
    pub cidr: Option<String>,
}

impl ResourceOutputsDefinition for NetworkOutputs {
    fn get_resource_type(&self) -> ResourceType {
        Network::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::<NetworkOutputs>() == Some(self)
    }

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

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

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

    fn get_dependencies(&self) -> Vec<ResourceRef> {
        Vec::new() // Network has no dependencies
    }

    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
        let new_network = new_config
            .as_any()
            .downcast_ref::<Network>()
            .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_network.id {
            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
                resource_id: self.id.clone(),
                reason: "the 'id' field is immutable".to_string(),
            }));
        }

        // Validate that network type doesn't change (e.g., from Create to BYO-VPC)
        // This would require recreating all networking infrastructure
        match (&self.settings, &new_network.settings) {
            (NetworkSettings::UseDefault, NetworkSettings::UseDefault) => Ok(()),
            (
                NetworkSettings::Create {
                    availability_zones: old_zones,
                    ..
                },
                NetworkSettings::Create {
                    availability_zones: new_zones,
                    ..
                },
            ) => {
                // The network controller creates subnets once and never adds or removes zones.
                if old_zones != new_zones {
                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
                        resource_id: self.id.clone(),
                        reason: format!(
                            "the network's availability zone count can't change after it is created ({old_zones} to {new_zones}); create a new deployment to use a different count"
                        ),
                    }));
                }
                Ok(())
            }
            (NetworkSettings::ByoVpcAws { .. }, NetworkSettings::ByoVpcAws { .. }) => Ok(()),
            (NetworkSettings::ByoVpcGcp { .. }, NetworkSettings::ByoVpcGcp { .. }) => Ok(()),
            (NetworkSettings::ByoVnetAzure { .. }, NetworkSettings::ByoVnetAzure { .. }) => Ok(()),
            _ => Err(AlienError::new(ErrorData::InvalidResourceUpdate {
                resource_id: self.id.clone(),
                reason: "cannot change network type (e.g., from 'create' to 'use-default')"
                    .to_string(),
            })),
        }
    }

    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::<Network>() == 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 test_network_create_settings() {
        let network = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: Some("10.0.0.0/16".to_string()),
                availability_zones: 3,
            })
            .build();

        assert_eq!(network.id(), "default-network");
        match &network.settings {
            NetworkSettings::Create {
                cidr,
                availability_zones,
            } => {
                assert_eq!(cidr.as_deref(), Some("10.0.0.0/16"));
                assert_eq!(*availability_zones, 3);
            }
            _ => panic!("Expected Create settings"),
        }
    }

    #[test]
    fn test_network_byo_vpc_aws_settings() {
        let network = Network::new("default-network".to_string())
            .settings(NetworkSettings::ByoVpcAws {
                vpc_id: "vpc-12345".to_string(),
                public_subnet_ids: vec!["subnet-pub-1".to_string(), "subnet-pub-2".to_string()],
                private_subnet_ids: vec!["subnet-priv-1".to_string(), "subnet-priv-2".to_string()],
                security_group_ids: vec!["sg-123".to_string()],
            })
            .build();

        match &network.settings {
            NetworkSettings::ByoVpcAws {
                vpc_id,
                public_subnet_ids,
                private_subnet_ids,
                security_group_ids,
            } => {
                assert_eq!(vpc_id, "vpc-12345");
                assert_eq!(public_subnet_ids.len(), 2);
                assert_eq!(private_subnet_ids.len(), 2);
                assert_eq!(security_group_ids.len(), 1);
            }
            _ => panic!("Expected ByoVpcAws settings"),
        }
    }

    #[test]
    fn test_network_validate_update_same_type() {
        let network = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: Some("10.0.0.0/16".to_string()),
                availability_zones: 2,
            })
            .build();

        assert!(network.validate_update(&network.clone()).is_ok());
    }

    #[test]
    fn test_network_validate_update_availability_zone_change_fails() {
        let network1 = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: Some("10.0.0.0/16".to_string()),
                availability_zones: 2,
            })
            .build();

        let network2 = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: Some("10.0.0.0/16".to_string()),
                availability_zones: 3,
            })
            .build();

        let error = network1
            .validate_update(&network2)
            .expect_err("changing the zone count should be rejected");
        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
        assert!(error.message.contains("(2 to 3)"), "{}", error.message);
    }

    #[test]
    fn test_network_validate_update_different_type_fails() {
        let network1 = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: Some("10.0.0.0/16".to_string()),
                availability_zones: 2,
            })
            .build();

        let network2 = Network::new("default-network".to_string())
            .settings(NetworkSettings::ByoVpcAws {
                vpc_id: "vpc-12345".to_string(),
                public_subnet_ids: vec![],
                private_subnet_ids: vec![],
                security_group_ids: vec![],
            })
            .build();

        // Changing network type should fail
        let result = network1.validate_update(&network2);
        assert!(result.is_err());
    }

    #[test]
    fn test_network_validate_update_use_default() {
        let network1 = Network::new("default-network".to_string())
            .settings(NetworkSettings::UseDefault)
            .build();

        let network2 = Network::new("default-network".to_string())
            .settings(NetworkSettings::UseDefault)
            .build();

        assert!(network1.validate_update(&network2).is_ok());
    }

    #[test]
    fn test_network_validate_update_use_default_to_create_fails() {
        let network1 = Network::new("default-network".to_string())
            .settings(NetworkSettings::UseDefault)
            .build();

        let network2 = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: None,
                availability_zones: 2,
            })
            .build();

        assert!(network1.validate_update(&network2).is_err());
    }

    #[test]
    fn test_network_serialization() {
        let network = Network::new("default-network".to_string())
            .settings(NetworkSettings::Create {
                cidr: None,
                availability_zones: 2,
            })
            .build();

        let json = serde_json::to_string(&network).unwrap();
        let deserialized: Network = serde_json::from_str(&json).unwrap();
        assert_eq!(network, deserialized);
    }
}