diff --git a/.github/workflows/ci-fast.yml b/.github/workflows/ci-fast.yml index 20858772e..0487c19cc 100644 --- a/.github/workflows/ci-fast.yml +++ b/.github/workflows/ci-fast.yml @@ -133,6 +133,9 @@ jobs: - name: Lint run: pnpm format-and-lint + - name: Enforce max source file length + run: scripts/check-max-lines.sh + - name: TypeScript typecheck env: NODE_OPTIONS: "--max-old-space-size=8192" diff --git a/.github/workflows/ci-fork.yml b/.github/workflows/ci-fork.yml index fd41e089e..ce72f40ab 100644 --- a/.github/workflows/ci-fork.yml +++ b/.github/workflows/ci-fork.yml @@ -84,6 +84,9 @@ jobs: - name: Lint run: pnpm format-and-lint + - name: Enforce max source file length + run: scripts/check-max-lines.sh + - name: TypeScript typecheck env: NODE_OPTIONS: "--max-old-space-size=8192" diff --git a/crates/alien-aws-clients/src/aws/AGENTS.md b/crates/alien-aws-clients/src/aws/AGENTS.md index e311dde12..fd54cf984 100644 --- a/crates/alien-aws-clients/src/aws/AGENTS.md +++ b/crates/alien-aws-clients/src/aws/AGENTS.md @@ -5,4 +5,4 @@ 3. Implement core operations only: OK to skip optional fields/features, but all required fields must be present for compatibility 4. Map service errors: Check AWS docs "Common Errors" section and map to `RemoteResourceNotFound`, `AuthenticationError`, `RateLimitExceeded`, etc 5. Use infrastructure: `.aws_sign_v4()` for auth, `.aws_error_for_status()` for errors, support `service_endpoint_overrides` -6. Add comprehensive tests: Create `tests/aws_[servicename]_client_tests.rs`, follow existing test patterns +6. Add comprehensive tests: Create `tests/aws_[servicename]_client_tests.rs`, follow existing test patterns (s3's tests live in the `tests/aws_s3_client_tests/` directory module, split by topic because of their size) diff --git a/crates/alien-aws-clients/src/aws/ec2/api.rs b/crates/alien-aws-clients/src/aws/ec2/api.rs new file mode 100644 index 000000000..75496cd9c --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/api.rs @@ -0,0 +1,176 @@ +use super::types::*; +use alien_client_core::Result; +use async_trait::async_trait; + +#[cfg(feature = "test-utils")] +use mockall::automock; + +// --------------------------------------------------------------------------- +// EC2 API Trait +// --------------------------------------------------------------------------- + +#[cfg_attr(feature = "test-utils", automock)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait Ec2Api: Send + Sync + std::fmt::Debug { + // VPC Operations + async fn describe_vpcs(&self, request: DescribeVpcsRequest) -> Result; + async fn describe_vpc_attribute( + &self, + request: DescribeVpcAttributeRequest, + ) -> Result; + async fn create_vpc(&self, request: CreateVpcRequest) -> Result; + async fn delete_vpc(&self, vpc_id: &str) -> Result<()>; + async fn modify_vpc_attribute(&self, request: ModifyVpcAttributeRequest) -> Result<()>; + + // Subnet Operations + async fn describe_subnets( + &self, + request: DescribeSubnetsRequest, + ) -> Result; + async fn create_subnet(&self, request: CreateSubnetRequest) -> Result; + async fn delete_subnet(&self, subnet_id: &str) -> Result<()>; + + // Internet Gateway Operations + async fn create_internet_gateway( + &self, + request: CreateInternetGatewayRequest, + ) -> Result; + async fn delete_internet_gateway(&self, internet_gateway_id: &str) -> Result<()>; + async fn attach_internet_gateway(&self, request: AttachInternetGatewayRequest) -> Result<()>; + async fn detach_internet_gateway(&self, request: DetachInternetGatewayRequest) -> Result<()>; + async fn describe_internet_gateways( + &self, + request: DescribeInternetGatewaysRequest, + ) -> Result; + + // NAT Gateway Operations + async fn create_nat_gateway( + &self, + request: CreateNatGatewayRequest, + ) -> Result; + async fn delete_nat_gateway(&self, nat_gateway_id: &str) -> Result; + async fn describe_nat_gateways( + &self, + request: DescribeNatGatewaysRequest, + ) -> Result; + + // Elastic IP Operations + async fn allocate_address( + &self, + request: AllocateAddressRequest, + ) -> Result; + async fn release_address(&self, allocation_id: &str) -> Result<()>; + + // Route Table Operations + async fn describe_route_tables( + &self, + request: DescribeRouteTablesRequest, + ) -> Result; + async fn create_route_table( + &self, + request: CreateRouteTableRequest, + ) -> Result; + async fn delete_route_table(&self, route_table_id: &str) -> Result<()>; + async fn create_route(&self, request: CreateRouteRequest) -> Result<()>; + async fn delete_route(&self, request: DeleteRouteRequest) -> Result<()>; + async fn associate_route_table( + &self, + request: AssociateRouteTableRequest, + ) -> Result; + async fn disassociate_route_table(&self, association_id: &str) -> Result<()>; + + // Security Group Operations + async fn describe_security_groups( + &self, + request: DescribeSecurityGroupsRequest, + ) -> Result; + async fn describe_network_interfaces( + &self, + request: DescribeNetworkInterfacesRequest, + ) -> Result; + async fn create_security_group( + &self, + request: CreateSecurityGroupRequest, + ) -> Result; + async fn delete_security_group(&self, group_id: &str) -> Result<()>; + async fn authorize_security_group_ingress( + &self, + request: AuthorizeSecurityGroupIngressRequest, + ) -> Result<()>; + async fn authorize_security_group_egress( + &self, + request: AuthorizeSecurityGroupEgressRequest, + ) -> Result<()>; + async fn revoke_security_group_ingress( + &self, + request: RevokeSecurityGroupIngressRequest, + ) -> Result<()>; + async fn revoke_security_group_egress( + &self, + request: RevokeSecurityGroupEgressRequest, + ) -> Result<()>; + + // Availability Zone Operations + async fn describe_availability_zones( + &self, + request: DescribeAvailabilityZonesRequest, + ) -> Result; + + // AMI Operations + async fn describe_images( + &self, + request: DescribeImagesRequest, + ) -> Result; + + // Instance Operations + async fn terminate_instances( + &self, + instance_ids: Vec, + ) -> Result; + async fn describe_instances( + &self, + request: DescribeInstancesRequest, + ) -> Result; + + // Volume Operations + async fn create_volume(&self, request: CreateVolumeRequest) -> Result; + async fn modify_volume(&self, request: ModifyVolumeRequest) -> Result; + async fn describe_volumes_modifications( + &self, + request: DescribeVolumesModificationsRequest, + ) -> Result; + async fn delete_volume(&self, volume_id: &str) -> Result<()>; + async fn describe_volumes( + &self, + request: DescribeVolumesRequest, + ) -> Result; + async fn attach_volume(&self, request: AttachVolumeRequest) -> Result; + async fn detach_volume(&self, request: DetachVolumeRequest) -> Result; + + // Launch Template Operations + async fn create_launch_template( + &self, + request: CreateLaunchTemplateRequest, + ) -> Result; + /// Creates a new version of an existing launch template with updated launch template data. + /// ASGs using $Latest will automatically pick up the new version. + /// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateLaunchTemplateVersion.html + async fn create_launch_template_version( + &self, + request: CreateLaunchTemplateVersionRequest, + ) -> Result; + async fn delete_launch_template( + &self, + request: DeleteLaunchTemplateRequest, + ) -> Result; + async fn describe_launch_templates( + &self, + request: DescribeLaunchTemplatesRequest, + ) -> Result; + + // Console Output + /// Gets the console output for an EC2 instance (base64-encoded). + /// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetConsoleOutput.html + async fn get_console_output(&self, instance_id: String) -> Result; +} diff --git a/crates/alien-aws-clients/src/aws/ec2.rs b/crates/alien-aws-clients/src/aws/ec2/client.rs similarity index 51% rename from crates/alien-aws-clients/src/aws/ec2.rs rename to crates/alien-aws-clients/src/aws/ec2/client.rs index 7d61611b2..e948f79b8 100644 --- a/crates/alien-aws-clients/src/aws/ec2.rs +++ b/crates/alien-aws-clients/src/aws/ec2/client.rs @@ -1,40 +1,16 @@ -//! AWS EC2 Client -//! -//! This module provides a client for interacting with AWS EC2 APIs, focused on -//! VPC networking operations including VPCs, Subnets, Internet Gateways, NAT Gateways, -//! Route Tables, Security Groups, and Elastic IPs. -//! -//! # Example -//! -//! ```rust,ignore -//! use alien_aws_clients::ec2::{Ec2Client, Ec2Api, CreateVpcRequest}; -//! use reqwest::Client; -//! -//! let ec2_client = Ec2Client::new(Client::new(), aws_config); -//! let vpc = ec2_client.create_vpc( -//! CreateVpcRequest::builder() -//! .cidr_block("10.0.0.0/16".to_string()) -//! .build() -//! ).await?; -//! ``` - +use super::api::Ec2Api; +use super::types::*; use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig}; use crate::aws::credential_provider::AwsCredentialProvider; use alien_client_core::{ErrorData, Result}; -use base64::{engine::general_purpose::STANDARD, Engine as _}; - use alien_error::ContextError; use async_trait::async_trait; -use bon::Builder; use form_urlencoded; use reqwest::{Client, Method, StatusCode}; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::collections::HashMap; -#[cfg(feature = "test-utils")] -use mockall::automock; - // --------------------------------------------------------------------------- // EC2 Error Response Parsing // --------------------------------------------------------------------------- @@ -59,176 +35,6 @@ struct Ec2ErrorDetails { pub message: String, } -// --------------------------------------------------------------------------- -// EC2 API Trait -// --------------------------------------------------------------------------- - -#[cfg_attr(feature = "test-utils", automock)] -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -pub trait Ec2Api: Send + Sync + std::fmt::Debug { - // VPC Operations - async fn describe_vpcs(&self, request: DescribeVpcsRequest) -> Result; - async fn describe_vpc_attribute( - &self, - request: DescribeVpcAttributeRequest, - ) -> Result; - async fn create_vpc(&self, request: CreateVpcRequest) -> Result; - async fn delete_vpc(&self, vpc_id: &str) -> Result<()>; - async fn modify_vpc_attribute(&self, request: ModifyVpcAttributeRequest) -> Result<()>; - - // Subnet Operations - async fn describe_subnets( - &self, - request: DescribeSubnetsRequest, - ) -> Result; - async fn create_subnet(&self, request: CreateSubnetRequest) -> Result; - async fn delete_subnet(&self, subnet_id: &str) -> Result<()>; - - // Internet Gateway Operations - async fn create_internet_gateway( - &self, - request: CreateInternetGatewayRequest, - ) -> Result; - async fn delete_internet_gateway(&self, internet_gateway_id: &str) -> Result<()>; - async fn attach_internet_gateway(&self, request: AttachInternetGatewayRequest) -> Result<()>; - async fn detach_internet_gateway(&self, request: DetachInternetGatewayRequest) -> Result<()>; - async fn describe_internet_gateways( - &self, - request: DescribeInternetGatewaysRequest, - ) -> Result; - - // NAT Gateway Operations - async fn create_nat_gateway( - &self, - request: CreateNatGatewayRequest, - ) -> Result; - async fn delete_nat_gateway(&self, nat_gateway_id: &str) -> Result; - async fn describe_nat_gateways( - &self, - request: DescribeNatGatewaysRequest, - ) -> Result; - - // Elastic IP Operations - async fn allocate_address( - &self, - request: AllocateAddressRequest, - ) -> Result; - async fn release_address(&self, allocation_id: &str) -> Result<()>; - - // Route Table Operations - async fn describe_route_tables( - &self, - request: DescribeRouteTablesRequest, - ) -> Result; - async fn create_route_table( - &self, - request: CreateRouteTableRequest, - ) -> Result; - async fn delete_route_table(&self, route_table_id: &str) -> Result<()>; - async fn create_route(&self, request: CreateRouteRequest) -> Result<()>; - async fn delete_route(&self, request: DeleteRouteRequest) -> Result<()>; - async fn associate_route_table( - &self, - request: AssociateRouteTableRequest, - ) -> Result; - async fn disassociate_route_table(&self, association_id: &str) -> Result<()>; - - // Security Group Operations - async fn describe_security_groups( - &self, - request: DescribeSecurityGroupsRequest, - ) -> Result; - async fn describe_network_interfaces( - &self, - request: DescribeNetworkInterfacesRequest, - ) -> Result; - async fn create_security_group( - &self, - request: CreateSecurityGroupRequest, - ) -> Result; - async fn delete_security_group(&self, group_id: &str) -> Result<()>; - async fn authorize_security_group_ingress( - &self, - request: AuthorizeSecurityGroupIngressRequest, - ) -> Result<()>; - async fn authorize_security_group_egress( - &self, - request: AuthorizeSecurityGroupEgressRequest, - ) -> Result<()>; - async fn revoke_security_group_ingress( - &self, - request: RevokeSecurityGroupIngressRequest, - ) -> Result<()>; - async fn revoke_security_group_egress( - &self, - request: RevokeSecurityGroupEgressRequest, - ) -> Result<()>; - - // Availability Zone Operations - async fn describe_availability_zones( - &self, - request: DescribeAvailabilityZonesRequest, - ) -> Result; - - // AMI Operations - async fn describe_images( - &self, - request: DescribeImagesRequest, - ) -> Result; - - // Instance Operations - async fn terminate_instances( - &self, - instance_ids: Vec, - ) -> Result; - async fn describe_instances( - &self, - request: DescribeInstancesRequest, - ) -> Result; - - // Volume Operations - async fn create_volume(&self, request: CreateVolumeRequest) -> Result; - async fn modify_volume(&self, request: ModifyVolumeRequest) -> Result; - async fn describe_volumes_modifications( - &self, - request: DescribeVolumesModificationsRequest, - ) -> Result; - async fn delete_volume(&self, volume_id: &str) -> Result<()>; - async fn describe_volumes( - &self, - request: DescribeVolumesRequest, - ) -> Result; - async fn attach_volume(&self, request: AttachVolumeRequest) -> Result; - async fn detach_volume(&self, request: DetachVolumeRequest) -> Result; - - // Launch Template Operations - async fn create_launch_template( - &self, - request: CreateLaunchTemplateRequest, - ) -> Result; - /// Creates a new version of an existing launch template with updated launch template data. - /// ASGs using $Latest will automatically pick up the new version. - /// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateLaunchTemplateVersion.html - async fn create_launch_template_version( - &self, - request: CreateLaunchTemplateVersionRequest, - ) -> Result; - async fn delete_launch_template( - &self, - request: DeleteLaunchTemplateRequest, - ) -> Result; - async fn describe_launch_templates( - &self, - request: DescribeLaunchTemplatesRequest, - ) -> Result; - - // Console Output - /// Gets the console output for an EC2 instance (base64-encoded). - /// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetConsoleOutput.html - async fn get_console_output(&self, instance_id: String) -> Result; -} - // --------------------------------------------------------------------------- // EC2 Client // --------------------------------------------------------------------------- @@ -611,83 +417,6 @@ impl Ec2Client { } } } - - fn create_volume_form_data(request: &CreateVolumeRequest) -> HashMap { - let mut form_data = HashMap::new(); - form_data.insert("Action".to_string(), "CreateVolume".to_string()); - form_data.insert("Version".to_string(), "2016-11-15".to_string()); - form_data.insert( - "AvailabilityZone".to_string(), - request.availability_zone.clone(), - ); - - if let Some(client_token) = &request.client_token { - form_data.insert("ClientToken".to_string(), client_token.clone()); - } - if let Some(size) = request.size { - form_data.insert("Size".to_string(), size.to_string()); - } - if let Some(snapshot_id) = &request.snapshot_id { - form_data.insert("SnapshotId".to_string(), snapshot_id.clone()); - } - if let Some(volume_type) = &request.volume_type { - form_data.insert("VolumeType".to_string(), volume_type.clone()); - } - if let Some(iops) = request.iops { - form_data.insert("Iops".to_string(), iops.to_string()); - } - if let Some(throughput) = request.throughput { - form_data.insert("Throughput".to_string(), throughput.to_string()); - } - if let Some(encrypted) = request.encrypted { - form_data.insert("Encrypted".to_string(), encrypted.to_string()); - } - if let Some(kms_key_id) = &request.kms_key_id { - form_data.insert("KmsKeyId".to_string(), kms_key_id.clone()); - } - if let Some(tag_specs) = &request.tag_specifications { - Self::add_tag_specifications(&mut form_data, tag_specs); - } - - form_data - } - - fn modify_volume_form_data(request: &ModifyVolumeRequest) -> HashMap { - let mut form_data = HashMap::new(); - form_data.insert("Action".to_string(), "ModifyVolume".to_string()); - form_data.insert("Version".to_string(), "2016-11-15".to_string()); - form_data.insert("VolumeId".to_string(), request.volume_id.clone()); - form_data.insert("Size".to_string(), request.size.to_string()); - form_data - } - - fn describe_volumes_modifications_form_data( - request: &DescribeVolumesModificationsRequest, - ) -> HashMap { - let mut form_data = HashMap::new(); - form_data.insert( - "Action".to_string(), - "DescribeVolumesModifications".to_string(), - ); - form_data.insert("Version".to_string(), "2016-11-15".to_string()); - - if let Some(volume_ids) = &request.volume_ids { - for (i, volume_id) in volume_ids.iter().enumerate() { - form_data.insert(format!("VolumeId.{}", i + 1), volume_id.clone()); - } - } - if let Some(filters) = &request.filters { - Self::add_filters(&mut form_data, filters); - } - if let Some(max_results) = request.max_results { - form_data.insert("MaxResults".to_string(), max_results.to_string()); - } - if let Some(next_token) = &request.next_token { - form_data.insert("NextToken".to_string(), next_token.clone()); - } - - form_data - } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -2025,35 +1754,112 @@ impl Ec2Api for Ec2Client { } impl Ec2Client { - /// Append `LaunchTemplateData.CpuOptions.*` form fields. - /// - /// Currently only emits `NestedVirtualization` when set. AWS validates - /// support at launch time and rejects unsupported instance types with a - /// clear error (nested virt is only available on 8th-gen Intel: c8i/m8i/r8i - /// and their flex variants), so no client-side allowlist is needed. - fn add_cpu_options( - form_data: &mut HashMap, - cpu_options: Option<&LaunchTemplateCpuOptions>, - ) { - let Some(cpu_options) = cpu_options else { - return; - }; - if let Some(nested) = &cpu_options.nested_virtualization { - form_data.insert( - "LaunchTemplateData.CpuOptions.NestedVirtualization".to_string(), - nested.clone(), - ); + fn create_volume_form_data(request: &CreateVolumeRequest) -> HashMap { + let mut form_data = HashMap::new(); + form_data.insert("Action".to_string(), "CreateVolume".to_string()); + form_data.insert("Version".to_string(), "2016-11-15".to_string()); + form_data.insert( + "AvailabilityZone".to_string(), + request.availability_zone.clone(), + ); + + if let Some(client_token) = &request.client_token { + form_data.insert("ClientToken".to_string(), client_token.clone()); + } + if let Some(size) = request.size { + form_data.insert("Size".to_string(), size.to_string()); + } + if let Some(snapshot_id) = &request.snapshot_id { + form_data.insert("SnapshotId".to_string(), snapshot_id.clone()); + } + if let Some(volume_type) = &request.volume_type { + form_data.insert("VolumeType".to_string(), volume_type.clone()); + } + if let Some(iops) = request.iops { + form_data.insert("Iops".to_string(), iops.to_string()); + } + if let Some(throughput) = request.throughput { + form_data.insert("Throughput".to_string(), throughput.to_string()); + } + if let Some(encrypted) = request.encrypted { + form_data.insert("Encrypted".to_string(), encrypted.to_string()); + } + if let Some(kms_key_id) = &request.kms_key_id { + form_data.insert("KmsKeyId".to_string(), kms_key_id.clone()); + } + if let Some(tag_specs) = &request.tag_specifications { + Self::add_tag_specifications(&mut form_data, tag_specs); } - } - /// Add IP permissions to form data for security group operations. - fn add_ip_permissions(form_data: &mut HashMap, permissions: &[IpPermission]) { - for (i, perm) in permissions.iter().enumerate() { - let idx = i + 1; - form_data.insert( - format!("IpPermissions.{}.IpProtocol", idx), - perm.ip_protocol.clone(), - ); + form_data + } + + fn modify_volume_form_data(request: &ModifyVolumeRequest) -> HashMap { + let mut form_data = HashMap::new(); + form_data.insert("Action".to_string(), "ModifyVolume".to_string()); + form_data.insert("Version".to_string(), "2016-11-15".to_string()); + form_data.insert("VolumeId".to_string(), request.volume_id.clone()); + form_data.insert("Size".to_string(), request.size.to_string()); + form_data + } + + fn describe_volumes_modifications_form_data( + request: &DescribeVolumesModificationsRequest, + ) -> HashMap { + let mut form_data = HashMap::new(); + form_data.insert( + "Action".to_string(), + "DescribeVolumesModifications".to_string(), + ); + form_data.insert("Version".to_string(), "2016-11-15".to_string()); + + if let Some(volume_ids) = &request.volume_ids { + for (i, volume_id) in volume_ids.iter().enumerate() { + form_data.insert(format!("VolumeId.{}", i + 1), volume_id.clone()); + } + } + if let Some(filters) = &request.filters { + Self::add_filters(&mut form_data, filters); + } + if let Some(max_results) = request.max_results { + form_data.insert("MaxResults".to_string(), max_results.to_string()); + } + if let Some(next_token) = &request.next_token { + form_data.insert("NextToken".to_string(), next_token.clone()); + } + + form_data + } + + /// Append `LaunchTemplateData.CpuOptions.*` form fields. + /// + /// Currently only emits `NestedVirtualization` when set. AWS validates + /// support at launch time and rejects unsupported instance types with a + /// clear error (nested virt is only available on 8th-gen Intel: c8i/m8i/r8i + /// and their flex variants), so no client-side allowlist is needed. + fn add_cpu_options( + form_data: &mut HashMap, + cpu_options: Option<&LaunchTemplateCpuOptions>, + ) { + let Some(cpu_options) = cpu_options else { + return; + }; + if let Some(nested) = &cpu_options.nested_virtualization { + form_data.insert( + "LaunchTemplateData.CpuOptions.NestedVirtualization".to_string(), + nested.clone(), + ); + } + } + + /// Add IP permissions to form data for security group operations. + fn add_ip_permissions(form_data: &mut HashMap, permissions: &[IpPermission]) { + for (i, perm) in permissions.iter().enumerate() { + let idx = i + 1; + form_data.insert( + format!("IpPermissions.{}.IpProtocol", idx), + perm.ip_protocol.clone(), + ); if let Some(from_port) = perm.from_port { form_data.insert( @@ -2122,1848 +1928,5 @@ impl Ec2Client { } } -// --------------------------------------------------------------------------- -// Common Types -// --------------------------------------------------------------------------- - -/// A filter to apply when describing resources. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct Filter { - /// The name of the filter. - pub name: String, - /// The filter values. - pub values: Vec, -} - -/// A tag to apply to a resource. -/// Note: EC2 XML responses use lowercase `` and `` tags. -#[derive(Debug, Clone, Serialize, Deserialize, Builder)] -pub struct Tag { - /// The key of the tag. - pub key: String, - /// The value of the tag. - pub value: String, -} - -/// Tag specification for creating resources with tags. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct TagSpecification { - /// The type of resource to tag. - pub resource_type: String, - /// The tags to apply. - pub tags: Vec, -} - -// --------------------------------------------------------------------------- -// VPC Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to describe VPCs. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeVpcsRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub vpc_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing VPCs. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeVpcsResponse { - #[serde(rename = "vpcSet")] - pub vpc_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VpcSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents a VPC. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Vpc { - pub vpc_id: Option, - pub state: Option, - pub cidr_block: Option, - pub owner_id: Option, - pub instance_tenancy: Option, - pub is_default: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TagSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Request to describe a VPC attribute. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct DescribeVpcAttributeRequest { - pub vpc_id: String, - /// The attribute to describe: "enableDnsSupport" or "enableDnsHostnames" - pub attribute: String, -} - -/// Response from describing a VPC attribute. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeVpcAttributeResponse { - pub vpc_id: Option, - #[serde(rename = "enableDnsSupport")] - pub enable_dns_support: Option, - #[serde(rename = "enableDnsHostnames")] - pub enable_dns_hostnames: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AttributeBooleanValue { - pub value: Option, -} - -/// Request to create a VPC. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateVpcRequest { - pub cidr_block: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_tenancy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub amazon_provided_ipv6_cidr_block: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating a VPC. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateVpcResponse { - pub vpc: Option, -} - -/// Request to modify VPC attributes. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct ModifyVpcAttributeRequest { - pub vpc_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_dns_support: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_dns_hostnames: Option, -} - -// --------------------------------------------------------------------------- -// Subnet Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to describe subnets. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeSubnetsRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub subnet_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing subnets. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeSubnetsResponse { - #[serde(rename = "subnetSet")] - pub subnet_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubnetSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents a subnet. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Subnet { - pub subnet_id: Option, - pub vpc_id: Option, - pub state: Option, - pub cidr_block: Option, - pub availability_zone: Option, - pub availability_zone_id: Option, - pub available_ip_address_count: Option, - pub default_for_az: Option, - pub map_public_ip_on_launch: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -/// Request to create a subnet. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateSubnetRequest { - pub vpc_id: String, - pub cidr_block: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub availability_zone: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub availability_zone_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating a subnet. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateSubnetResponse { - pub subnet: Option, -} - -// --------------------------------------------------------------------------- -// Internet Gateway Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to create an internet gateway. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct CreateInternetGatewayRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating an internet gateway. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateInternetGatewayResponse { - #[serde(rename = "internetGateway")] - pub internet_gateway: Option, -} - -/// Represents an internet gateway. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InternetGateway { - pub internet_gateway_id: Option, - #[serde(rename = "attachmentSet")] - pub attachment_set: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InternetGatewayAttachmentSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InternetGatewayAttachment { - pub vpc_id: Option, - pub state: Option, -} - -/// Request to attach an internet gateway to a VPC. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct AttachInternetGatewayRequest { - pub internet_gateway_id: String, - pub vpc_id: String, -} - -/// Request to detach an internet gateway from a VPC. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct DetachInternetGatewayRequest { - pub internet_gateway_id: String, - pub vpc_id: String, -} - -/// Request to describe internet gateways. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeInternetGatewaysRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub internet_gateway_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing internet gateways. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeInternetGatewaysResponse { - #[serde(rename = "internetGatewaySet")] - pub internet_gateway_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InternetGatewaySet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -// --------------------------------------------------------------------------- -// NAT Gateway Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to create a NAT gateway. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateNatGatewayRequest { - /// The subnet in which to create the NAT gateway. - pub subnet_id: String, - /// [Public NAT only] The allocation ID of the Elastic IP address for the gateway. - #[serde(skip_serializing_if = "Option::is_none")] - pub allocation_id: Option, - /// The connectivity type: "public" (default) or "private". - #[serde(skip_serializing_if = "Option::is_none")] - pub connectivity_type: Option, - /// The private IPv4 address to assign to the NAT gateway. - #[serde(skip_serializing_if = "Option::is_none")] - pub private_ip_address: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating a NAT gateway. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateNatGatewayResponse { - #[serde(rename = "natGateway")] - pub nat_gateway: Option, -} - -/// Represents a NAT gateway. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NatGateway { - pub nat_gateway_id: Option, - pub subnet_id: Option, - pub vpc_id: Option, - pub state: Option, - pub connectivity_type: Option, - #[serde(rename = "natGatewayAddressSet")] - pub nat_gateway_address_set: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NatGatewayAddressSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NatGatewayAddress { - pub allocation_id: Option, - pub network_interface_id: Option, - pub private_ip: Option, - pub public_ip: Option, -} - -/// Response from deleting a NAT gateway. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteNatGatewayResponse { - pub nat_gateway_id: Option, -} - -/// Request to describe NAT gateways. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeNatGatewaysRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub nat_gateway_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing NAT gateways. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeNatGatewaysResponse { - #[serde(rename = "natGatewaySet")] - pub nat_gateway_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NatGatewaySet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -// --------------------------------------------------------------------------- -// Elastic IP Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to allocate an Elastic IP address. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct AllocateAddressRequest { - /// The domain: "vpc" (default) or "standard". - #[serde(skip_serializing_if = "Option::is_none")] - pub domain: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from allocating an Elastic IP address. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AllocateAddressResponse { - pub public_ip: Option, - pub allocation_id: Option, - pub domain: Option, -} - -// --------------------------------------------------------------------------- -// Route Table Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to describe route tables. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeRouteTablesRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub route_table_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing route tables. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeRouteTablesResponse { - #[serde(rename = "routeTableSet")] - pub route_table_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RouteTableSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents a route table. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RouteTable { - pub route_table_id: Option, - pub vpc_id: Option, - #[serde(rename = "routeSet")] - pub route_set: Option, - #[serde(rename = "associationSet")] - pub association_set: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RouteSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents a route in a route table. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Route { - pub destination_cidr_block: Option, - pub gateway_id: Option, - pub nat_gateway_id: Option, - pub instance_id: Option, - pub network_interface_id: Option, - pub vpc_peering_connection_id: Option, - pub transit_gateway_id: Option, - pub state: Option, - pub origin: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RouteTableAssociationSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents an association between a route table and a subnet. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RouteTableAssociation { - pub route_table_association_id: Option, - pub route_table_id: Option, - pub subnet_id: Option, - pub main: Option, -} - -/// Request to create a route table. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateRouteTableRequest { - pub vpc_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating a route table. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateRouteTableResponse { - #[serde(rename = "routeTable")] - pub route_table: Option, -} - -/// Request to create a route. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateRouteRequest { - pub route_table_id: String, - pub destination_cidr_block: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub gateway_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub nat_gateway_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub network_interface_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub vpc_peering_connection_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub transit_gateway_id: Option, -} - -/// Request to delete a route. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct DeleteRouteRequest { - pub route_table_id: String, - pub destination_cidr_block: String, -} - -/// Request to associate a route table with a subnet. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct AssociateRouteTableRequest { - pub route_table_id: String, - pub subnet_id: String, -} - -/// Response from associating a route table. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssociateRouteTableResponse { - pub association_id: Option, -} - -// --------------------------------------------------------------------------- -// Security Group Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to describe security groups. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeSecurityGroupsRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub group_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub group_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing security groups. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeSecurityGroupsResponse { - #[serde(rename = "securityGroupInfo")] - pub security_group_info: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SecurityGroupSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents a security group. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SecurityGroup { - pub group_id: Option, - pub group_name: Option, - pub vpc_id: Option, - pub owner_id: Option, - pub group_description: Option, - #[serde(rename = "ipPermissions")] - pub ip_permissions: Option, - #[serde(rename = "ipPermissionsEgress")] - pub ip_permissions_egress: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -/// Request to describe network interfaces. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeNetworkInterfacesRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub network_interface_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing network interfaces. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeNetworkInterfacesResponse { - #[serde(rename = "networkInterfaceSet")] - pub network_interface_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NetworkInterfaceSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents an EC2 network interface. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NetworkInterface { - pub network_interface_id: Option, - pub status: Option, - pub description: Option, - pub subnet_id: Option, - pub vpc_id: Option, - #[serde(rename = "groupSet")] - pub group_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupIdentifierSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupIdentifier { - pub group_id: Option, - pub group_name: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IpPermissionSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// IP permission in a response (from DescribeSecurityGroups). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IpPermissionResponse { - pub ip_protocol: Option, - pub from_port: Option, - pub to_port: Option, - #[serde(rename = "ipRanges")] - pub ip_ranges: Option, - #[serde(rename = "ipv6Ranges")] - pub ipv6_ranges: Option, - #[serde(rename = "groups")] - pub groups: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IpRangeSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IpRangeResponse { - pub cidr_ip: Option, - pub description: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Ipv6RangeSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Ipv6RangeResponse { - pub cidr_ipv6: Option, - pub description: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UserIdGroupPairSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UserIdGroupPairResponse { - pub group_id: Option, - pub user_id: Option, - pub description: Option, -} - -/// Request to create a security group. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateSecurityGroupRequest { - pub group_name: String, - pub description: String, - pub vpc_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating a security group. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateSecurityGroupResponse { - pub group_id: Option, -} - -/// IP permission for requests (authorize/revoke). -#[derive(Debug, Clone, Serialize, Builder)] -pub struct IpPermission { - /// The IP protocol: tcp, udp, icmp, or -1 for all. - pub ip_protocol: String, - /// The start of port range (or ICMP type). - #[serde(skip_serializing_if = "Option::is_none")] - pub from_port: Option, - /// The end of port range (or ICMP code). - #[serde(skip_serializing_if = "Option::is_none")] - pub to_port: Option, - /// The IPv4 CIDR ranges. - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_ranges: Option>, - /// The IPv6 CIDR ranges. - #[serde(skip_serializing_if = "Option::is_none")] - pub ipv6_ranges: Option>, - /// The security group and AWS account ID pairs. - #[serde(skip_serializing_if = "Option::is_none")] - pub user_id_group_pairs: Option>, -} - -/// IPv4 CIDR range for security group rules. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct IpRange { - pub cidr_ip: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// IPv6 CIDR range for security group rules. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct Ipv6Range { - pub cidr_ipv6: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Security group and AWS account ID pair for security group rules. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct UserIdGroupPair { - #[serde(skip_serializing_if = "Option::is_none")] - pub group_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Request to authorize security group ingress. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct AuthorizeSecurityGroupIngressRequest { - pub group_id: String, - pub ip_permissions: Vec, -} - -/// Request to authorize security group egress. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct AuthorizeSecurityGroupEgressRequest { - pub group_id: String, - pub ip_permissions: Vec, -} - -/// Request to revoke security group ingress. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct RevokeSecurityGroupIngressRequest { - pub group_id: String, - pub ip_permissions: Vec, -} - -/// Request to revoke security group egress. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct RevokeSecurityGroupEgressRequest { - pub group_id: String, - pub ip_permissions: Vec, -} - -// --------------------------------------------------------------------------- -// Availability Zone Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to describe availability zones. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeAvailabilityZonesRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub zone_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub zone_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub all_availability_zones: Option, -} - -/// Response from describing availability zones. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeAvailabilityZonesResponse { - #[serde(rename = "availabilityZoneInfo")] - pub availability_zone_info: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AvailabilityZoneSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents an availability zone. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AvailabilityZone { - pub zone_name: Option, - pub zone_id: Option, - pub zone_state: Option, - pub region_name: Option, - pub zone_type: Option, - pub opt_in_status: Option, -} - -// --------------------------------------------------------------------------- -// AMI Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to describe images (AMIs). -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeImagesRequest { - /// The image IDs. - #[serde(skip_serializing_if = "Option::is_none")] - pub image_ids: Option>, - /// The owners (self, amazon, aws-marketplace, or account ID). - #[serde(skip_serializing_if = "Option::is_none")] - pub owners: Option>, - /// Users that have explicit launch permissions. - #[serde(skip_serializing_if = "Option::is_none")] - pub executable_users: Option>, - /// Filters for the images. - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - /// Whether to include deprecated AMIs. - #[serde(skip_serializing_if = "Option::is_none")] - pub include_deprecated: Option, - /// Maximum results. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - /// Token for pagination. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing images. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeImagesResponse { - #[serde(rename = "imagesSet")] - pub images_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents an AMI. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Image { - pub image_id: Option, - pub name: Option, - pub description: Option, - pub image_location: Option, - pub state: Option, - pub owner_id: Option, - pub creation_date: Option, - pub architecture: Option, - pub platform: Option, - pub platform_details: Option, - pub image_type: Option, - pub root_device_type: Option, - pub root_device_name: Option, - pub virtualization_type: Option, - pub hypervisor: Option, - pub is_public: Option, - pub deprecation_time: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, - #[serde(rename = "blockDeviceMapping")] - pub block_device_mapping: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BlockDeviceMappingSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BlockDeviceMapping { - pub device_name: Option, - pub ebs: Option, - pub virtual_name: Option, - pub no_device: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EbsBlockDevice { - pub snapshot_id: Option, - pub volume_size: Option, - pub volume_type: Option, - pub delete_on_termination: Option, - pub encrypted: Option, - pub iops: Option, - pub throughput: Option, -} - -// --------------------------------------------------------------------------- -// Instance Request/Response Types -// --------------------------------------------------------------------------- - -/// Response from terminating instances. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminateInstancesResponse { - #[serde(rename = "instancesSet")] - pub instances_set: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminatingInstanceSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminatingInstance { - pub instance_id: Option, - pub current_state: Option, - pub previous_state: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstanceState { - pub code: Option, - pub name: Option, -} - -/// Request to describe instances. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeInstancesRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing instances. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeInstancesResponse { - #[serde(rename = "reservationSet")] - pub reservation_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReservationSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Reservation { - pub reservation_id: Option, - pub owner_id: Option, - #[serde(rename = "instancesSet")] - pub instances_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstanceSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Instance { - pub instance_id: Option, - pub image_id: Option, - pub instance_type: Option, - pub key_name: Option, - pub launch_time: Option, - pub placement: Option, - pub private_dns_name: Option, - pub private_ip_address: Option, - pub public_dns_name: Option, - pub public_ip_address: Option, - pub state_reason: Option, - pub instance_state: Option, - pub subnet_id: Option, - pub vpc_id: Option, - pub architecture: Option, - pub root_device_type: Option, - pub root_device_name: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Placement { - pub availability_zone: Option, - pub tenancy: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StateReason { - pub code: Option, - pub message: Option, -} - -// --------------------------------------------------------------------------- -// Volume Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to create a volume. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateVolumeRequest { - /// The availability zone. - pub availability_zone: String, - /// Stable token used by EC2 to make retries idempotent. - #[serde(skip_serializing_if = "Option::is_none")] - pub client_token: Option, - /// The size of the volume in GiBs. - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, - /// The snapshot ID to create the volume from. - #[serde(skip_serializing_if = "Option::is_none")] - pub snapshot_id: Option, - /// The volume type: gp2, gp3, io1, io2, st1, sc1, standard. - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_type: Option, - /// IOPS for io1, io2, or gp3. - #[serde(skip_serializing_if = "Option::is_none")] - pub iops: Option, - /// Throughput for gp3 in MiB/s. - #[serde(skip_serializing_if = "Option::is_none")] - pub throughput: Option, - /// Whether to encrypt the volume. - #[serde(skip_serializing_if = "Option::is_none")] - pub encrypted: Option, - /// The KMS key ID for encryption. - #[serde(skip_serializing_if = "Option::is_none")] - pub kms_key_id: Option, - /// Tags for the volume. - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Response from creating a volume. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateVolumeResponse { - pub volume_id: Option, - pub size: Option, - pub availability_zone: Option, - pub state: Option, - pub volume_type: Option, - pub iops: Option, - pub throughput: Option, - pub encrypted: Option, - pub create_time: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -/// Request to grow an EBS volume. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct ModifyVolumeRequest { - /// ID of the volume to modify. - pub volume_id: String, - /// Target size in GiB. EC2 does not permit shrinking a volume. - pub size: i32, -} - -/// Response returned after starting a volume modification. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModifyVolumeResponse { - pub volume_modification: Option, -} - -/// Request to inspect the latest modifications for EBS volumes. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeVolumesModificationsRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response containing the latest requested volume modifications. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeVolumesModificationsResponse { - #[serde(rename = "volumeModificationSet")] - pub volume_modification_set: Option, - pub next_token: Option, -} - -/// Collection of EBS volume modifications. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VolumeModificationSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Latest modification state reported by EC2 for an EBS volume. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VolumeModification { - pub volume_id: Option, - pub modification_state: Option, - pub status_message: Option, - pub progress: Option, - pub original_size: Option, - pub target_size: Option, - pub start_time: Option, - pub end_time: Option, -} - -/// Request to describe volumes. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeVolumesRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing volumes. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeVolumesResponse { - #[serde(rename = "volumeSet")] - pub volume_set: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VolumeSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents an EBS volume. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Volume { - pub volume_id: Option, - pub size: Option, - pub availability_zone: Option, - #[serde(rename = "status")] - pub state: Option, - pub volume_type: Option, - pub iops: Option, - pub throughput: Option, - pub encrypted: Option, - pub snapshot_id: Option, - pub create_time: Option, - #[serde(rename = "attachmentSet")] - pub attachment_set: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VolumeAttachmentSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VolumeAttachment { - pub volume_id: Option, - pub instance_id: Option, - pub device: Option, - pub state: Option, - pub attach_time: Option, - pub delete_on_termination: Option, -} - -/// Request to attach a volume. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct AttachVolumeRequest { - /// The volume ID. - pub volume_id: String, - /// The instance ID. - pub instance_id: String, - /// The device name (e.g., /dev/sdf). - pub device: String, -} - -/// Response from attaching a volume. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachVolumeResponse { - pub volume_id: Option, - pub instance_id: Option, - pub device: Option, - pub state: Option, - pub attach_time: Option, -} - -/// Request to detach a volume. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct DetachVolumeRequest { - /// The volume ID. - pub volume_id: String, - /// The instance ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_id: Option, - /// The device name. - #[serde(skip_serializing_if = "Option::is_none")] - pub device: Option, - /// Whether to force detach. - #[serde(skip_serializing_if = "Option::is_none")] - pub force: Option, -} - -/// Response from detaching a volume. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DetachVolumeResponse { - pub volume_id: Option, - pub instance_id: Option, - pub device: Option, - pub state: Option, - pub attach_time: Option, -} - -// --------------------------------------------------------------------------- -// Launch Template Request/Response Types -// --------------------------------------------------------------------------- - -/// Request to create a launch template. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct CreateLaunchTemplateRequest { - /// The name for the launch template. - pub launch_template_name: String, - /// A description for the launch template. - #[serde(skip_serializing_if = "Option::is_none")] - pub version_description: Option, - /// The launch template data. - pub launch_template_data: RequestLaunchTemplateData, - /// Tags for the launch template. - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// Launch template data for the request. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct RequestLaunchTemplateData { - /// The ID of the AMI. - #[serde(skip_serializing_if = "Option::is_none")] - pub image_id: Option, - /// The instance type. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_type: Option, - /// The key name. - #[serde(skip_serializing_if = "Option::is_none")] - pub key_name: Option, - /// The user data (base64-encoded). - #[serde(skip_serializing_if = "Option::is_none")] - pub user_data: Option, - /// The security group IDs. - #[serde(skip_serializing_if = "Option::is_none")] - pub security_group_ids: Option>, - /// The IAM instance profile. - #[serde(skip_serializing_if = "Option::is_none")] - pub iam_instance_profile: Option, - /// Block device mappings. - #[serde(skip_serializing_if = "Option::is_none")] - pub block_device_mappings: Option>, - /// Network interfaces. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_interfaces: Option>, - /// Metadata options. - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata_options: Option, - /// CPU options (currently only `NestedVirtualization`). - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_options: Option, - /// Tags to apply to resources created from the launch template. - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_specifications: Option>, -} - -/// IAM instance profile specification for launch template. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct LaunchTemplateIamInstanceProfileSpecification { - #[serde(skip_serializing_if = "Option::is_none")] - pub arn: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -/// Block device mapping for launch template. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct LaunchTemplateBlockDeviceMapping { - #[serde(skip_serializing_if = "Option::is_none")] - pub device_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ebs: Option, -} - -/// EBS block device for launch template. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct LaunchTemplateEbsBlockDevice { - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub volume_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_on_termination: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub encrypted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub iops: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub throughput: Option, -} - -/// Network interface for launch template. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct LaunchTemplateNetworkInterface { - #[serde(skip_serializing_if = "Option::is_none")] - pub device_index: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub associate_public_ip_address: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub subnet_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub groups: Option>, -} - -/// Instance metadata options for launch template. -#[derive(Debug, Clone, Serialize, Builder)] -pub struct LaunchTemplateInstanceMetadataOptions { - #[serde(skip_serializing_if = "Option::is_none")] - pub http_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub http_endpoint: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub http_put_response_hop_limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_metadata_tags: Option, -} - -/// CPU options for launch template. -/// -/// AWS only accepts `nested_virtualization = "enabled"` on 8th-generation -/// Intel instance types (c8i, m8i, r8i, and their flex variants). Other -/// instance types will reject the launch with a clear error — we let AWS -/// be the authority on supported types rather than maintaining our own -/// allowlist. -/// -/// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_LaunchTemplateCpuOptionsRequest.html -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct LaunchTemplateCpuOptions { - /// "enabled" or "disabled". Omit to leave at AWS default ("disabled"). - #[serde(skip_serializing_if = "Option::is_none")] - pub nested_virtualization: Option, -} - -/// Response from creating a launch template. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateLaunchTemplateResponse { - pub launch_template: Option, -} - -/// Request to create a new version of an existing launch template. -/// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateLaunchTemplateVersion.html -#[derive(Debug, Clone, Builder, Default)] -pub struct CreateLaunchTemplateVersionRequest { - pub launch_template_id: Option, - pub launch_template_name: Option, - pub source_version: Option, - pub version_description: Option, - pub launch_template_data: RequestLaunchTemplateData, -} - -/// Response from creating a launch template version. -#[derive(Debug, Deserialize)] -pub struct CreateLaunchTemplateVersionResponse { - #[serde(rename = "launchTemplateVersion")] - pub launch_template_version: Option, -} - -/// A version of a launch template. -#[derive(Debug, Clone, Deserialize)] -pub struct LaunchTemplateVersion { - #[serde(rename = "launchTemplateId")] - pub launch_template_id: Option, - #[serde(rename = "versionNumber")] - pub version_number: Option, -} - -/// Request to delete a launch template. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DeleteLaunchTemplateRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub launch_template_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub launch_template_name: Option, -} - -/// Response from deleting a launch template. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteLaunchTemplateResponse { - pub launch_template: Option, -} - -/// Request to describe launch templates. -#[derive(Debug, Clone, Serialize, Builder, Default)] -pub struct DescribeLaunchTemplatesRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub launch_template_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub launch_template_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filters: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_results: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_token: Option, -} - -/// Response from describing launch templates. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DescribeLaunchTemplatesResponse { - #[serde(rename = "launchTemplates")] - pub launch_templates: Option, - #[serde(rename = "nextToken")] - pub next_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LaunchTemplateSet { - #[serde(rename = "item", default)] - pub items: Vec, -} - -/// Represents a launch template. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LaunchTemplate { - pub launch_template_id: Option, - pub launch_template_name: Option, - pub create_time: Option, - pub created_by: Option, - pub default_version_number: Option, - pub latest_version_number: Option, - #[serde(rename = "tagSet")] - pub tag_set: Option, -} - -/// Response from the GetConsoleOutput API. -/// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetConsoleOutput.html -#[derive(Debug, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct GetConsoleOutputResponse { - /// The ID of the instance. - #[serde(rename = "instanceId")] - pub instance_id: Option, - /// The console output, base64-encoded. - pub output: Option, - /// The time at which the output was last updated. - pub timestamp: Option, -} - -#[cfg(test)] -mod volume_operation_tests { - use super::*; - - #[test] - fn create_volume_maps_idempotency_token_to_ec2_query_parameter() { - let request = CreateVolumeRequest::builder() - .availability_zone("us-west-2a".to_string()) - .client_token("deployment-disk-ordinal-3".to_string()) - .size(64) - .volume_type("gp3".to_string()) - .encrypted(true) - .build(); - - let form = Ec2Client::create_volume_form_data(&request); - - assert_eq!(form.get("Action").map(String::as_str), Some("CreateVolume")); - assert_eq!( - form.get("ClientToken").map(String::as_str), - Some("deployment-disk-ordinal-3") - ); - assert_eq!( - form.get("AvailabilityZone").map(String::as_str), - Some("us-west-2a") - ); - assert_eq!(form.get("Size").map(String::as_str), Some("64")); - assert_eq!(form.get("VolumeType").map(String::as_str), Some("gp3")); - assert_eq!(form.get("Encrypted").map(String::as_str), Some("true")); - } - - #[test] - fn volume_growth_operations_map_to_ec2_query_parameters() { - let modify = ModifyVolumeRequest::builder() - .volume_id("vol-0123456789abcdef0".to_string()) - .size(128) - .build(); - let modify_form = Ec2Client::modify_volume_form_data(&modify); - assert_eq!( - modify_form.get("Action").map(String::as_str), - Some("ModifyVolume") - ); - assert_eq!( - modify_form.get("VolumeId").map(String::as_str), - Some("vol-0123456789abcdef0") - ); - assert_eq!(modify_form.get("Size").map(String::as_str), Some("128")); - - let describe = DescribeVolumesModificationsRequest::builder() - .volume_ids(vec![ - "vol-0123456789abcdef0".to_string(), - "vol-0123456789abcdef1".to_string(), - ]) - .max_results(2) - .next_token("next-page".to_string()) - .build(); - let describe_form = Ec2Client::describe_volumes_modifications_form_data(&describe); - assert_eq!( - describe_form.get("Action").map(String::as_str), - Some("DescribeVolumesModifications") - ); - assert_eq!( - describe_form.get("VolumeId.1").map(String::as_str), - Some("vol-0123456789abcdef0") - ); - assert_eq!( - describe_form.get("VolumeId.2").map(String::as_str), - Some("vol-0123456789abcdef1") - ); - assert_eq!( - describe_form.get("MaxResults").map(String::as_str), - Some("2") - ); - assert_eq!( - describe_form.get("NextToken").map(String::as_str), - Some("next-page") - ); - } - - #[test] - fn volume_modification_responses_deserialize_from_ec2_xml() { - let modify: ModifyVolumeResponse = quick_xml::de::from_str( - r#" - - vol-0123456789abcdef0 - modifying - 0 - 64 - 128 - - "#, - ) - .expect("ModifyVolume response should deserialize"); - let modification = modify - .volume_modification - .expect("ModifyVolume should include its modification"); - assert_eq!( - modification.volume_id.as_deref(), - Some("vol-0123456789abcdef0") - ); - assert_eq!( - modification.modification_state.as_deref(), - Some("modifying") - ); - assert_eq!(modification.progress, Some(0)); - assert_eq!(modification.original_size, Some(64)); - assert_eq!(modification.target_size, Some(128)); - - let described: DescribeVolumesModificationsResponse = quick_xml::de::from_str( - r#" - - - vol-0123456789abcdef0 - completed - 100 - 64 - 128 - 2026-07-20T10:00:00.000Z - - - next-page - "#, - ) - .expect("DescribeVolumesModifications response should deserialize"); - let modifications = described - .volume_modification_set - .expect("response should include modifications"); - assert_eq!(modifications.items.len(), 1); - assert_eq!( - modifications.items[0].modification_state.as_deref(), - Some("completed") - ); - assert_eq!(modifications.items[0].progress, Some(100)); - assert_eq!(described.next_token.as_deref(), Some("next-page")); - } -} - -impl GetConsoleOutputResponse { - /// Decodes the base64-encoded output to a UTF-8 string. - pub fn decode_output(&self) -> Option { - self.output.as_ref().and_then(|b64| { - STANDARD - .decode(b64.trim()) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - }) - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn describe_volumes_deserializes_aws_status_as_volume_state() { - let response: DescribeVolumesResponse = quick_xml::de::from_str( - r#" - - - vol-0123456789abcdef0 - 10 - us-west-2a - available - gp3 - - - "#, - ) - .expect("AWS DescribeVolumes response should deserialize"); - - let volumes = response - .volume_set - .expect("volumeSet should be present") - .items; - assert_eq!(volumes.len(), 1); - assert_eq!( - volumes[0].volume_id.as_deref(), - Some("vol-0123456789abcdef0") - ); - assert_eq!(volumes[0].state.as_deref(), Some("available")); - } - - #[test] - fn launch_template_already_exists_is_a_typed_resource_conflict() { - let response = r#" - - InvalidLaunchTemplateName.AlreadyExistsException - Launch template name already in use. - request-id"#; - - let error = Ec2Client::map_ec2_error( - StatusCode::BAD_REQUEST, - response, - "CreateLaunchTemplate", - "deployment-compute-general-lt", - Some("LaunchTemplateName=deployment-compute-general-lt"), - ); - - assert!(matches!( - error, - Some(ErrorData::RemoteResourceConflict { - resource_type, - resource_name, - .. - }) if resource_type == "EC2 Resource" - && resource_name == "deployment-compute-general-lt" - )); - } - - #[test] - fn missing_volume_is_a_typed_remote_resource_not_found() { - let response = r#" - - InvalidVolume.NotFound - The volume does not exist. - request-id"#; - - let error = Ec2Client::map_ec2_error( - StatusCode::BAD_REQUEST, - response, - "DescribeVolumesModifications", - "vol-0123456789abcdef0", - Some("VolumeId.1=vol-0123456789abcdef0"), - ); - - assert!(matches!( - error, - Some(ErrorData::RemoteResourceNotFound { - resource_type, - resource_name, - }) if resource_type == "Volume" && resource_name == "vol-0123456789abcdef0" - )); - } - - /// Setting `nested_virtualization=Some("enabled")` emits the AWS - /// form-encoded key `LaunchTemplateData.CpuOptions.NestedVirtualization` - /// with value `enabled`. Locks in the exact wire-format string AWS - /// rejects/accepts on the LT create endpoint. - #[test] - fn add_cpu_options_emits_nested_virtualization_key() { - let mut form_data = HashMap::new(); - let cpu_options = LaunchTemplateCpuOptions::builder() - .nested_virtualization("enabled".to_string()) - .build(); - Ec2Client::add_cpu_options(&mut form_data, Some(&cpu_options)); - assert_eq!( - form_data.get("LaunchTemplateData.CpuOptions.NestedVirtualization"), - Some(&"enabled".to_string()) - ); - assert_eq!(form_data.len(), 1); - } - - /// `None` cpu_options → nothing is appended. Guards against accidentally - /// sending an empty `CpuOptions` block on non-privileged daemon deploys. - #[test] - fn add_cpu_options_with_none_emits_nothing() { - let mut form_data = HashMap::new(); - Ec2Client::add_cpu_options(&mut form_data, None); - assert!(form_data.is_empty()); - } - - /// `Some(CpuOptions { nested_virtualization: None })` → nothing appended. - /// Future fields on CpuOptions could be set independently; the encoder - /// should not emit a key for an unset NestedVirtualization specifically. - #[test] - fn add_cpu_options_with_unset_nested_field_emits_nothing() { - let mut form_data = HashMap::new(); - let cpu_options = LaunchTemplateCpuOptions::builder().build(); - Ec2Client::add_cpu_options(&mut form_data, Some(&cpu_options)); - assert!(form_data.is_empty()); - } -} +mod tests; diff --git a/crates/alien-aws-clients/src/aws/ec2/client/tests.rs b/crates/alien-aws-clients/src/aws/ec2/client/tests.rs new file mode 100644 index 000000000..21717f525 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/client/tests.rs @@ -0,0 +1,251 @@ +use super::*; + +#[test] +fn create_volume_maps_idempotency_token_to_ec2_query_parameter() { + let request = CreateVolumeRequest::builder() + .availability_zone("us-west-2a".to_string()) + .client_token("deployment-disk-ordinal-3".to_string()) + .size(64) + .volume_type("gp3".to_string()) + .encrypted(true) + .build(); + + let form = Ec2Client::create_volume_form_data(&request); + + assert_eq!(form.get("Action").map(String::as_str), Some("CreateVolume")); + assert_eq!( + form.get("ClientToken").map(String::as_str), + Some("deployment-disk-ordinal-3") + ); + assert_eq!( + form.get("AvailabilityZone").map(String::as_str), + Some("us-west-2a") + ); + assert_eq!(form.get("Size").map(String::as_str), Some("64")); + assert_eq!(form.get("VolumeType").map(String::as_str), Some("gp3")); + assert_eq!(form.get("Encrypted").map(String::as_str), Some("true")); +} + +#[test] +fn volume_growth_operations_map_to_ec2_query_parameters() { + let modify = ModifyVolumeRequest::builder() + .volume_id("vol-0123456789abcdef0".to_string()) + .size(128) + .build(); + let modify_form = Ec2Client::modify_volume_form_data(&modify); + assert_eq!( + modify_form.get("Action").map(String::as_str), + Some("ModifyVolume") + ); + assert_eq!( + modify_form.get("VolumeId").map(String::as_str), + Some("vol-0123456789abcdef0") + ); + assert_eq!(modify_form.get("Size").map(String::as_str), Some("128")); + + let describe = DescribeVolumesModificationsRequest::builder() + .volume_ids(vec![ + "vol-0123456789abcdef0".to_string(), + "vol-0123456789abcdef1".to_string(), + ]) + .max_results(2) + .next_token("next-page".to_string()) + .build(); + let describe_form = Ec2Client::describe_volumes_modifications_form_data(&describe); + assert_eq!( + describe_form.get("Action").map(String::as_str), + Some("DescribeVolumesModifications") + ); + assert_eq!( + describe_form.get("VolumeId.1").map(String::as_str), + Some("vol-0123456789abcdef0") + ); + assert_eq!( + describe_form.get("VolumeId.2").map(String::as_str), + Some("vol-0123456789abcdef1") + ); + assert_eq!( + describe_form.get("MaxResults").map(String::as_str), + Some("2") + ); + assert_eq!( + describe_form.get("NextToken").map(String::as_str), + Some("next-page") + ); +} + +#[test] +fn volume_modification_responses_deserialize_from_ec2_xml() { + let modify: ModifyVolumeResponse = quick_xml::de::from_str( + r#" + + vol-0123456789abcdef0 + modifying + 0 + 64 + 128 + + "#, + ) + .expect("ModifyVolume response should deserialize"); + let modification = modify + .volume_modification + .expect("ModifyVolume should include its modification"); + assert_eq!( + modification.volume_id.as_deref(), + Some("vol-0123456789abcdef0") + ); + assert_eq!( + modification.modification_state.as_deref(), + Some("modifying") + ); + assert_eq!(modification.progress, Some(0)); + assert_eq!(modification.original_size, Some(64)); + assert_eq!(modification.target_size, Some(128)); + + let described: DescribeVolumesModificationsResponse = quick_xml::de::from_str( + r#" + + + vol-0123456789abcdef0 + completed + 100 + 64 + 128 + 2026-07-20T10:00:00.000Z + + + next-page + "#, + ) + .expect("DescribeVolumesModifications response should deserialize"); + let modifications = described + .volume_modification_set + .expect("response should include modifications"); + assert_eq!(modifications.items.len(), 1); + assert_eq!( + modifications.items[0].modification_state.as_deref(), + Some("completed") + ); + assert_eq!(modifications.items[0].progress, Some(100)); + assert_eq!(described.next_token.as_deref(), Some("next-page")); +} + +#[test] +fn describe_volumes_deserializes_aws_status_as_volume_state() { + let response: DescribeVolumesResponse = quick_xml::de::from_str( + r#" + + + vol-0123456789abcdef0 + 10 + us-west-2a + available + gp3 + + + "#, + ) + .expect("AWS DescribeVolumes response should deserialize"); + + let volumes = response + .volume_set + .expect("volumeSet should be present") + .items; + assert_eq!(volumes.len(), 1); + assert_eq!( + volumes[0].volume_id.as_deref(), + Some("vol-0123456789abcdef0") + ); + assert_eq!(volumes[0].state.as_deref(), Some("available")); +} + +#[test] +fn launch_template_already_exists_is_a_typed_resource_conflict() { + let response = r#" + + InvalidLaunchTemplateName.AlreadyExistsException + Launch template name already in use. + request-id"#; + + let error = Ec2Client::map_ec2_error( + StatusCode::BAD_REQUEST, + response, + "CreateLaunchTemplate", + "deployment-compute-general-lt", + Some("LaunchTemplateName=deployment-compute-general-lt"), + ); + + assert!(matches!( + error, + Some(ErrorData::RemoteResourceConflict { + resource_type, + resource_name, + .. + }) if resource_type == "EC2 Resource" + && resource_name == "deployment-compute-general-lt" + )); +} + +#[test] +fn missing_volume_is_a_typed_remote_resource_not_found() { + let response = r#" + + InvalidVolume.NotFound + The volume does not exist. + request-id"#; + + let error = Ec2Client::map_ec2_error( + StatusCode::BAD_REQUEST, + response, + "DescribeVolumesModifications", + "vol-0123456789abcdef0", + Some("VolumeId.1=vol-0123456789abcdef0"), + ); + + assert!(matches!( + error, + Some(ErrorData::RemoteResourceNotFound { + resource_type, + resource_name, + }) if resource_type == "Volume" && resource_name == "vol-0123456789abcdef0" + )); +} + +/// Setting `nested_virtualization=Some("enabled")` emits the AWS +/// form-encoded key `LaunchTemplateData.CpuOptions.NestedVirtualization` +/// with value `enabled`. Locks in the exact wire-format string AWS +/// rejects/accepts on the LT create endpoint. +#[test] +fn add_cpu_options_emits_nested_virtualization_key() { + let mut form_data = HashMap::new(); + let cpu_options = LaunchTemplateCpuOptions::builder() + .nested_virtualization("enabled".to_string()) + .build(); + Ec2Client::add_cpu_options(&mut form_data, Some(&cpu_options)); + assert_eq!( + form_data.get("LaunchTemplateData.CpuOptions.NestedVirtualization"), + Some(&"enabled".to_string()) + ); + assert_eq!(form_data.len(), 1); +} + +/// `None` cpu_options → nothing is appended. Guards against accidentally +/// sending an empty `CpuOptions` block on non-privileged daemon deploys. +#[test] +fn add_cpu_options_with_none_emits_nothing() { + let mut form_data = HashMap::new(); + Ec2Client::add_cpu_options(&mut form_data, None); + assert!(form_data.is_empty()); +} + +/// `Some(CpuOptions { nested_virtualization: None })` → nothing appended. +/// Future fields on CpuOptions could be set independently; the encoder +/// should not emit a key for an unset NestedVirtualization specifically. +#[test] +fn add_cpu_options_with_unset_nested_field_emits_nothing() { + let mut form_data = HashMap::new(); + let cpu_options = LaunchTemplateCpuOptions::builder().build(); + Ec2Client::add_cpu_options(&mut form_data, Some(&cpu_options)); + assert!(form_data.is_empty()); +} diff --git a/crates/alien-aws-clients/src/aws/ec2/mod.rs b/crates/alien-aws-clients/src/aws/ec2/mod.rs new file mode 100644 index 000000000..8bdc4434f --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/mod.rs @@ -0,0 +1,27 @@ +//! AWS EC2 Client +//! +//! This module provides a client for interacting with AWS EC2 APIs, focused on +//! VPC networking operations including VPCs, Subnets, Internet Gateways, NAT Gateways, +//! Route Tables, Security Groups, and Elastic IPs. +//! +//! # Example +//! +//! ```rust,ignore +//! use alien_aws_clients::ec2::{Ec2Client, Ec2Api, CreateVpcRequest}; +//! use reqwest::Client; +//! +//! let ec2_client = Ec2Client::new(Client::new(), aws_config); +//! let vpc = ec2_client.create_vpc( +//! CreateVpcRequest::builder() +//! .cidr_block("10.0.0.0/16".to_string()) +//! .build() +//! ).await?; +//! ``` + +mod api; +mod client; +mod types; + +pub use api::*; +pub use client::*; +pub use types::*; diff --git a/crates/alien-aws-clients/src/aws/ec2/types/common.rs b/crates/alien-aws-clients/src/aws/ec2/types/common.rs new file mode 100644 index 000000000..b6f064722 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/common.rs @@ -0,0 +1,41 @@ +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Common Types +// --------------------------------------------------------------------------- + +/// A filter to apply when describing resources. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct Filter { + /// The name of the filter. + pub name: String, + /// The filter values. + pub values: Vec, +} + +/// A tag to apply to a resource. +/// Note: EC2 XML responses use lowercase `` and `` tags. +#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +pub struct Tag { + /// The key of the tag. + pub key: String, + /// The value of the tag. + pub value: String, +} + +/// Tag specification for creating resources with tags. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct TagSpecification { + /// The type of resource to tag. + pub resource_type: String, + /// The tags to apply. + pub tags: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TagSet { + #[serde(rename = "item", default)] + pub items: Vec, +} diff --git a/crates/alien-aws-clients/src/aws/ec2/types/instance.rs b/crates/alien-aws-clients/src/aws/ec2/types/instance.rs new file mode 100644 index 000000000..b37dc9a15 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/instance.rs @@ -0,0 +1,267 @@ +use super::common::{Filter, TagSet}; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Availability Zone Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to describe availability zones. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeAvailabilityZonesRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub zone_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub zone_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub all_availability_zones: Option, +} + +/// Response from describing availability zones. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeAvailabilityZonesResponse { + #[serde(rename = "availabilityZoneInfo")] + pub availability_zone_info: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AvailabilityZoneSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents an availability zone. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AvailabilityZone { + pub zone_name: Option, + pub zone_id: Option, + pub zone_state: Option, + pub region_name: Option, + pub zone_type: Option, + pub opt_in_status: Option, +} + +// --------------------------------------------------------------------------- +// AMI Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to describe images (AMIs). +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeImagesRequest { + /// The image IDs. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_ids: Option>, + /// The owners (self, amazon, aws-marketplace, or account ID). + #[serde(skip_serializing_if = "Option::is_none")] + pub owners: Option>, + /// Users that have explicit launch permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub executable_users: Option>, + /// Filters for the images. + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + /// Whether to include deprecated AMIs. + #[serde(skip_serializing_if = "Option::is_none")] + pub include_deprecated: Option, + /// Maximum results. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + /// Token for pagination. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing images. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeImagesResponse { + #[serde(rename = "imagesSet")] + pub images_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents an AMI. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Image { + pub image_id: Option, + pub name: Option, + pub description: Option, + pub image_location: Option, + pub state: Option, + pub owner_id: Option, + pub creation_date: Option, + pub architecture: Option, + pub platform: Option, + pub platform_details: Option, + pub image_type: Option, + pub root_device_type: Option, + pub root_device_name: Option, + pub virtualization_type: Option, + pub hypervisor: Option, + pub is_public: Option, + pub deprecation_time: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, + #[serde(rename = "blockDeviceMapping")] + pub block_device_mapping: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockDeviceMappingSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockDeviceMapping { + pub device_name: Option, + pub ebs: Option, + pub virtual_name: Option, + pub no_device: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EbsBlockDevice { + pub snapshot_id: Option, + pub volume_size: Option, + pub volume_type: Option, + pub delete_on_termination: Option, + pub encrypted: Option, + pub iops: Option, + pub throughput: Option, +} + +// --------------------------------------------------------------------------- +// Instance Request/Response Types +// --------------------------------------------------------------------------- + +/// Response from terminating instances. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminateInstancesResponse { + #[serde(rename = "instancesSet")] + pub instances_set: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminatingInstanceSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminatingInstance { + pub instance_id: Option, + pub current_state: Option, + pub previous_state: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstanceState { + pub code: Option, + pub name: Option, +} + +/// Request to describe instances. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeInstancesRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing instances. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeInstancesResponse { + #[serde(rename = "reservationSet")] + pub reservation_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReservationSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Reservation { + pub reservation_id: Option, + pub owner_id: Option, + #[serde(rename = "instancesSet")] + pub instances_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstanceSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Instance { + pub instance_id: Option, + pub image_id: Option, + pub instance_type: Option, + pub key_name: Option, + pub launch_time: Option, + pub placement: Option, + pub private_dns_name: Option, + pub private_ip_address: Option, + pub public_dns_name: Option, + pub public_ip_address: Option, + pub state_reason: Option, + pub instance_state: Option, + pub subnet_id: Option, + pub vpc_id: Option, + pub architecture: Option, + pub root_device_type: Option, + pub root_device_name: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Placement { + pub availability_zone: Option, + pub tenancy: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateReason { + pub code: Option, + pub message: Option, +} diff --git a/crates/alien-aws-clients/src/aws/ec2/types/launch_template.rs b/crates/alien-aws-clients/src/aws/ec2/types/launch_template.rs new file mode 100644 index 000000000..32c7f1c67 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/launch_template.rs @@ -0,0 +1,260 @@ +use super::common::{Filter, TagSet, TagSpecification}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Launch Template Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to create a launch template. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateLaunchTemplateRequest { + /// The name for the launch template. + pub launch_template_name: String, + /// A description for the launch template. + #[serde(skip_serializing_if = "Option::is_none")] + pub version_description: Option, + /// The launch template data. + pub launch_template_data: RequestLaunchTemplateData, + /// Tags for the launch template. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Launch template data for the request. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct RequestLaunchTemplateData { + /// The ID of the AMI. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_id: Option, + /// The instance type. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_type: Option, + /// The key name. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_name: Option, + /// The user data (base64-encoded). + #[serde(skip_serializing_if = "Option::is_none")] + pub user_data: Option, + /// The security group IDs. + #[serde(skip_serializing_if = "Option::is_none")] + pub security_group_ids: Option>, + /// The IAM instance profile. + #[serde(skip_serializing_if = "Option::is_none")] + pub iam_instance_profile: Option, + /// Block device mappings. + #[serde(skip_serializing_if = "Option::is_none")] + pub block_device_mappings: Option>, + /// Network interfaces. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_interfaces: Option>, + /// Metadata options. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_options: Option, + /// CPU options (currently only `NestedVirtualization`). + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_options: Option, + /// Tags to apply to resources created from the launch template. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// IAM instance profile specification for launch template. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct LaunchTemplateIamInstanceProfileSpecification { + #[serde(skip_serializing_if = "Option::is_none")] + pub arn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// Block device mapping for launch template. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct LaunchTemplateBlockDeviceMapping { + #[serde(skip_serializing_if = "Option::is_none")] + pub device_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ebs: Option, +} + +/// EBS block device for launch template. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct LaunchTemplateEbsBlockDevice { + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_on_termination: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iops: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub throughput: Option, +} + +/// Network interface for launch template. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct LaunchTemplateNetworkInterface { + #[serde(skip_serializing_if = "Option::is_none")] + pub device_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub associate_public_ip_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subnet_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub groups: Option>, +} + +/// Instance metadata options for launch template. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct LaunchTemplateInstanceMetadataOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub http_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub http_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub http_put_response_hop_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_metadata_tags: Option, +} + +/// CPU options for launch template. +/// +/// AWS only accepts `nested_virtualization = "enabled"` on 8th-generation +/// Intel instance types (c8i, m8i, r8i, and their flex variants). Other +/// instance types will reject the launch with a clear error — we let AWS +/// be the authority on supported types rather than maintaining our own +/// allowlist. +/// +/// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_LaunchTemplateCpuOptionsRequest.html +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct LaunchTemplateCpuOptions { + /// "enabled" or "disabled". Omit to leave at AWS default ("disabled"). + #[serde(skip_serializing_if = "Option::is_none")] + pub nested_virtualization: Option, +} + +/// Response from creating a launch template. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLaunchTemplateResponse { + pub launch_template: Option, +} + +/// Request to create a new version of an existing launch template. +/// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateLaunchTemplateVersion.html +#[derive(Debug, Clone, Builder, Default)] +pub struct CreateLaunchTemplateVersionRequest { + pub launch_template_id: Option, + pub launch_template_name: Option, + pub source_version: Option, + pub version_description: Option, + pub launch_template_data: RequestLaunchTemplateData, +} + +/// Response from creating a launch template version. +#[derive(Debug, Deserialize)] +pub struct CreateLaunchTemplateVersionResponse { + #[serde(rename = "launchTemplateVersion")] + pub launch_template_version: Option, +} + +/// A version of a launch template. +#[derive(Debug, Clone, Deserialize)] +pub struct LaunchTemplateVersion { + #[serde(rename = "launchTemplateId")] + pub launch_template_id: Option, + #[serde(rename = "versionNumber")] + pub version_number: Option, +} + +/// Request to delete a launch template. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DeleteLaunchTemplateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_template_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_template_name: Option, +} + +/// Response from deleting a launch template. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLaunchTemplateResponse { + pub launch_template: Option, +} + +/// Request to describe launch templates. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeLaunchTemplatesRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_template_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_template_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing launch templates. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeLaunchTemplatesResponse { + #[serde(rename = "launchTemplates")] + pub launch_templates: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchTemplateSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents a launch template. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchTemplate { + pub launch_template_id: Option, + pub launch_template_name: Option, + pub create_time: Option, + pub created_by: Option, + pub default_version_number: Option, + pub latest_version_number: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +/// Response from the GetConsoleOutput API. +/// See: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetConsoleOutput.html +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GetConsoleOutputResponse { + /// The ID of the instance. + #[serde(rename = "instanceId")] + pub instance_id: Option, + /// The console output, base64-encoded. + pub output: Option, + /// The time at which the output was last updated. + pub timestamp: Option, +} + +impl GetConsoleOutputResponse { + /// Decodes the base64-encoded output to a UTF-8 string. + pub fn decode_output(&self) -> Option { + self.output.as_ref().and_then(|b64| { + STANDARD + .decode(b64.trim()) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + }) + } +} diff --git a/crates/alien-aws-clients/src/aws/ec2/types/mod.rs b/crates/alien-aws-clients/src/aws/ec2/types/mod.rs new file mode 100644 index 000000000..c7e3270cc --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/mod.rs @@ -0,0 +1,13 @@ +mod common; +mod instance; +mod launch_template; +mod network; +mod security_group; +mod volume; + +pub use common::*; +pub use instance::*; +pub use launch_template::*; +pub use network::*; +pub use security_group::*; +pub use volume::*; diff --git a/crates/alien-aws-clients/src/aws/ec2/types/network.rs b/crates/alien-aws-clients/src/aws/ec2/types/network.rs new file mode 100644 index 000000000..c26414e93 --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/network.rs @@ -0,0 +1,528 @@ +use super::common::{Filter, TagSet, TagSpecification}; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// VPC Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to describe VPCs. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeVpcsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub vpc_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing VPCs. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeVpcsResponse { + #[serde(rename = "vpcSet")] + pub vpc_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VpcSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents a VPC. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Vpc { + pub vpc_id: Option, + pub state: Option, + pub cidr_block: Option, + pub owner_id: Option, + pub instance_tenancy: Option, + pub is_default: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +/// Request to describe a VPC attribute. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct DescribeVpcAttributeRequest { + pub vpc_id: String, + /// The attribute to describe: "enableDnsSupport" or "enableDnsHostnames" + pub attribute: String, +} + +/// Response from describing a VPC attribute. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeVpcAttributeResponse { + pub vpc_id: Option, + #[serde(rename = "enableDnsSupport")] + pub enable_dns_support: Option, + #[serde(rename = "enableDnsHostnames")] + pub enable_dns_hostnames: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributeBooleanValue { + pub value: Option, +} + +/// Request to create a VPC. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateVpcRequest { + pub cidr_block: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_tenancy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub amazon_provided_ipv6_cidr_block: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating a VPC. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateVpcResponse { + pub vpc: Option, +} + +/// Request to modify VPC attributes. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct ModifyVpcAttributeRequest { + pub vpc_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_dns_support: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_dns_hostnames: Option, +} + +// --------------------------------------------------------------------------- +// Subnet Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to describe subnets. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeSubnetsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub subnet_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing subnets. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeSubnetsResponse { + #[serde(rename = "subnetSet")] + pub subnet_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubnetSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents a subnet. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Subnet { + pub subnet_id: Option, + pub vpc_id: Option, + pub state: Option, + pub cidr_block: Option, + pub availability_zone: Option, + pub availability_zone_id: Option, + pub available_ip_address_count: Option, + pub default_for_az: Option, + pub map_public_ip_on_launch: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +/// Request to create a subnet. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateSubnetRequest { + pub vpc_id: String, + pub cidr_block: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub availability_zone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub availability_zone_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating a subnet. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSubnetResponse { + pub subnet: Option, +} + +// --------------------------------------------------------------------------- +// Internet Gateway Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to create an internet gateway. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct CreateInternetGatewayRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating an internet gateway. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateInternetGatewayResponse { + #[serde(rename = "internetGateway")] + pub internet_gateway: Option, +} + +/// Represents an internet gateway. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InternetGateway { + pub internet_gateway_id: Option, + #[serde(rename = "attachmentSet")] + pub attachment_set: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InternetGatewayAttachmentSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InternetGatewayAttachment { + pub vpc_id: Option, + pub state: Option, +} + +/// Request to attach an internet gateway to a VPC. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct AttachInternetGatewayRequest { + pub internet_gateway_id: String, + pub vpc_id: String, +} + +/// Request to detach an internet gateway from a VPC. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct DetachInternetGatewayRequest { + pub internet_gateway_id: String, + pub vpc_id: String, +} + +/// Request to describe internet gateways. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeInternetGatewaysRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub internet_gateway_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing internet gateways. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeInternetGatewaysResponse { + #[serde(rename = "internetGatewaySet")] + pub internet_gateway_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InternetGatewaySet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +// --------------------------------------------------------------------------- +// NAT Gateway Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to create a NAT gateway. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateNatGatewayRequest { + /// The subnet in which to create the NAT gateway. + pub subnet_id: String, + /// [Public NAT only] The allocation ID of the Elastic IP address for the gateway. + #[serde(skip_serializing_if = "Option::is_none")] + pub allocation_id: Option, + /// The connectivity type: "public" (default) or "private". + #[serde(skip_serializing_if = "Option::is_none")] + pub connectivity_type: Option, + /// The private IPv4 address to assign to the NAT gateway. + #[serde(skip_serializing_if = "Option::is_none")] + pub private_ip_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating a NAT gateway. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateNatGatewayResponse { + #[serde(rename = "natGateway")] + pub nat_gateway: Option, +} + +/// Represents a NAT gateway. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NatGateway { + pub nat_gateway_id: Option, + pub subnet_id: Option, + pub vpc_id: Option, + pub state: Option, + pub connectivity_type: Option, + #[serde(rename = "natGatewayAddressSet")] + pub nat_gateway_address_set: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NatGatewayAddressSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NatGatewayAddress { + pub allocation_id: Option, + pub network_interface_id: Option, + pub private_ip: Option, + pub public_ip: Option, +} + +/// Response from deleting a NAT gateway. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteNatGatewayResponse { + pub nat_gateway_id: Option, +} + +/// Request to describe NAT gateways. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeNatGatewaysRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub nat_gateway_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing NAT gateways. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeNatGatewaysResponse { + #[serde(rename = "natGatewaySet")] + pub nat_gateway_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NatGatewaySet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +// --------------------------------------------------------------------------- +// Elastic IP Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to allocate an Elastic IP address. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct AllocateAddressRequest { + /// The domain: "vpc" (default) or "standard". + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from allocating an Elastic IP address. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AllocateAddressResponse { + pub public_ip: Option, + pub allocation_id: Option, + pub domain: Option, +} + +// --------------------------------------------------------------------------- +// Route Table Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to describe route tables. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeRouteTablesRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub route_table_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing route tables. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeRouteTablesResponse { + #[serde(rename = "routeTableSet")] + pub route_table_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTableSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents a route table. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTable { + pub route_table_id: Option, + pub vpc_id: Option, + #[serde(rename = "routeSet")] + pub route_set: Option, + #[serde(rename = "associationSet")] + pub association_set: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents a route in a route table. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Route { + pub destination_cidr_block: Option, + pub gateway_id: Option, + pub nat_gateway_id: Option, + pub instance_id: Option, + pub network_interface_id: Option, + pub vpc_peering_connection_id: Option, + pub transit_gateway_id: Option, + pub state: Option, + pub origin: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTableAssociationSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents an association between a route table and a subnet. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTableAssociation { + pub route_table_association_id: Option, + pub route_table_id: Option, + pub subnet_id: Option, + pub main: Option, +} + +/// Request to create a route table. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateRouteTableRequest { + pub vpc_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating a route table. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRouteTableResponse { + #[serde(rename = "routeTable")] + pub route_table: Option, +} + +/// Request to create a route. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateRouteRequest { + pub route_table_id: String, + pub destination_cidr_block: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nat_gateway_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub network_interface_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vpc_peering_connection_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transit_gateway_id: Option, +} + +/// Request to delete a route. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct DeleteRouteRequest { + pub route_table_id: String, + pub destination_cidr_block: String, +} + +/// Request to associate a route table with a subnet. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct AssociateRouteTableRequest { + pub route_table_id: String, + pub subnet_id: String, +} + +/// Response from associating a route table. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssociateRouteTableResponse { + pub association_id: Option, +} diff --git a/crates/alien-aws-clients/src/aws/ec2/types/security_group.rs b/crates/alien-aws-clients/src/aws/ec2/types/security_group.rs new file mode 100644 index 000000000..5da41784c --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/security_group.rs @@ -0,0 +1,272 @@ +use super::common::{Filter, TagSet, TagSpecification}; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Security Group Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to describe security groups. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeSecurityGroupsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub group_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing security groups. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeSecurityGroupsResponse { + #[serde(rename = "securityGroupInfo")] + pub security_group_info: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityGroupSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents a security group. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityGroup { + pub group_id: Option, + pub group_name: Option, + pub vpc_id: Option, + pub owner_id: Option, + pub group_description: Option, + #[serde(rename = "ipPermissions")] + pub ip_permissions: Option, + #[serde(rename = "ipPermissionsEgress")] + pub ip_permissions_egress: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +/// Request to describe network interfaces. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeNetworkInterfacesRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub network_interface_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing network interfaces. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeNetworkInterfacesResponse { + #[serde(rename = "networkInterfaceSet")] + pub network_interface_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkInterfaceSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents an EC2 network interface. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkInterface { + pub network_interface_id: Option, + pub status: Option, + pub description: Option, + pub subnet_id: Option, + pub vpc_id: Option, + #[serde(rename = "groupSet")] + pub group_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupIdentifierSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupIdentifier { + pub group_id: Option, + pub group_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IpPermissionSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// IP permission in a response (from DescribeSecurityGroups). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IpPermissionResponse { + pub ip_protocol: Option, + pub from_port: Option, + pub to_port: Option, + #[serde(rename = "ipRanges")] + pub ip_ranges: Option, + #[serde(rename = "ipv6Ranges")] + pub ipv6_ranges: Option, + #[serde(rename = "groups")] + pub groups: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IpRangeSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IpRangeResponse { + pub cidr_ip: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Ipv6RangeSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Ipv6RangeResponse { + pub cidr_ipv6: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserIdGroupPairSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserIdGroupPairResponse { + pub group_id: Option, + pub user_id: Option, + pub description: Option, +} + +/// Request to create a security group. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateSecurityGroupRequest { + pub group_name: String, + pub description: String, + pub vpc_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating a security group. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSecurityGroupResponse { + pub group_id: Option, +} + +/// IP permission for requests (authorize/revoke). +#[derive(Debug, Clone, Serialize, Builder)] +pub struct IpPermission { + /// The IP protocol: tcp, udp, icmp, or -1 for all. + pub ip_protocol: String, + /// The start of port range (or ICMP type). + #[serde(skip_serializing_if = "Option::is_none")] + pub from_port: Option, + /// The end of port range (or ICMP code). + #[serde(skip_serializing_if = "Option::is_none")] + pub to_port: Option, + /// The IPv4 CIDR ranges. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_ranges: Option>, + /// The IPv6 CIDR ranges. + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv6_ranges: Option>, + /// The security group and AWS account ID pairs. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id_group_pairs: Option>, +} + +/// IPv4 CIDR range for security group rules. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct IpRange { + pub cidr_ip: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// IPv6 CIDR range for security group rules. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct Ipv6Range { + pub cidr_ipv6: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Security group and AWS account ID pair for security group rules. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct UserIdGroupPair { + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Request to authorize security group ingress. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct AuthorizeSecurityGroupIngressRequest { + pub group_id: String, + pub ip_permissions: Vec, +} + +/// Request to authorize security group egress. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct AuthorizeSecurityGroupEgressRequest { + pub group_id: String, + pub ip_permissions: Vec, +} + +/// Request to revoke security group ingress. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct RevokeSecurityGroupIngressRequest { + pub group_id: String, + pub ip_permissions: Vec, +} + +/// Request to revoke security group egress. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct RevokeSecurityGroupEgressRequest { + pub group_id: String, + pub ip_permissions: Vec, +} diff --git a/crates/alien-aws-clients/src/aws/ec2/types/volume.rs b/crates/alien-aws-clients/src/aws/ec2/types/volume.rs new file mode 100644 index 000000000..52a789e8f --- /dev/null +++ b/crates/alien-aws-clients/src/aws/ec2/types/volume.rs @@ -0,0 +1,236 @@ +use super::common::{Filter, TagSet, TagSpecification}; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Volume Request/Response Types +// --------------------------------------------------------------------------- + +/// Request to create a volume. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct CreateVolumeRequest { + /// The availability zone. + pub availability_zone: String, + /// Stable token used by EC2 to make retries idempotent. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_token: Option, + /// The size of the volume in GiBs. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// The snapshot ID to create the volume from. + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot_id: Option, + /// The volume type: gp2, gp3, io1, io2, st1, sc1, standard. + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_type: Option, + /// IOPS for io1, io2, or gp3. + #[serde(skip_serializing_if = "Option::is_none")] + pub iops: Option, + /// Throughput for gp3 in MiB/s. + #[serde(skip_serializing_if = "Option::is_none")] + pub throughput: Option, + /// Whether to encrypt the volume. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, + /// The KMS key ID for encryption. + #[serde(skip_serializing_if = "Option::is_none")] + pub kms_key_id: Option, + /// Tags for the volume. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_specifications: Option>, +} + +/// Response from creating a volume. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateVolumeResponse { + pub volume_id: Option, + pub size: Option, + pub availability_zone: Option, + pub state: Option, + pub volume_type: Option, + pub iops: Option, + pub throughput: Option, + pub encrypted: Option, + pub create_time: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +/// Request to grow an EBS volume. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct ModifyVolumeRequest { + /// ID of the volume to modify. + pub volume_id: String, + /// Target size in GiB. EC2 does not permit shrinking a volume. + pub size: i32, +} + +/// Response returned after starting a volume modification. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModifyVolumeResponse { + pub volume_modification: Option, +} + +/// Request to inspect the latest modifications for EBS volumes. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeVolumesModificationsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response containing the latest requested volume modifications. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeVolumesModificationsResponse { + #[serde(rename = "volumeModificationSet")] + pub volume_modification_set: Option, + pub next_token: Option, +} + +/// Collection of EBS volume modifications. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeModificationSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Latest modification state reported by EC2 for an EBS volume. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeModification { + pub volume_id: Option, + pub modification_state: Option, + pub status_message: Option, + pub progress: Option, + pub original_size: Option, + pub target_size: Option, + pub start_time: Option, + pub end_time: Option, +} + +/// Request to describe volumes. +#[derive(Debug, Clone, Serialize, Builder, Default)] +pub struct DescribeVolumesRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_token: Option, +} + +/// Response from describing volumes. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeVolumesResponse { + #[serde(rename = "volumeSet")] + pub volume_set: Option, + #[serde(rename = "nextToken")] + pub next_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +/// Represents an EBS volume. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Volume { + pub volume_id: Option, + pub size: Option, + pub availability_zone: Option, + #[serde(rename = "status")] + pub state: Option, + pub volume_type: Option, + pub iops: Option, + pub throughput: Option, + pub encrypted: Option, + pub snapshot_id: Option, + pub create_time: Option, + #[serde(rename = "attachmentSet")] + pub attachment_set: Option, + #[serde(rename = "tagSet")] + pub tag_set: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeAttachmentSet { + #[serde(rename = "item", default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeAttachment { + pub volume_id: Option, + pub instance_id: Option, + pub device: Option, + pub state: Option, + pub attach_time: Option, + pub delete_on_termination: Option, +} + +/// Request to attach a volume. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct AttachVolumeRequest { + /// The volume ID. + pub volume_id: String, + /// The instance ID. + pub instance_id: String, + /// The device name (e.g., /dev/sdf). + pub device: String, +} + +/// Response from attaching a volume. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachVolumeResponse { + pub volume_id: Option, + pub instance_id: Option, + pub device: Option, + pub state: Option, + pub attach_time: Option, +} + +/// Request to detach a volume. +#[derive(Debug, Clone, Serialize, Builder)] +pub struct DetachVolumeRequest { + /// The volume ID. + pub volume_id: String, + /// The instance ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_id: Option, + /// The device name. + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, + /// Whether to force detach. + #[serde(skip_serializing_if = "Option::is_none")] + pub force: Option, +} + +/// Response from detaching a volume. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetachVolumeResponse { + pub volume_id: Option, + pub instance_id: Option, + pub device: Option, + pub state: Option, + pub attach_time: Option, +} diff --git a/crates/alien-aws-clients/src/aws/elbv2.rs b/crates/alien-aws-clients/src/aws/elbv2.rs index eb8dfe73b..a7cb6b874 100644 --- a/crates/alien-aws-clients/src/aws/elbv2.rs +++ b/crates/alien-aws-clients/src/aws/elbv2.rs @@ -1997,96 +1997,4 @@ pub struct AlpnPolicyWrapper { } #[cfg(test)] -mod wire_tests { - use super::*; - use alien_core::{AwsClientConfig, AwsCredentials, AwsServiceOverrides}; - use std::{ - collections::HashMap, - io::{Read, Write}, - net::TcpListener, - sync::{Arc, Mutex}, - }; - - #[tokio::test] - async fn modify_load_balancer_attributes_sends_aws_query_request() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let endpoint = format!("http://{}", listener.local_addr().expect("local address")); - let observed = Arc::new(Mutex::new(String::new())); - let captured = observed.clone(); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept request"); - let mut bytes = Vec::new(); - let mut buffer = [0_u8; 4096]; - loop { - let count = stream.read(&mut buffer).expect("read request"); - assert!(count > 0, "request ended before body"); - bytes.extend_from_slice(&buffer[..count]); - if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { - let header_end = end + 4; - let headers = String::from_utf8_lossy(&bytes[..header_end]); - let length: usize = headers - .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length: ") - .and_then(|value| value.parse().ok()) - }) - .expect("content length"); - if bytes.len() >= header_end + length { - *captured.lock().expect("capture lock") = - String::from_utf8(bytes[header_end..header_end + length].to_vec()) - .expect("body utf8"); - break; - } - } - } - let body = "load_balancing.cross_zone.enabledtrue"; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ) - .expect("write response"); - }); - let config = AwsClientConfig { - account_id: "123456789012".to_string(), - region: "us-east-1".to_string(), - credentials: AwsCredentials::AccessKeys { - access_key_id: "test-access".to_string(), - secret_access_key: "test-secret".to_string(), - session_token: None, - }, - service_overrides: Some(AwsServiceOverrides { - endpoints: HashMap::from([("elasticloadbalancing".to_string(), endpoint)]), - }), - }; - Elbv2Client::new(Client::new(), AwsCredentialProvider::from_config_sync(config)) - .modify_load_balancer_attributes(ModifyLoadBalancerAttributesRequest::builder() - .load_balancer_arn("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/example/abc".to_string()) - .attributes(vec![LoadBalancerAttribute { key: "load_balancing.cross_zone.enabled".to_string(), value: "true".to_string() }]).build()) - .await.expect("request should succeed"); - server.join().expect("server should finish"); - let form: HashMap = - form_urlencoded::parse(observed.lock().expect("capture lock").as_bytes()) - .into_owned() - .collect(); - assert_eq!( - form.get("Action").map(String::as_str), - Some("ModifyLoadBalancerAttributes") - ); - assert_eq!(form.get("Version").map(String::as_str), Some("2015-12-01")); - assert_eq!( - form.get("Attributes.member.1.Key").map(String::as_str), - Some("load_balancing.cross_zone.enabled") - ); - assert_eq!( - form.get("Attributes.member.1.Value").map(String::as_str), - Some("true") - ); - assert!(form - .get("LoadBalancerArn") - .is_some_and(|arn| arn.contains("loadbalancer/net/example/abc"))); - assert_eq!(form.len(), 5); - } -} +mod wire_tests; diff --git a/crates/alien-aws-clients/src/aws/elbv2/wire_tests.rs b/crates/alien-aws-clients/src/aws/elbv2/wire_tests.rs new file mode 100644 index 000000000..62639e98c --- /dev/null +++ b/crates/alien-aws-clients/src/aws/elbv2/wire_tests.rs @@ -0,0 +1,104 @@ +use super::*; +use alien_core::{AwsClientConfig, AwsCredentials, AwsServiceOverrides}; +use std::{ + collections::HashMap, + io::{Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, +}; + +#[tokio::test] +async fn modify_load_balancer_attributes_sends_aws_query_request() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let endpoint = format!("http://{}", listener.local_addr().expect("local address")); + let observed = Arc::new(Mutex::new(String::new())); + let captured = observed.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let count = stream.read(&mut buffer).expect("read request"); + assert!(count > 0, "request ended before body"); + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + let header_end = end + 4; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let length: usize = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|value| value.parse().ok()) + }) + .expect("content length"); + if bytes.len() >= header_end + length { + *captured.lock().expect("capture lock") = + String::from_utf8(bytes[header_end..header_end + length].to_vec()) + .expect("body utf8"); + break; + } + } + } + let body = "load_balancing.cross_zone.enabledtrue"; + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write response"); + }); + let config = AwsClientConfig { + account_id: "123456789012".to_string(), + region: "us-east-1".to_string(), + credentials: AwsCredentials::AccessKeys { + access_key_id: "test-access".to_string(), + secret_access_key: "test-secret".to_string(), + session_token: None, + }, + service_overrides: Some(AwsServiceOverrides { + endpoints: HashMap::from([("elasticloadbalancing".to_string(), endpoint)]), + }), + }; + Elbv2Client::new( + Client::new(), + AwsCredentialProvider::from_config_sync(config), + ) + .modify_load_balancer_attributes( + ModifyLoadBalancerAttributesRequest::builder() + .load_balancer_arn( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/example/abc" + .to_string(), + ) + .attributes(vec![LoadBalancerAttribute { + key: "load_balancing.cross_zone.enabled".to_string(), + value: "true".to_string(), + }]) + .build(), + ) + .await + .expect("request should succeed"); + server.join().expect("server should finish"); + let form: HashMap = + form_urlencoded::parse(observed.lock().expect("capture lock").as_bytes()) + .into_owned() + .collect(); + assert_eq!( + form.get("Action").map(String::as_str), + Some("ModifyLoadBalancerAttributes") + ); + assert_eq!(form.get("Version").map(String::as_str), Some("2015-12-01")); + assert_eq!( + form.get("Attributes.member.1.Key").map(String::as_str), + Some("load_balancing.cross_zone.enabled") + ); + assert_eq!( + form.get("Attributes.member.1.Value").map(String::as_str), + Some("true") + ); + assert!(form + .get("LoadBalancerArn") + .is_some_and(|arn| arn.contains("loadbalancer/net/example/abc"))); + assert_eq!(form.len(), 5); +} diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests.rs deleted file mode 100644 index e3e9e7d2e..000000000 --- a/crates/alien-aws-clients/tests/aws_s3_client_tests.rs +++ /dev/null @@ -1,2265 +0,0 @@ -#![cfg(test)] - -use alien_aws_clients::s3::{ - FilterRule, GetObjectRequest, HeadObjectRequest, LambdaFunctionConfiguration, - LifecycleConfiguration, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, - LifecycleRuleStatus, NotificationConfiguration, NotificationFilter, ObjectIdentifier, - PublicAccessBlockConfiguration, PutObjectRequest, S3Api, S3Client, S3KeyFilter, - VersioningStatus, -}; -use alien_aws_clients::AwsCredentialProvider; -use alien_client_core::Error; -use alien_client_core::ErrorData; -use reqwest::Client; -use std::collections::HashSet; -use std::env; -use std::path::PathBuf as StdPathBuf; -use std::sync::Mutex; -use test_context::{test_context, AsyncTestContext}; -use tracing::{info, warn}; -use uuid::Uuid; -use workspace_root; - -// Helper function to put an object using the S3Api -async fn put_test_object( - client: &S3Client, - bucket_name: &str, - object_key: &str, - body: Vec, - content_type: Option<&str>, -) -> Result<(), Error> { - let request = PutObjectRequest::builder() - .bucket(bucket_name.to_string()) - .key(object_key.to_string()) - .body(body) - .maybe_content_type(content_type.map(|s| s.to_string())) - .build(); - - client.put_object(&request).await?; - Ok(()) -} - -struct S3TestContext { - client: S3Client, - created_buckets: Mutex>, -} - -impl AsyncTestContext for S3TestContext { - async fn setup() -> S3TestContext { - let root: StdPathBuf = workspace_root::get_workspace_root(); - dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); - tracing_subscriber::fmt::try_init().ok(); // Initialize tracing - - let region = std::env::var("AWS_MANAGEMENT_REGION") - .expect("AWS_MANAGEMENT_REGION must be set in .env.test"); - let access_key = std::env::var("AWS_MANAGEMENT_ACCESS_KEY_ID") - .expect("AWS_MANAGEMENT_ACCESS_KEY_ID must be set in .env.test"); - let secret_key = std::env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") - .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY must be set in .env.test"); - let account_id = std::env::var("AWS_MANAGEMENT_ACCOUNT_ID") - .expect("AWS_MANAGEMENT_ACCOUNT_ID must be set in .env.test"); - - let aws_config = alien_aws_clients::AwsClientConfig { - account_id, - region, - credentials: alien_aws_clients::AwsCredentials::AccessKeys { - access_key_id: access_key, - secret_access_key: secret_key, - session_token: None, - }, - service_overrides: None, - }; - - let client = S3Client::new( - Client::new(), - AwsCredentialProvider::from_config_sync(aws_config), - ); - - S3TestContext { - client, - created_buckets: Mutex::new(HashSet::new()), - } - } - - async fn teardown(self) { - info!("🧹 Starting S3 test cleanup..."); - - let buckets_to_cleanup = { - let buckets = self.created_buckets.lock().unwrap(); - buckets.clone() - }; - - for bucket_name in buckets_to_cleanup { - self.cleanup_bucket(&bucket_name).await; - } - - info!("✅ S3 test cleanup completed"); - } -} - -impl S3TestContext { - fn track_bucket(&self, bucket_name: &str) { - let mut buckets = self.created_buckets.lock().unwrap(); - buckets.insert(bucket_name.to_string()); - info!("📝 Tracking bucket for cleanup: {}", bucket_name); - } - - fn untrack_bucket(&self, bucket_name: &str) { - let mut buckets = self.created_buckets.lock().unwrap(); - buckets.remove(bucket_name); - info!( - "✅ Bucket {} successfully cleaned up and untracked", - bucket_name - ); - } - - async fn cleanup_bucket(&self, bucket_name: &str) { - info!("🧹 Cleaning up bucket: {}", bucket_name); - - match self.client.empty_bucket(bucket_name).await { - Ok(_) => { - if let Err(e) = self.client.delete_bucket(bucket_name).await { - if !matches!( - e, - Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - } - ) { - warn!( - "Failed to delete bucket {} during cleanup: {:?}", - bucket_name, e - ); - } - } else { - info!("✅ Bucket {} deleted successfully", bucket_name); - } - } - Err(Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - }) => { - info!("🔍 Bucket {} was already deleted", bucket_name); - } - Err(e) => { - warn!( - "Failed to empty bucket {} during cleanup: {:?}", - bucket_name, e - ); - // Try deleting anyway, it might be empty from a previous failed empty attempt - if let Err(e_del) = self.client.delete_bucket(bucket_name).await { - if !matches!( - e_del, - Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - } - ) { - warn!( - "Failed to delete bucket {} during cleanup (after empty failed): {:?}", - bucket_name, e_del - ); - } - } - } - } - } - - fn generate_unique_bucket_name(&self) -> String { - format!( - "alien-test-bucket-{}", - Uuid::new_v4().as_simple().to_string() - ) - } - - async fn create_test_bucket(&self, bucket_name: &str) -> Result<(), Error> { - let result = self.client.create_bucket(bucket_name).await; - if result.is_ok() { - self.track_bucket(bucket_name); - } - result - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_create_and_delete_bucket(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - // Create bucket - let create_result = ctx.create_test_bucket(&bucket_name).await; - assert!( - create_result.is_ok(), - "Failed to create bucket: {:?}", - create_result.err() - ); - - // Test head_bucket - should succeed for existing bucket - let head_result = ctx.client.head_bucket(&bucket_name).await; - assert!( - head_result.is_ok(), - "head_bucket failed for existing bucket: {:?}", - head_result.err() - ); - - // Delete bucket - let delete_result = ctx.client.delete_bucket(&bucket_name).await; - let delete_ok = delete_result.is_ok() - || matches!( - delete_result, - Err(Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - }) - ); - assert!( - delete_ok, - "Failed to delete bucket: {:?}", - delete_result.err() - ); - if delete_ok { - ctx.untrack_bucket(&bucket_name); - } - - // Test head_bucket - should fail for deleted bucket - let head_after_delete_result = ctx.client.head_bucket(&bucket_name).await; - assert!( - matches!( - head_after_delete_result, - Err(Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - }) - ), - "Expected RemoteResourceNotFound after deleting bucket, got {:?}", - head_after_delete_result - ); - - // Verify bucket is deleted by trying to delete again (should fail) - let delete_again_result = ctx.client.delete_bucket(&bucket_name).await; - assert!( - matches!( - delete_again_result, - Err(Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - }) - ), - "Expected RemoteResourceNotFound after deleting bucket, got {:?}", - delete_again_result - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_delete_non_existent_bucket(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); // Name that shouldn't exist - - let result = ctx.client.delete_bucket(&bucket_name).await; - assert!( - matches!( - result, - Err(Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - }) - ), - "Expected RemoteResourceNotFound, got {:?}", - result - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_create_bucket_already_exists(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - // Create bucket first time - let create_first_result = ctx.create_test_bucket(&bucket_name).await; - assert!( - create_first_result.is_ok(), - "Failed to create bucket initially: {:?}", - create_first_result.err() - ); - - // Attempt to create the same bucket again. - // S3 returns 200 OK if the caller owns the bucket and it's in the same region, - // or BucketAlreadyOwnedByYou (which we map to RemoteResourceConflict). - let create_second_result = ctx.client.create_bucket(&bucket_name).await; - assert!( - create_second_result.is_ok() - || matches!( - &create_second_result, - Err(Error { - error: Some(ErrorData::RemoteResourceConflict { .. }), - .. - }) - ), - "Expected Ok or RemoteResourceConflict, got {:?}", - create_second_result - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_bucket_versioning(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for versioning test"); - - // Check initial versioning status (should be None/undefined) - let initial_versioning_result = ctx.client.get_bucket_versioning(&bucket_name).await; - assert!( - initial_versioning_result.is_ok(), - "Failed to get initial bucket versioning: {:?}", - initial_versioning_result.err() - ); - let initial_versioning = initial_versioning_result.unwrap(); - // Initial status should be None (versioning not enabled) - assert!( - initial_versioning.status.is_none(), - "Expected initial versioning status to be None, got {:?}", - initial_versioning.status - ); - - let result_enable = ctx - .client - .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) - .await; - assert!( - result_enable.is_ok(), - "Failed to enable versioning: {:?}", - result_enable.err() - ); - - // Check versioning status after enabling - let enabled_versioning_result = ctx.client.get_bucket_versioning(&bucket_name).await; - assert!( - enabled_versioning_result.is_ok(), - "Failed to get bucket versioning after enabling: {:?}", - enabled_versioning_result.err() - ); - let enabled_versioning = enabled_versioning_result.unwrap(); - assert!( - matches!(enabled_versioning.status, Some(VersioningStatus::Enabled)), - "Expected versioning status to be Enabled, got {:?}", - enabled_versioning.status - ); - - let result_suspend = ctx - .client - .put_bucket_versioning(&bucket_name, VersioningStatus::Suspended) - .await; - assert!( - result_suspend.is_ok(), - "Failed to suspend versioning: {:?}", - result_suspend.err() - ); - - // Check versioning status after suspending - let suspended_versioning_result = ctx.client.get_bucket_versioning(&bucket_name).await; - assert!( - suspended_versioning_result.is_ok(), - "Failed to get bucket versioning after suspending: {:?}", - suspended_versioning_result.err() - ); - let suspended_versioning = suspended_versioning_result.unwrap(); - assert!( - matches!( - suspended_versioning.status, - Some(VersioningStatus::Suspended) - ), - "Expected versioning status to be Suspended, got {:?}", - suspended_versioning.status - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_public_access_block(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for public access block test"); - - let config = PublicAccessBlockConfiguration::builder() - .block_public_acls(true) - .ignore_public_acls(true) - .block_public_policy(true) - .restrict_public_buckets(true) - .build(); - - let result = ctx - .client - .put_public_access_block(&bucket_name, config) - .await; - assert!( - result.is_ok(), - "Failed to put public access block: {:?}", - result.err() - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_and_delete_bucket_policy(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for policy test"); - - // First, disable Block Public Access settings to allow public policies - let public_access_config = PublicAccessBlockConfiguration::builder() - .block_public_acls(false) - .ignore_public_acls(false) - .block_public_policy(false) // This is the key setting - .restrict_public_buckets(false) - .build(); - - ctx.client - .put_public_access_block(&bucket_name, public_access_config) - .await - .expect("Failed to disable Block Public Access settings"); - - let policy_document = format!( - "{{\"Version\":\"2012-10-17\",\"Statement\":[{{\"Sid\":\"PublicReadGetObject\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::{}/*\"]}}]}}", - bucket_name - ); - - let put_result = ctx - .client - .put_bucket_policy(&bucket_name, &policy_document) - .await; - assert!( - put_result.is_ok(), - "Failed to put bucket policy: {:?}", - put_result.err() - ); - - // Note: Verifying typically requires GetBucketPolicy, not in current S3Client. - - let delete_result = ctx.client.delete_bucket_policy(&bucket_name).await; - assert!( - delete_result.is_ok(), - "Failed to delete bucket policy: {:?}", - delete_result.err() - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_delete_non_existent_bucket_policy(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for policy test"); - - // Attempt to delete a policy that was never set. - // S3's DeleteBucketPolicy returns 204 No Content even if no policy exists. - // So, a successful response is expected. - let result = ctx.client.delete_bucket_policy(&bucket_name).await; - assert!( - result.is_ok(), - "Expected success when deleting non-existent policy, got {:?}", - result - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_and_delete_bucket_lifecycle(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for lifecycle test"); - - let lifecycle_config = LifecycleConfiguration::builder() - .rules(vec![LifecycleRule::builder() - .id("TestRule1".to_string()) - .status(LifecycleRuleStatus::Enabled) - .filter(LifecycleRuleFilter::builder().build()) - .expiration(LifecycleExpiration::builder().days(30).build()) - .build()]) - .build(); - - let put_result = ctx - .client - .put_bucket_lifecycle_configuration(&bucket_name, &lifecycle_config) - .await; - assert!( - put_result.is_ok(), - "Failed to put bucket lifecycle configuration: {:?}", - put_result.err() - ); - - // Note: Verifying typically requires GetBucketLifecycleConfiguration, not in current S3Client. - - let delete_result = ctx.client.delete_bucket_lifecycle(&bucket_name).await; - assert!( - delete_result.is_ok(), - "Failed to delete bucket lifecycle: {:?}", - delete_result.err() - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_delete_non_existent_bucket_lifecycle(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for lifecycle test"); - - // S3's DeleteBucketLifecycle returns 204 No Content even if no lifecycle config exists. - let result = ctx.client.delete_bucket_lifecycle(&bucket_name).await; - assert!( - result.is_ok(), - "Expected success when deleting non-existent lifecycle, got {:?}", - result - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_list_objects_v2_basic_and_prefix(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for list_objects_v2 test"); - - // Test on empty bucket first - let list_empty_result = ctx.client.list_objects_v2(&bucket_name, None, None).await; - assert!( - list_empty_result.is_ok(), - "list_objects_v2 on empty bucket failed: {:?}", - list_empty_result.err() - ); - let empty_output = list_empty_result.unwrap(); - assert!(empty_output.contents.is_empty()); - assert_eq!(empty_output.key_count, 0); - assert!(!empty_output.is_truncated); - - // Add some objects - put_test_object( - &ctx.client, - &bucket_name, - "test1.txt", - b"hello".to_vec(), - Some("text/plain"), - ) - .await - .expect("Failed to put object test1.txt"); - put_test_object( - &ctx.client, - &bucket_name, - "test2.txt", - b"world".to_vec(), - Some("text/plain"), - ) - .await - .expect("Failed to put object test2.txt"); - put_test_object( - &ctx.client, - &bucket_name, - "prefix/obj1.txt", - b"data1".to_vec(), - Some("text/plain"), - ) - .await - .expect("Failed to put object prefix/obj1.txt"); - put_test_object( - &ctx.client, - &bucket_name, - "prefix/obj2.txt", - b"data2".to_vec(), - Some("text/plain"), - ) - .await - .expect("Failed to put object prefix/obj2.txt"); - - // Test listing all objects - let list_all_result = ctx.client.list_objects_v2(&bucket_name, None, None).await; - assert!( - list_all_result.is_ok(), - "list_objects_v2 failed: {:?}", - list_all_result.err() - ); - let all_output = list_all_result.unwrap(); - assert_eq!(all_output.contents.len(), 4); - assert_eq!(all_output.key_count, 4); - - // Test listing with prefix - let list_prefix_result = ctx - .client - .list_objects_v2(&bucket_name, Some("prefix/".to_string()), None) - .await; - assert!( - list_prefix_result.is_ok(), - "list_objects_v2 with prefix failed: {:?}", - list_prefix_result.err() - ); - let prefix_output = list_prefix_result.unwrap(); - assert_eq!(prefix_output.contents.len(), 2); - assert_eq!(prefix_output.key_count, 2); - assert!(prefix_output - .contents - .iter() - .all(|obj| obj.key.starts_with("prefix/"))); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_list_objects_v2_pagination(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for list_objects_v2 pagination test"); - - // Add more objects than a single page (e.g., S3 defaults to 1000, but our ListObjectsV2Output doesn't expose max-keys from request) - // For simplicity, let's assume a small number will trigger truncation if we could control max_keys in request. - // The current list_objects_v2 doesn't allow setting max_keys. We'll test continuation if the API returns truncated. - // This test relies on the S3 default behavior or if client.list_objects_v2 internally uses a small max_keys. - // For now, we put a few objects and check if a second call with a token (if provided) works. - - for i in 0..5 { - // Put 5 objects - put_test_object( - &ctx.client, - &bucket_name, - &format!("page_obj_{}.txt", i), - vec![i as u8], - Some("text/plain"), - ) - .await - .unwrap(); - } - - let mut all_keys_retrieved = std::collections::HashSet::new(); - let mut continuation_token: Option = None; - let mut total_key_count = 0; - - loop { - let list_result = ctx - .client - .list_objects_v2(&bucket_name, None, continuation_token) - .await; - assert!( - list_result.is_ok(), - "list_objects_v2 for pagination failed: {:?}", - list_result.err() - ); - let output = list_result.unwrap(); - - for obj in output.contents { - all_keys_retrieved.insert(obj.key.clone()); - } - total_key_count += output.key_count; - - if output.is_truncated { - continuation_token = output.next_continuation_token; - assert!( - continuation_token.is_some(), - "Expected next_continuation_token when is_truncated is true" - ); - } else { - break; - } - } - assert_eq!( - all_keys_retrieved.len(), - 5, - "Expected to retrieve all 5 keys via pagination (if any)" - ); - // The total_key_count from S3 responses might be tricky if it's per page. The primary check is retrieving all keys. -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_delete_objects(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for delete_objects test"); - - put_test_object( - &ctx.client, - &bucket_name, - "delete_me_1.txt", - vec![1], - Some("text/plain"), - ) - .await - .unwrap(); - put_test_object( - &ctx.client, - &bucket_name, - "delete_me_2.txt", - vec![2], - Some("text/plain"), - ) - .await - .unwrap(); - put_test_object( - &ctx.client, - &bucket_name, - "keep_me.txt", - vec![3], - Some("text/plain"), - ) - .await - .unwrap(); - - let objects_to_delete = [ - ObjectIdentifier::builder() - .key("delete_me_1.txt".to_string()) - .build(), - ObjectIdentifier::builder() - .key("delete_me_2.txt".to_string()) - .build(), - ObjectIdentifier::builder() - .key("does_not_exist.txt".to_string()) - .build(), // Test deleting non-existent - ]; - - // Test with Quiet = false (default behavior if not specified, but our client exposes it) - let delete_result_verbose = ctx - .client - .delete_objects(&bucket_name, &objects_to_delete, false) - .await; - assert!( - delete_result_verbose.is_ok(), - "delete_objects (verbose) failed: {:?}", - delete_result_verbose.err() - ); - let verbose_output = delete_result_verbose.unwrap(); - - // S3 behavior: non-existent keys might still be reported as "deleted" in some cases - // The important thing is that the real objects are deleted and no errors occurred - assert!( - verbose_output.deleted.len() >= 2, - "Expected at least 2 objects to be reported as deleted (verbose), got {}", - verbose_output.deleted.len() - ); - assert!(verbose_output - .deleted - .iter() - .any(|d| d.key == "delete_me_1.txt")); - assert!(verbose_output - .deleted - .iter() - .any(|d| d.key == "delete_me_2.txt")); - - // Errors should be empty or minimal for non-existent objects - assert!( - verbose_output.errors.is_empty(), - "Expected no errors in verbose output, got {:?}", - verbose_output.errors - ); - - // Put objects again for the quiet test - put_test_object( - &ctx.client, - &bucket_name, - "delete_me_quiet_1.txt", - vec![1], - Some("text/plain"), - ) - .await - .unwrap(); - put_test_object( - &ctx.client, - &bucket_name, - "delete_me_quiet_2.txt", - vec![2], - Some("text/plain"), - ) - .await - .unwrap(); - - let objects_to_delete_quiet = [ - ObjectIdentifier::builder() - .key("delete_me_quiet_1.txt".to_string()) - .build(), - ObjectIdentifier::builder() - .key("delete_me_quiet_2.txt".to_string()) - .build(), - ]; - - // Test with Quiet = true - let delete_result_quiet = ctx - .client - .delete_objects(&bucket_name, &objects_to_delete_quiet, true) - .await; - assert!( - delete_result_quiet.is_ok(), - "delete_objects (quiet) failed: {:?}", - delete_result_quiet.err() - ); - let quiet_output = delete_result_quiet.unwrap(); - assert!( - quiet_output.deleted.is_empty(), - "Expected no objects in Deleted list for quiet mode" - ); - assert!( - quiet_output.errors.is_empty(), - "Expected no errors in Errors list for quiet mode (for successful deletes)" - ); - - // Verify objects are deleted - let list_after_delete_result = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert_eq!( - list_after_delete_result.contents.len(), - 1, - "Not all specified objects were deleted" - ); - assert_eq!(list_after_delete_result.contents[0].key, "keep_me.txt"); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_empty_bucket_no_versions(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for empty_bucket test"); - - put_test_object( - &ctx.client, - &bucket_name, - "obj1.txt", - vec![1], - Some("text/plain"), - ) - .await - .unwrap(); - put_test_object( - &ctx.client, - &bucket_name, - "prefix/obj2.txt", - vec![2], - Some("text/plain"), - ) - .await - .unwrap(); - - let empty_result = ctx.client.empty_bucket(&bucket_name).await; - assert!( - empty_result.is_ok(), - "empty_bucket failed: {:?}", - empty_result.err() - ); - - let list_after_empty_result = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert!( - list_after_empty_result.contents.is_empty(), - "Bucket was not empty after empty_bucket call" - ); - assert_eq!(list_after_empty_result.key_count, 0); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_empty_bucket_with_versions(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for versioned empty_bucket test"); - ctx.client - .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) - .await - .expect("Failed to enable versioning"); - - // Create multiple versions of an object - put_test_object( - &ctx.client, - &bucket_name, - "versioned_obj.txt", - b"version1".to_vec(), - Some("text/plain"), - ) - .await - .unwrap(); - put_test_object( - &ctx.client, - &bucket_name, - "versioned_obj.txt", - b"version2".to_vec(), - Some("text/plain"), - ) - .await - .unwrap(); - put_test_object( - &ctx.client, - &bucket_name, - "other_obj.txt", - b"other".to_vec(), - Some("text/plain"), - ) - .await - .unwrap(); - - // Add a delete marker - let objects_to_delete_marker = [ObjectIdentifier::builder() - .key("versioned_obj.txt".to_string()) - .build()]; - ctx.client - .delete_objects(&bucket_name, &objects_to_delete_marker, false) - .await - .expect("Failed to create delete marker"); - - let empty_result = ctx.client.empty_bucket(&bucket_name).await; - assert!( - empty_result.is_ok(), - "empty_bucket with versions failed: {:?}", - empty_result.err() - ); - - // Verify bucket is empty (no objects or versions left) - // ListObjectVersions would be the definitive check, but it's not directly exposed. - // empty_bucket aims to delete all versions and markers. - // A ListObjectsV2 might show empty if only delete markers for all objects are left and then removed. - let list_after_empty_result = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert!( - list_after_empty_result.contents.is_empty(), - "Bucket (current versions) not empty after versioned empty_bucket" - ); - - // To be absolutely sure, try to delete the bucket. If it fails due to contents, empty_bucket didn't fully work. - let delete_bucket_result = ctx.client.delete_bucket(&bucket_name).await; - if delete_bucket_result.is_ok() { - ctx.untrack_bucket(&bucket_name); - } - assert!( - delete_bucket_result.is_ok(), - "Failed to delete bucket after versioned empty_bucket, likely not empty: {:?}. List output: {:?}", - delete_bucket_result.err(), - list_after_empty_result - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_invalid_bucket_policy(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - // Disable public access block first - let public_access_config = PublicAccessBlockConfiguration::builder() - .block_public_acls(false) - .ignore_public_acls(false) - .block_public_policy(false) - .restrict_public_buckets(false) - .build(); - ctx.client - .put_public_access_block(&bucket_name, public_access_config) - .await - .expect("Failed to disable Block Public Access settings"); - - // Test invalid JSON policy - let invalid_policy = r#"{"Version":"2012-10-17","Statement":[{"Sid":"InvalidPolicy""#; // Malformed JSON - - let result = ctx - .client - .put_bucket_policy(&bucket_name, invalid_policy) - .await; - assert!(result.is_err(), "Expected error for invalid policy JSON"); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_invalid_lifecycle_configuration(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - // Test lifecycle config with invalid days (negative) - let invalid_lifecycle_config = LifecycleConfiguration::builder() - .rules(vec![LifecycleRule::builder() - .id("InvalidRule".to_string()) - .status(LifecycleRuleStatus::Enabled) - .filter(LifecycleRuleFilter::builder().build()) - .expiration( - LifecycleExpiration::builder() - .days(-1) // Invalid negative days - .build(), - ) - .build()]) - .build(); - - let result = ctx - .client - .put_bucket_lifecycle_configuration(&bucket_name, &invalid_lifecycle_config) - .await; - assert!( - result.is_err(), - "Expected error for invalid lifecycle configuration" - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_delete_objects_large_batch(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - // Create 100 objects (getting close to S3's 1000 object limit per delete request) - for i in 0..100 { - put_test_object( - &ctx.client, - &bucket_name, - &format!("batch_obj_{}.txt", i), - vec![i as u8], - Some("text/plain"), - ) - .await - .expect(&format!("Failed to put object {}", i)); - } - - // Delete all objects in one batch - let objects_to_delete: Vec = (0..100) - .map(|i| { - ObjectIdentifier::builder() - .key(format!("batch_obj_{}.txt", i)) - .build() - }) - .collect(); - - let delete_result = ctx - .client - .delete_objects(&bucket_name, &objects_to_delete, false) - .await; - assert!( - delete_result.is_ok(), - "Failed to delete batch of objects: {:?}", - delete_result.err() - ); - - let delete_output = delete_result.unwrap(); - assert_eq!( - delete_output.deleted.len(), - 100, - "Not all objects were deleted" - ); - assert!( - delete_output.errors.is_empty(), - "Unexpected errors during batch delete" - ); - - // Verify bucket is empty - let list_result = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert!( - list_result.contents.is_empty(), - "Bucket should be empty after batch delete" - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_bucket_name_validation_edge_cases(ctx: &mut S3TestContext) { - // Test edge cases for bucket names (these should be invalid in most regions) - let long_name = "a".repeat(64); // Too long (maximum 63 chars) - let invalid_bucket_names = vec![ - "UPPERCASE", // Uppercase not allowed in most regions - "bucket-with-periods.in.name", // Periods can cause SSL issues - "bucket-ending-with-dash-", // Can't end with dash - "-bucket-starting-with-dash", // Can't start with dash - "bu", // Too short (minimum 3 chars) - &long_name, // Too long (maximum 63 chars) - "bucket_with_underscores", // Underscores not allowed - "bucket..double.dots", // Double dots not allowed - "192.168.1.1", // IP address format not allowed - ]; - - for bucket_name in invalid_bucket_names { - let result = ctx.client.create_bucket(bucket_name).await; - // Note: Some of these might be caught by S3 service validation rather than client-side - // The test is to ensure we handle the errors gracefully - if result.is_ok() { - // If it somehow succeeded, track it for cleanup - ctx.track_bucket(bucket_name); - // In real scenarios, this shouldn't happen for truly invalid names - warn!( - "Warning: Bucket name '{}' was accepted when it shouldn't be", - bucket_name - ); - } - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_concurrent_object_operations(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - // Test concurrent put operations - let mut handles = Vec::new(); - for i in 0..10 { - let root: StdPathBuf = workspace_root::get_workspace_root(); - dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); - - let region = std::env::var("AWS_MANAGEMENT_REGION") - .expect("AWS_MANAGEMENT_REGION must be set in .env.test"); - let access_key = std::env::var("AWS_MANAGEMENT_ACCESS_KEY_ID") - .expect("AWS_MANAGEMENT_ACCESS_KEY_ID must be set in .env.test"); - let secret_key = std::env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") - .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY must be set in .env.test"); - let account_id = std::env::var("AWS_MANAGEMENT_ACCOUNT_ID") - .expect("AWS_MANAGEMENT_ACCOUNT_ID must be set in .env.test"); - - let account_id = - env::var("AWS_MANAGEMENT_ACCOUNT_ID").unwrap_or_else(|_| "123456789012".to_string()); - let aws_config = alien_aws_clients::AwsClientConfig { - account_id, - region: region.clone(), - credentials: alien_aws_clients::AwsCredentials::AccessKeys { - access_key_id: access_key, - secret_access_key: secret_key, - session_token: None, - }, - service_overrides: None, - }; - let client_clone = S3Client::new( - Client::new(), - AwsCredentialProvider::from_config_sync(aws_config), - ); - let bucket_name_clone = bucket_name.clone(); - - let handle = tokio::spawn(async move { - let key = format!("concurrent_obj_{}.txt", i); - let data = format!("Data for object {}", i).into_bytes(); - put_test_object( - &client_clone, - &bucket_name_clone, - &key, - data, - Some("text/plain"), - ) - .await - }); - handles.push(handle); - } - - // Wait for all operations to complete - for handle in handles { - let result = handle.await.expect("Task panicked"); - assert!( - result.is_ok(), - "Concurrent put operation failed: {:?}", - result.err() - ); - } - - // Verify all objects were created - let list_result = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert_eq!( - list_result.contents.len(), - 10, - "Not all concurrent objects were created" - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_object_key_length_limits(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - // Test very long object key (S3 limit is 1024 UTF-8 characters) - let long_key = "a".repeat(1020); // Close to but under the limit - let result = put_test_object( - &ctx.client, - &bucket_name, - &long_key, - b"test".to_vec(), - Some("text/plain"), - ) - .await; - assert!( - result.is_ok(), - "Failed to put object with long key: {:?}", - result.err() - ); - - // Test key that exceeds the limit - let too_long_key = "a".repeat(1025); // Over the limit - let result = put_test_object( - &ctx.client, - &bucket_name, - &too_long_key, - b"test".to_vec(), - Some("text/plain"), - ) - .await; - // This should fail - either client-side validation or server-side rejection - if result.is_ok() { - warn!( - "Warning: Unexpectedly long key was accepted: {} chars", - too_long_key.len() - ); - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_versioned_object_operations(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - ctx.client - .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) - .await - .expect("Failed to enable versioning"); - - let object_key = "versioned-object.txt"; - - // Create multiple versions - for i in 1..=3 { - let content = format!("Version {} content", i); - let result = put_test_object( - &ctx.client, - &bucket_name, - object_key, - content.into_bytes(), - Some("text/plain"), - ) - .await; - assert!( - result.is_ok(), - "Failed to put version {}: {:?}", - i, - result.err() - ); - } - - // List objects should show only the latest version - let list_result = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert_eq!(list_result.contents.len(), 1); - assert_eq!(list_result.contents[0].key, object_key); - - // Delete the current version (creates a delete marker) - let delete_objects = [ObjectIdentifier::builder() - .key(object_key.to_string()) - .build()]; - let delete_result = ctx - .client - .delete_objects(&bucket_name, &delete_objects, false) - .await; - assert!( - delete_result.is_ok(), - "Failed to delete versioned object: {:?}", - delete_result.err() - ); - - // Object should no longer appear in regular listing - let list_after_delete = ctx - .client - .list_objects_v2(&bucket_name, None, None) - .await - .unwrap(); - assert!( - list_after_delete.contents.is_empty(), - "Object should not appear after delete marker" - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_bucket_location_basic(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for location test"); - - let location_result = ctx.client.get_bucket_location(&bucket_name).await; - assert!( - location_result.is_ok(), - "Failed to get bucket location: {:?}", - location_result.err() - ); - - let location_output = location_result.unwrap(); - let region = location_output.region(); - - // The bucket should be in the same region as our client - assert_eq!( - region, - ctx.client.region(), - "Bucket region '{}' doesn't match client region '{}'", - region, - ctx.client.region() - ); - - // Log the raw location constraint for debugging - info!( - "Bucket {} location constraint: {:?}, resolved region: {}", - bucket_name, location_output.location_constraint, region - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_bucket_location_us_east_1_special_case(ctx: &mut S3TestContext) { - // Rebuild client with us-east-1 region - let root: StdPathBuf = workspace_root::get_workspace_root(); - dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); - let access_key_id = - env::var("AWS_MANAGEMENT_ACCESS_KEY_ID").expect("AWS_MANAGEMENT_ACCESS_KEY_ID not set"); - let secret_access_key = env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") - .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY not set"); - let session_token = env::var("AWS_SESSION_TOKEN").ok(); - let account_id = - env::var("AWS_MANAGEMENT_ACCOUNT_ID").unwrap_or_else(|_| "123456789012".to_string()); - let aws_config = alien_aws_clients::AwsClientConfig { - account_id, - region: "us-east-1".to_string(), - credentials: alien_aws_clients::AwsCredentials::AccessKeys { - access_key_id, - secret_access_key, - session_token, - }, - service_overrides: None, - }; - ctx.client = S3Client::new( - Client::new(), - AwsCredentialProvider::from_config_sync(aws_config), - ); - - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket in us-east-1"); - - let location_result = ctx.client.get_bucket_location(&bucket_name).await; - assert!( - location_result.is_ok(), - "Failed to get bucket location in us-east-1: {:?}", - location_result.err() - ); - - let location_output = location_result.unwrap(); - - // In us-east-1, S3 returns null/empty LocationConstraint - assert!( - location_output.location_constraint.is_none() - || location_output.location_constraint.as_deref() == Some(""), - "Expected null/empty LocationConstraint for us-east-1, got: {:?}", - location_output.location_constraint - ); - - // But the region() method should still return "us-east-1" - assert_eq!(location_output.region(), "us-east-1"); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_bucket_location_non_existent_bucket(ctx: &mut S3TestContext) { - let non_existent_bucket = ctx.generate_unique_bucket_name(); - - let location_result = ctx.client.get_bucket_location(&non_existent_bucket).await; - assert!( - location_result.is_err(), - "Expected error for non-existent bucket" - ); - - // Should get a RemoteResourceNotFound error - match location_result.unwrap_err() { - Error { - error: - Some(ErrorData::RemoteResourceNotFound { - resource_type, - resource_name, - .. - }), - .. - } => { - assert_eq!(resource_type, "Bucket"); - assert_eq!(resource_name, non_existent_bucket); - } - other_error => { - panic!("Expected RemoteResourceNotFound, got: {:?}", other_error); - } - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_bucket_location_multiple_buckets(ctx: &mut S3TestContext) { - // Test getting location for multiple buckets to ensure consistency - let bucket_names: Vec = (0..3).map(|_| ctx.generate_unique_bucket_name()).collect(); - - // Create all buckets - for bucket_name in &bucket_names { - ctx.create_test_bucket(bucket_name) - .await - .expect(&format!("Failed to create bucket {}", bucket_name)); - } - - // Get location for all buckets - for bucket_name in &bucket_names { - let location_result = ctx.client.get_bucket_location(bucket_name).await; - assert!( - location_result.is_ok(), - "Failed to get location for bucket {}: {:?}", - bucket_name, - location_result.err() - ); - - let location_output = location_result.unwrap(); - let region = location_output.region(); - - assert_eq!( - region, - ctx.client.region(), - "Bucket {} region '{}' doesn't match client region '{}'", - bucket_name, - region, - ctx.client.region() - ); - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_bucket_location_concurrent_requests(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for concurrent test"); - - // Make multiple concurrent requests for the same bucket location - let mut handles = Vec::new(); - for i in 0..5 { - let root: StdPathBuf = workspace_root::get_workspace_root(); - dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); - - let access_key_id = - env::var("AWS_MANAGEMENT_ACCESS_KEY_ID").expect("AWS_MANAGEMENT_ACCESS_KEY_ID not set"); - let secret_access_key = env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") - .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY not set"); - let region = env::var("AWS_MANAGEMENT_REGION").unwrap_or_else(|_| "us-east-1".to_string()); - let session_token = env::var("AWS_SESSION_TOKEN").ok(); - - let account_id = - env::var("AWS_MANAGEMENT_ACCOUNT_ID").unwrap_or_else(|_| "123456789012".to_string()); - let aws_config = alien_aws_clients::AwsClientConfig { - account_id, - region: region.clone(), - credentials: alien_aws_clients::AwsCredentials::AccessKeys { - access_key_id, - secret_access_key, - session_token, - }, - service_overrides: None, - }; - let client_clone = S3Client::new( - Client::new(), - AwsCredentialProvider::from_config_sync(aws_config), - ); - let bucket_name_clone = bucket_name.clone(); - - let handle = tokio::spawn(async move { - let result = client_clone.get_bucket_location(&bucket_name_clone).await; - (i, result) - }); - handles.push(handle); - } - - // Wait for all requests to complete - for handle in handles { - let (request_id, result) = handle.await.expect("Task panicked"); - assert!( - result.is_ok(), - "Concurrent get_bucket_location request {} failed: {:?}", - request_id, - result.err() - ); - - let location_output = result.unwrap(); - assert_eq!( - location_output.region(), - ctx.client.region(), - "Request {} returned wrong region", - request_id - ); - } -} - -// ------------------------------------------------------------------------- -// Object Operations Tests (PutObject, GetObject, HeadObject) -// ------------------------------------------------------------------------- - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_and_get_object(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let object_key = "test-object.txt"; - let content = b"Hello, World!"; - - // Put object - let put_request = PutObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .body(content.to_vec()) - .content_type("text/plain".to_string()) - .build(); - - let put_result = ctx.client.put_object(&put_request).await; - assert!( - put_result.is_ok(), - "Failed to put object: {:?}", - put_result.err() - ); - let put_output = put_result.unwrap(); - assert!( - put_output.e_tag.is_some(), - "Expected ETag in PutObject response" - ); - - // Get object - let get_request = GetObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .build(); - - let get_result = ctx.client.get_object(&get_request).await; - assert!( - get_result.is_ok(), - "Failed to get object: {:?}", - get_result.err() - ); - let get_output = get_result.unwrap(); - - assert_eq!(get_output.body, content, "Object content doesn't match"); - assert_eq!(get_output.content_type, Some("text/plain".to_string())); - assert!( - get_output.e_tag.is_some(), - "Expected ETag in GetObject response" - ); - assert!( - get_output.last_modified.is_some(), - "Expected LastModified in GetObject response" - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_head_object(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let object_key = "test-head-object.txt"; - let content = b"Test content for HEAD"; - - // Put object first - put_test_object( - &ctx.client, - &bucket_name, - object_key, - content.to_vec(), - Some("text/plain"), - ) - .await - .expect("Failed to put object"); - - // Head object - let head_request = HeadObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .build(); - - let head_result = ctx.client.head_object(&head_request).await; - assert!( - head_result.is_ok(), - "Failed to head object: {:?}", - head_result.err() - ); - let head_output = head_result.unwrap(); - - assert_eq!(head_output.content_type, Some("text/plain".to_string())); - assert_eq!(head_output.content_length, Some(content.len() as i64)); - assert!( - head_output.e_tag.is_some(), - "Expected ETag in HeadObject response" - ); - assert!( - head_output.last_modified.is_some(), - "Expected LastModified in HeadObject response" - ); - assert_eq!( - head_output.delete_marker, None, - "Should not have delete marker" - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_object_non_existent(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let get_request = GetObjectRequest::builder() - .bucket(bucket_name) - .key("non-existent-key.txt".to_string()) - .build(); - - let get_result = ctx.client.get_object(&get_request).await; - assert!( - get_result.is_err(), - "Expected error for non-existent object" - ); - - match get_result.unwrap_err() { - Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - } => { - // Expected error - } - other_error => { - panic!("Expected RemoteResourceNotFound, got: {:?}", other_error); - } - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_head_object_non_existent(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let head_request = HeadObjectRequest::builder() - .bucket(bucket_name) - .key("non-existent-key.txt".to_string()) - .build(); - - let head_result = ctx.client.head_object(&head_request).await; - assert!( - head_result.is_err(), - "Expected error for non-existent object" - ); - - match head_result.unwrap_err() { - Error { - error: Some(ErrorData::RemoteResourceNotFound { .. }), - .. - } => { - // Expected error - } - other_error => { - panic!("Expected RemoteResourceNotFound, got: {:?}", other_error); - } - } -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_get_object_with_range(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let object_key = "test-range-object.txt"; - let content = b"0123456789ABCDEFGHIJ"; - - // Put object - put_test_object( - &ctx.client, - &bucket_name, - object_key, - content.to_vec(), - Some("text/plain"), - ) - .await - .expect("Failed to put object"); - - // Get object with range (first 10 bytes) - let get_request = GetObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .range("bytes=0-9".to_string()) - .build(); - - let get_result = ctx.client.get_object(&get_request).await; - assert!( - get_result.is_ok(), - "Failed to get object with range: {:?}", - get_result.err() - ); - let get_output = get_result.unwrap(); - - assert_eq!( - get_output.body, b"0123456789", - "Range request didn't return correct bytes" - ); - assert_eq!(get_output.content_length, Some(10)); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_object_with_metadata(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let object_key = "test-metadata-object.txt"; - let content = b"Test content"; - - // Put object with storage class - let put_request = PutObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .body(content.to_vec()) - .content_type("text/plain".to_string()) - .storage_class("STANDARD".to_string()) - .build(); - - let put_result = ctx.client.put_object(&put_request).await; - assert!( - put_result.is_ok(), - "Failed to put object with metadata: {:?}", - put_result.err() - ); - - // Verify with HEAD - let head_request = HeadObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .build(); - - let head_result = ctx.client.head_object(&head_request).await; - assert!( - head_result.is_ok(), - "Failed to head object: {:?}", - head_result.err() - ); - let head_output = head_result.unwrap(); - - assert_eq!(head_output.content_type, Some("text/plain".to_string())); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_get_large_object(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - - let object_key = "large-object.bin"; - // Create a 1MB object - let content: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); - - // Put object - let put_request = PutObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .body(content.clone()) - .content_type("application/octet-stream".to_string()) - .build(); - - let put_result = ctx.client.put_object(&put_request).await; - assert!( - put_result.is_ok(), - "Failed to put large object: {:?}", - put_result.err() - ); - - // Get object - let get_request = GetObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .build(); - - let get_result = ctx.client.get_object(&get_request).await; - assert!( - get_result.is_ok(), - "Failed to get large object: {:?}", - get_result.err() - ); - let get_output = get_result.unwrap(); - - assert_eq!( - get_output.body, content, - "Large object content doesn't match" - ); - assert_eq!(get_output.content_length, Some(content.len() as i64)); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_versioned_get_and_head(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket"); - ctx.client - .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) - .await - .expect("Failed to enable versioning"); - - let object_key = "versioned-object.txt"; - let content_v1 = b"Version 1"; - let content_v2 = b"Version 2"; - - // Put version 1 - let put_request_v1 = PutObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .body(content_v1.to_vec()) - .content_type("text/plain".to_string()) - .build(); - - let put_result_v1 = ctx.client.put_object(&put_request_v1).await; - assert!( - put_result_v1.is_ok(), - "Failed to put version 1: {:?}", - put_result_v1.err() - ); - let version_id_1 = put_result_v1 - .unwrap() - .version_id - .expect("Expected version ID for v1"); - - // Put version 2 - let put_request_v2 = PutObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .body(content_v2.to_vec()) - .content_type("text/plain".to_string()) - .build(); - - let put_result_v2 = ctx.client.put_object(&put_request_v2).await; - assert!( - put_result_v2.is_ok(), - "Failed to put version 2: {:?}", - put_result_v2.err() - ); - let version_id_2 = put_result_v2 - .unwrap() - .version_id - .expect("Expected version ID for v2"); - - // Get version 1 - let get_request_v1 = GetObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .version_id(version_id_1.clone()) - .build(); - - let get_result_v1 = ctx.client.get_object(&get_request_v1).await; - assert!( - get_result_v1.is_ok(), - "Failed to get version 1: {:?}", - get_result_v1.err() - ); - assert_eq!(get_result_v1.unwrap().body, content_v1); - - // Get version 2 (latest) - let get_request_v2 = GetObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .build(); - - let get_result_v2 = ctx.client.get_object(&get_request_v2).await; - assert!( - get_result_v2.is_ok(), - "Failed to get version 2: {:?}", - get_result_v2.err() - ); - assert_eq!(get_result_v2.unwrap().body, content_v2); - - // Head version 1 - let head_request_v1 = HeadObjectRequest::builder() - .bucket(bucket_name.clone()) - .key(object_key.to_string()) - .version_id(version_id_1) - .build(); - - let head_result_v1 = ctx.client.head_object(&head_request_v1).await; - assert!( - head_result_v1.is_ok(), - "Failed to head version 1: {:?}", - head_result_v1.err() - ); - assert_eq!( - head_result_v1.unwrap().content_length, - Some(content_v1.len() as i64) - ); -} - -// ------------------------------------------------------------------------- -// Notification configuration tests -// ------------------------------------------------------------------------- - -/// S3 notification configurations require a valid Lambda ARN with the correct -/// resource-based policy allowing S3 to invoke it. Since integration tests may -/// not have a Lambda available, we test the serialization/deserialization -/// round-trip of the notification types and verify the get/put API calls work -/// against a real bucket using an empty configuration (which is always valid). - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_and_get_empty_notification_configuration(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for notification config test"); - - // Put an empty notification configuration (clears any existing notifications) - let empty_config = NotificationConfiguration::default(); - ctx.client - .put_bucket_notification_configuration(&bucket_name, &empty_config) - .await - .expect("Failed to put empty notification configuration"); - - // Get the notification configuration back — should be empty - let retrieved_config = ctx - .client - .get_bucket_notification_configuration(&bucket_name) - .await - .expect("Failed to get notification configuration"); - - assert!( - retrieved_config.lambda_function_configurations.is_empty(), - "Expected empty lambda function configurations after putting empty config, got {:?}", - retrieved_config.lambda_function_configurations - ); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_put_and_get_bucket_notification_configuration(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for notification config test"); - - // Construct a notification configuration with a Lambda function. - // NOTE: This requires a real Lambda ARN with S3 invocation permission. - // If the env var is not set, we skip the actual put/get and only verify - // that the types serialize correctly. - let lambda_arn = match env::var("AWS_TEST_LAMBDA_ARN") { - Ok(arn) if !arn.is_empty() => arn, - _ => { - info!("AWS_TEST_LAMBDA_ARN not set — skipping live notification configuration test, verifying serialization only"); - - let config = NotificationConfiguration { - lambda_function_configurations: vec![LambdaFunctionConfiguration { - id: Some("test-notification".to_string()), - lambda_function_arn: "arn:aws:lambda:us-east-1:123456789012:function:test-fn" - .to_string(), - events: vec!["s3:ObjectCreated:*".to_string()], - filter: None, - }], - }; - - // Verify serialization round-trip via quick_xml - let xml = quick_xml::se::to_string_with_root("NotificationConfiguration", &config) - .expect("Failed to serialize NotificationConfiguration"); - assert!( - xml.contains("s3:ObjectCreated:*"), - "Serialized XML should contain the event type" - ); - assert!( - xml.contains("arn:aws:lambda:us-east-1:123456789012:function:test-fn"), - "Serialized XML should contain the Lambda ARN" - ); - - let deserialized: NotificationConfiguration = quick_xml::de::from_str(&xml) - .expect("Failed to deserialize NotificationConfiguration"); - assert_eq!(deserialized.lambda_function_configurations.len(), 1); - assert_eq!( - deserialized.lambda_function_configurations[0].lambda_function_arn, - "arn:aws:lambda:us-east-1:123456789012:function:test-fn" - ); - assert_eq!( - deserialized.lambda_function_configurations[0].events, - vec!["s3:ObjectCreated:*"] - ); - - return; - } - }; - - let config = NotificationConfiguration { - lambda_function_configurations: vec![LambdaFunctionConfiguration { - id: Some("test-notification".to_string()), - lambda_function_arn: lambda_arn.clone(), - events: vec!["s3:ObjectCreated:*".to_string()], - filter: None, - }], - }; - - ctx.client - .put_bucket_notification_configuration(&bucket_name, &config) - .await - .expect("Failed to put notification configuration"); - - let retrieved_config = ctx - .client - .get_bucket_notification_configuration(&bucket_name) - .await - .expect("Failed to get notification configuration"); - - assert_eq!( - retrieved_config.lambda_function_configurations.len(), - 1, - "Expected exactly one lambda function configuration" - ); - assert_eq!( - retrieved_config.lambda_function_configurations[0].lambda_function_arn, lambda_arn, - "Lambda function ARN should match" - ); - assert_eq!( - retrieved_config.lambda_function_configurations[0].events, - vec!["s3:ObjectCreated:*"], - "Event types should match" - ); - - // Clean up: remove the notification configuration - ctx.client - .put_bucket_notification_configuration(&bucket_name, &NotificationConfiguration::default()) - .await - .expect("Failed to clear notification configuration"); -} - -#[test_context(S3TestContext)] -#[tokio::test] -async fn test_notification_config_with_event_filter(ctx: &mut S3TestContext) { - let bucket_name = ctx.generate_unique_bucket_name(); - - ctx.create_test_bucket(&bucket_name) - .await - .expect("Failed to create bucket for notification filter test"); - - // Build a configuration with specific event types and a key prefix/suffix filter - let lambda_arn = match env::var("AWS_TEST_LAMBDA_ARN") { - Ok(arn) if !arn.is_empty() => arn, - _ => { - info!( - "AWS_TEST_LAMBDA_ARN not set — testing serialization of filter configuration only" - ); - - let config = NotificationConfiguration { - lambda_function_configurations: vec![LambdaFunctionConfiguration { - id: Some("filtered-notification".to_string()), - lambda_function_arn: "arn:aws:lambda:us-east-1:123456789012:function:filter-fn" - .to_string(), - events: vec![ - "s3:ObjectCreated:Put".to_string(), - "s3:ObjectRemoved:Delete".to_string(), - ], - filter: Some(NotificationFilter { - key: S3KeyFilter { - filter_rules: vec![ - FilterRule { - name: "prefix".to_string(), - value: "uploads/".to_string(), - }, - FilterRule { - name: "suffix".to_string(), - value: ".json".to_string(), - }, - ], - }, - }), - }], - }; - - let xml = quick_xml::se::to_string_with_root("NotificationConfiguration", &config) - .expect("Failed to serialize filtered NotificationConfiguration"); - assert!( - xml.contains("uploads/"), - "Serialized XML should contain the prefix filter value" - ); - assert!( - xml.contains(".json"), - "Serialized XML should contain the suffix filter value" - ); - assert!( - xml.contains("s3:ObjectCreated:Put"), - "Serialized XML should contain Put event" - ); - assert!( - xml.contains("s3:ObjectRemoved:Delete"), - "Serialized XML should contain Delete event" - ); - - let deserialized: NotificationConfiguration = quick_xml::de::from_str(&xml) - .expect("Failed to deserialize filtered NotificationConfiguration"); - assert_eq!(deserialized.lambda_function_configurations.len(), 1); - - let lambda_config = &deserialized.lambda_function_configurations[0]; - assert_eq!(lambda_config.events.len(), 2); - assert!( - lambda_config.filter.is_some(), - "Filter should be present after deserialization" - ); - - let filter = lambda_config.filter.as_ref().unwrap(); - assert_eq!( - filter.key.filter_rules.len(), - 2, - "Should have two filter rules" - ); - - return; - } - }; - - let config = NotificationConfiguration { - lambda_function_configurations: vec![LambdaFunctionConfiguration { - id: Some("filtered-notification".to_string()), - lambda_function_arn: lambda_arn.clone(), - events: vec![ - "s3:ObjectCreated:Put".to_string(), - "s3:ObjectRemoved:Delete".to_string(), - ], - filter: Some(NotificationFilter { - key: S3KeyFilter { - filter_rules: vec![ - FilterRule { - name: "prefix".to_string(), - value: "uploads/".to_string(), - }, - FilterRule { - name: "suffix".to_string(), - value: ".json".to_string(), - }, - ], - }, - }), - }], - }; - - ctx.client - .put_bucket_notification_configuration(&bucket_name, &config) - .await - .expect("Failed to put filtered notification configuration"); - - let retrieved_config = ctx - .client - .get_bucket_notification_configuration(&bucket_name) - .await - .expect("Failed to get filtered notification configuration"); - - assert_eq!( - retrieved_config.lambda_function_configurations.len(), - 1, - "Expected exactly one lambda function configuration" - ); - - let lambda_config = &retrieved_config.lambda_function_configurations[0]; - assert_eq!( - lambda_config.lambda_function_arn, lambda_arn, - "Lambda function ARN should match" - ); - assert_eq!(lambda_config.events.len(), 2, "Should have two event types"); - assert!( - lambda_config - .events - .contains(&"s3:ObjectCreated:Put".to_string()), - "Should contain ObjectCreated:Put event" - ); - assert!( - lambda_config - .events - .contains(&"s3:ObjectRemoved:Delete".to_string()), - "Should contain ObjectRemoved:Delete event" - ); - - let filter = lambda_config - .filter - .as_ref() - .expect("Filter should be present in retrieved configuration"); - assert_eq!( - filter.key.filter_rules.len(), - 2, - "Should have two filter rules" - ); - - let prefix_rule = filter - .key - .filter_rules - .iter() - .find(|r| r.name == "prefix") - .expect("Should have a prefix filter rule"); - assert_eq!( - prefix_rule.value, "uploads/", - "Prefix filter value should match" - ); - - let suffix_rule = filter - .key - .filter_rules - .iter() - .find(|r| r.name == "suffix") - .expect("Should have a suffix filter rule"); - assert_eq!( - suffix_rule.value, ".json", - "Suffix filter value should match" - ); - - // Clean up: remove the notification configuration - ctx.client - .put_bucket_notification_configuration(&bucket_name, &NotificationConfiguration::default()) - .await - .expect("Failed to clear notification configuration"); -} diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests/bucket.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests/bucket.rs new file mode 100644 index 000000000..3ae6f6b32 --- /dev/null +++ b/crates/alien-aws-clients/tests/aws_s3_client_tests/bucket.rs @@ -0,0 +1,697 @@ +use crate::context::S3TestContext; +use alien_aws_clients::s3::{ + FilterRule, LambdaFunctionConfiguration, LifecycleConfiguration, LifecycleExpiration, + LifecycleRule, LifecycleRuleFilter, LifecycleRuleStatus, NotificationConfiguration, + NotificationFilter, PublicAccessBlockConfiguration, S3Api, S3KeyFilter, +}; +use alien_client_core::Error; +use alien_client_core::ErrorData; +use std::env; +use test_context::test_context; +use tracing::{info, warn}; + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_create_and_delete_bucket(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + // Create bucket + let create_result = ctx.create_test_bucket(&bucket_name).await; + assert!( + create_result.is_ok(), + "Failed to create bucket: {:?}", + create_result.err() + ); + + // Test head_bucket - should succeed for existing bucket + let head_result = ctx.client.head_bucket(&bucket_name).await; + assert!( + head_result.is_ok(), + "head_bucket failed for existing bucket: {:?}", + head_result.err() + ); + + // Delete bucket + let delete_result = ctx.client.delete_bucket(&bucket_name).await; + let delete_ok = delete_result.is_ok() + || matches!( + delete_result, + Err(Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + }) + ); + assert!( + delete_ok, + "Failed to delete bucket: {:?}", + delete_result.err() + ); + if delete_ok { + ctx.untrack_bucket(&bucket_name); + } + + // Test head_bucket - should fail for deleted bucket + let head_after_delete_result = ctx.client.head_bucket(&bucket_name).await; + assert!( + matches!( + head_after_delete_result, + Err(Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + }) + ), + "Expected RemoteResourceNotFound after deleting bucket, got {:?}", + head_after_delete_result + ); + + // Verify bucket is deleted by trying to delete again (should fail) + let delete_again_result = ctx.client.delete_bucket(&bucket_name).await; + assert!( + matches!( + delete_again_result, + Err(Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + }) + ), + "Expected RemoteResourceNotFound after deleting bucket, got {:?}", + delete_again_result + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_delete_non_existent_bucket(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); // Name that shouldn't exist + + let result = ctx.client.delete_bucket(&bucket_name).await; + assert!( + matches!( + result, + Err(Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + }) + ), + "Expected RemoteResourceNotFound, got {:?}", + result + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_create_bucket_already_exists(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + // Create bucket first time + let create_first_result = ctx.create_test_bucket(&bucket_name).await; + assert!( + create_first_result.is_ok(), + "Failed to create bucket initially: {:?}", + create_first_result.err() + ); + + // Attempt to create the same bucket again. + // S3 returns 200 OK if the caller owns the bucket and it's in the same region, + // or BucketAlreadyOwnedByYou (which we map to RemoteResourceConflict). + let create_second_result = ctx.client.create_bucket(&bucket_name).await; + assert!( + create_second_result.is_ok() + || matches!( + &create_second_result, + Err(Error { + error: Some(ErrorData::RemoteResourceConflict { .. }), + .. + }) + ), + "Expected Ok or RemoteResourceConflict, got {:?}", + create_second_result + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_public_access_block(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for public access block test"); + + let config = PublicAccessBlockConfiguration::builder() + .block_public_acls(true) + .ignore_public_acls(true) + .block_public_policy(true) + .restrict_public_buckets(true) + .build(); + + let result = ctx + .client + .put_public_access_block(&bucket_name, config) + .await; + assert!( + result.is_ok(), + "Failed to put public access block: {:?}", + result.err() + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_and_delete_bucket_policy(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for policy test"); + + // First, disable Block Public Access settings to allow public policies + let public_access_config = PublicAccessBlockConfiguration::builder() + .block_public_acls(false) + .ignore_public_acls(false) + .block_public_policy(false) // This is the key setting + .restrict_public_buckets(false) + .build(); + + ctx.client + .put_public_access_block(&bucket_name, public_access_config) + .await + .expect("Failed to disable Block Public Access settings"); + + let policy_document = format!( + "{{\"Version\":\"2012-10-17\",\"Statement\":[{{\"Sid\":\"PublicReadGetObject\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::{}/*\"]}}]}}", + bucket_name + ); + + let put_result = ctx + .client + .put_bucket_policy(&bucket_name, &policy_document) + .await; + assert!( + put_result.is_ok(), + "Failed to put bucket policy: {:?}", + put_result.err() + ); + + // Note: Verifying typically requires GetBucketPolicy, not in current S3Client. + + let delete_result = ctx.client.delete_bucket_policy(&bucket_name).await; + assert!( + delete_result.is_ok(), + "Failed to delete bucket policy: {:?}", + delete_result.err() + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_delete_non_existent_bucket_policy(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for policy test"); + + // Attempt to delete a policy that was never set. + // S3's DeleteBucketPolicy returns 204 No Content even if no policy exists. + // So, a successful response is expected. + let result = ctx.client.delete_bucket_policy(&bucket_name).await; + assert!( + result.is_ok(), + "Expected success when deleting non-existent policy, got {:?}", + result + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_and_delete_bucket_lifecycle(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for lifecycle test"); + + let lifecycle_config = LifecycleConfiguration::builder() + .rules(vec![LifecycleRule::builder() + .id("TestRule1".to_string()) + .status(LifecycleRuleStatus::Enabled) + .filter(LifecycleRuleFilter::builder().build()) + .expiration(LifecycleExpiration::builder().days(30).build()) + .build()]) + .build(); + + let put_result = ctx + .client + .put_bucket_lifecycle_configuration(&bucket_name, &lifecycle_config) + .await; + assert!( + put_result.is_ok(), + "Failed to put bucket lifecycle configuration: {:?}", + put_result.err() + ); + + // Note: Verifying typically requires GetBucketLifecycleConfiguration, not in current S3Client. + + let delete_result = ctx.client.delete_bucket_lifecycle(&bucket_name).await; + assert!( + delete_result.is_ok(), + "Failed to delete bucket lifecycle: {:?}", + delete_result.err() + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_delete_non_existent_bucket_lifecycle(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for lifecycle test"); + + // S3's DeleteBucketLifecycle returns 204 No Content even if no lifecycle config exists. + let result = ctx.client.delete_bucket_lifecycle(&bucket_name).await; + assert!( + result.is_ok(), + "Expected success when deleting non-existent lifecycle, got {:?}", + result + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_invalid_bucket_policy(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + // Disable public access block first + let public_access_config = PublicAccessBlockConfiguration::builder() + .block_public_acls(false) + .ignore_public_acls(false) + .block_public_policy(false) + .restrict_public_buckets(false) + .build(); + ctx.client + .put_public_access_block(&bucket_name, public_access_config) + .await + .expect("Failed to disable Block Public Access settings"); + + // Test invalid JSON policy + let invalid_policy = r#"{"Version":"2012-10-17","Statement":[{"Sid":"InvalidPolicy""#; // Malformed JSON + + let result = ctx + .client + .put_bucket_policy(&bucket_name, invalid_policy) + .await; + assert!(result.is_err(), "Expected error for invalid policy JSON"); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_invalid_lifecycle_configuration(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + // Test lifecycle config with invalid days (negative) + let invalid_lifecycle_config = LifecycleConfiguration::builder() + .rules(vec![LifecycleRule::builder() + .id("InvalidRule".to_string()) + .status(LifecycleRuleStatus::Enabled) + .filter(LifecycleRuleFilter::builder().build()) + .expiration( + LifecycleExpiration::builder() + .days(-1) // Invalid negative days + .build(), + ) + .build()]) + .build(); + + let result = ctx + .client + .put_bucket_lifecycle_configuration(&bucket_name, &invalid_lifecycle_config) + .await; + assert!( + result.is_err(), + "Expected error for invalid lifecycle configuration" + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_bucket_name_validation_edge_cases(ctx: &mut S3TestContext) { + // Test edge cases for bucket names (these should be invalid in most regions) + let long_name = "a".repeat(64); // Too long (maximum 63 chars) + let invalid_bucket_names = vec![ + "UPPERCASE", // Uppercase not allowed in most regions + "bucket-with-periods.in.name", // Periods can cause SSL issues + "bucket-ending-with-dash-", // Can't end with dash + "-bucket-starting-with-dash", // Can't start with dash + "bu", // Too short (minimum 3 chars) + &long_name, // Too long (maximum 63 chars) + "bucket_with_underscores", // Underscores not allowed + "bucket..double.dots", // Double dots not allowed + "192.168.1.1", // IP address format not allowed + ]; + + for bucket_name in invalid_bucket_names { + let result = ctx.client.create_bucket(bucket_name).await; + // Note: Some of these might be caught by S3 service validation rather than client-side + // The test is to ensure we handle the errors gracefully + if result.is_ok() { + // If it somehow succeeded, track it for cleanup + ctx.track_bucket(bucket_name); + // In real scenarios, this shouldn't happen for truly invalid names + warn!( + "Warning: Bucket name '{}' was accepted when it shouldn't be", + bucket_name + ); + } + } +} + +// ------------------------------------------------------------------------- +// Notification configuration tests +// ------------------------------------------------------------------------- + +/// S3 notification configurations require a valid Lambda ARN with the correct +/// resource-based policy allowing S3 to invoke it. Since integration tests may +/// not have a Lambda available, we test the serialization/deserialization +/// round-trip of the notification types and verify the get/put API calls work +/// against a real bucket using an empty configuration (which is always valid). + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_and_get_empty_notification_configuration(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for notification config test"); + + // Put an empty notification configuration (clears any existing notifications) + let empty_config = NotificationConfiguration::default(); + ctx.client + .put_bucket_notification_configuration(&bucket_name, &empty_config) + .await + .expect("Failed to put empty notification configuration"); + + // Get the notification configuration back — should be empty + let retrieved_config = ctx + .client + .get_bucket_notification_configuration(&bucket_name) + .await + .expect("Failed to get notification configuration"); + + assert!( + retrieved_config.lambda_function_configurations.is_empty(), + "Expected empty lambda function configurations after putting empty config, got {:?}", + retrieved_config.lambda_function_configurations + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_and_get_bucket_notification_configuration(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for notification config test"); + + // Construct a notification configuration with a Lambda function. + // NOTE: This requires a real Lambda ARN with S3 invocation permission. + // If the env var is not set, we skip the actual put/get and only verify + // that the types serialize correctly. + let lambda_arn = match env::var("AWS_TEST_LAMBDA_ARN") { + Ok(arn) if !arn.is_empty() => arn, + _ => { + info!("AWS_TEST_LAMBDA_ARN not set — skipping live notification configuration test, verifying serialization only"); + + let config = NotificationConfiguration { + lambda_function_configurations: vec![LambdaFunctionConfiguration { + id: Some("test-notification".to_string()), + lambda_function_arn: "arn:aws:lambda:us-east-1:123456789012:function:test-fn" + .to_string(), + events: vec!["s3:ObjectCreated:*".to_string()], + filter: None, + }], + }; + + // Verify serialization round-trip via quick_xml + let xml = quick_xml::se::to_string_with_root("NotificationConfiguration", &config) + .expect("Failed to serialize NotificationConfiguration"); + assert!( + xml.contains("s3:ObjectCreated:*"), + "Serialized XML should contain the event type" + ); + assert!( + xml.contains("arn:aws:lambda:us-east-1:123456789012:function:test-fn"), + "Serialized XML should contain the Lambda ARN" + ); + + let deserialized: NotificationConfiguration = quick_xml::de::from_str(&xml) + .expect("Failed to deserialize NotificationConfiguration"); + assert_eq!(deserialized.lambda_function_configurations.len(), 1); + assert_eq!( + deserialized.lambda_function_configurations[0].lambda_function_arn, + "arn:aws:lambda:us-east-1:123456789012:function:test-fn" + ); + assert_eq!( + deserialized.lambda_function_configurations[0].events, + vec!["s3:ObjectCreated:*"] + ); + + return; + } + }; + + let config = NotificationConfiguration { + lambda_function_configurations: vec![LambdaFunctionConfiguration { + id: Some("test-notification".to_string()), + lambda_function_arn: lambda_arn.clone(), + events: vec!["s3:ObjectCreated:*".to_string()], + filter: None, + }], + }; + + ctx.client + .put_bucket_notification_configuration(&bucket_name, &config) + .await + .expect("Failed to put notification configuration"); + + let retrieved_config = ctx + .client + .get_bucket_notification_configuration(&bucket_name) + .await + .expect("Failed to get notification configuration"); + + assert_eq!( + retrieved_config.lambda_function_configurations.len(), + 1, + "Expected exactly one lambda function configuration" + ); + assert_eq!( + retrieved_config.lambda_function_configurations[0].lambda_function_arn, lambda_arn, + "Lambda function ARN should match" + ); + assert_eq!( + retrieved_config.lambda_function_configurations[0].events, + vec!["s3:ObjectCreated:*"], + "Event types should match" + ); + + // Clean up: remove the notification configuration + ctx.client + .put_bucket_notification_configuration(&bucket_name, &NotificationConfiguration::default()) + .await + .expect("Failed to clear notification configuration"); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_notification_config_with_event_filter(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for notification filter test"); + + // Build a configuration with specific event types and a key prefix/suffix filter + let lambda_arn = match env::var("AWS_TEST_LAMBDA_ARN") { + Ok(arn) if !arn.is_empty() => arn, + _ => { + info!( + "AWS_TEST_LAMBDA_ARN not set — testing serialization of filter configuration only" + ); + + let config = NotificationConfiguration { + lambda_function_configurations: vec![LambdaFunctionConfiguration { + id: Some("filtered-notification".to_string()), + lambda_function_arn: "arn:aws:lambda:us-east-1:123456789012:function:filter-fn" + .to_string(), + events: vec![ + "s3:ObjectCreated:Put".to_string(), + "s3:ObjectRemoved:Delete".to_string(), + ], + filter: Some(NotificationFilter { + key: S3KeyFilter { + filter_rules: vec![ + FilterRule { + name: "prefix".to_string(), + value: "uploads/".to_string(), + }, + FilterRule { + name: "suffix".to_string(), + value: ".json".to_string(), + }, + ], + }, + }), + }], + }; + + let xml = quick_xml::se::to_string_with_root("NotificationConfiguration", &config) + .expect("Failed to serialize filtered NotificationConfiguration"); + assert!( + xml.contains("uploads/"), + "Serialized XML should contain the prefix filter value" + ); + assert!( + xml.contains(".json"), + "Serialized XML should contain the suffix filter value" + ); + assert!( + xml.contains("s3:ObjectCreated:Put"), + "Serialized XML should contain Put event" + ); + assert!( + xml.contains("s3:ObjectRemoved:Delete"), + "Serialized XML should contain Delete event" + ); + + let deserialized: NotificationConfiguration = quick_xml::de::from_str(&xml) + .expect("Failed to deserialize filtered NotificationConfiguration"); + assert_eq!(deserialized.lambda_function_configurations.len(), 1); + + let lambda_config = &deserialized.lambda_function_configurations[0]; + assert_eq!(lambda_config.events.len(), 2); + assert!( + lambda_config.filter.is_some(), + "Filter should be present after deserialization" + ); + + let filter = lambda_config.filter.as_ref().unwrap(); + assert_eq!( + filter.key.filter_rules.len(), + 2, + "Should have two filter rules" + ); + + return; + } + }; + + let config = NotificationConfiguration { + lambda_function_configurations: vec![LambdaFunctionConfiguration { + id: Some("filtered-notification".to_string()), + lambda_function_arn: lambda_arn.clone(), + events: vec![ + "s3:ObjectCreated:Put".to_string(), + "s3:ObjectRemoved:Delete".to_string(), + ], + filter: Some(NotificationFilter { + key: S3KeyFilter { + filter_rules: vec![ + FilterRule { + name: "prefix".to_string(), + value: "uploads/".to_string(), + }, + FilterRule { + name: "suffix".to_string(), + value: ".json".to_string(), + }, + ], + }, + }), + }], + }; + + ctx.client + .put_bucket_notification_configuration(&bucket_name, &config) + .await + .expect("Failed to put filtered notification configuration"); + + let retrieved_config = ctx + .client + .get_bucket_notification_configuration(&bucket_name) + .await + .expect("Failed to get filtered notification configuration"); + + assert_eq!( + retrieved_config.lambda_function_configurations.len(), + 1, + "Expected exactly one lambda function configuration" + ); + + let lambda_config = &retrieved_config.lambda_function_configurations[0]; + assert_eq!( + lambda_config.lambda_function_arn, lambda_arn, + "Lambda function ARN should match" + ); + assert_eq!(lambda_config.events.len(), 2, "Should have two event types"); + assert!( + lambda_config + .events + .contains(&"s3:ObjectCreated:Put".to_string()), + "Should contain ObjectCreated:Put event" + ); + assert!( + lambda_config + .events + .contains(&"s3:ObjectRemoved:Delete".to_string()), + "Should contain ObjectRemoved:Delete event" + ); + + let filter = lambda_config + .filter + .as_ref() + .expect("Filter should be present in retrieved configuration"); + assert_eq!( + filter.key.filter_rules.len(), + 2, + "Should have two filter rules" + ); + + let prefix_rule = filter + .key + .filter_rules + .iter() + .find(|r| r.name == "prefix") + .expect("Should have a prefix filter rule"); + assert_eq!( + prefix_rule.value, "uploads/", + "Prefix filter value should match" + ); + + let suffix_rule = filter + .key + .filter_rules + .iter() + .find(|r| r.name == "suffix") + .expect("Should have a suffix filter rule"); + assert_eq!( + suffix_rule.value, ".json", + "Suffix filter value should match" + ); + + // Clean up: remove the notification configuration + ctx.client + .put_bucket_notification_configuration(&bucket_name, &NotificationConfiguration::default()) + .await + .expect("Failed to clear notification configuration"); +} diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests/context.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests/context.rs new file mode 100644 index 000000000..b57bffa94 --- /dev/null +++ b/crates/alien-aws-clients/tests/aws_s3_client_tests/context.rs @@ -0,0 +1,172 @@ +use alien_aws_clients::s3::{PutObjectRequest, S3Api, S3Client}; +use alien_aws_clients::AwsCredentialProvider; +use alien_client_core::Error; +use alien_client_core::ErrorData; +use reqwest::Client; +use std::collections::HashSet; +use std::path::PathBuf as StdPathBuf; +use std::sync::Mutex; +use test_context::AsyncTestContext; +use tracing::{info, warn}; +use uuid::Uuid; + +// Helper function to put an object using the S3Api +pub(crate) async fn put_test_object( + client: &S3Client, + bucket_name: &str, + object_key: &str, + body: Vec, + content_type: Option<&str>, +) -> Result<(), Error> { + let request = PutObjectRequest::builder() + .bucket(bucket_name.to_string()) + .key(object_key.to_string()) + .body(body) + .maybe_content_type(content_type.map(|s| s.to_string())) + .build(); + + client.put_object(&request).await?; + Ok(()) +} + +pub(crate) struct S3TestContext { + pub(crate) client: S3Client, + created_buckets: Mutex>, +} + +impl AsyncTestContext for S3TestContext { + async fn setup() -> S3TestContext { + let root: StdPathBuf = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); + tracing_subscriber::fmt::try_init().ok(); // Initialize tracing + + let region = std::env::var("AWS_MANAGEMENT_REGION") + .expect("AWS_MANAGEMENT_REGION must be set in .env.test"); + let access_key = std::env::var("AWS_MANAGEMENT_ACCESS_KEY_ID") + .expect("AWS_MANAGEMENT_ACCESS_KEY_ID must be set in .env.test"); + let secret_key = std::env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") + .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY must be set in .env.test"); + let account_id = std::env::var("AWS_MANAGEMENT_ACCOUNT_ID") + .expect("AWS_MANAGEMENT_ACCOUNT_ID must be set in .env.test"); + + let aws_config = alien_aws_clients::AwsClientConfig { + account_id, + region, + credentials: alien_aws_clients::AwsCredentials::AccessKeys { + access_key_id: access_key, + secret_access_key: secret_key, + session_token: None, + }, + service_overrides: None, + }; + + let client = S3Client::new( + Client::new(), + AwsCredentialProvider::from_config_sync(aws_config), + ); + + S3TestContext { + client, + created_buckets: Mutex::new(HashSet::new()), + } + } + + async fn teardown(self) { + info!("🧹 Starting S3 test cleanup..."); + + let buckets_to_cleanup = { + let buckets = self.created_buckets.lock().unwrap(); + buckets.clone() + }; + + for bucket_name in buckets_to_cleanup { + self.cleanup_bucket(&bucket_name).await; + } + + info!("✅ S3 test cleanup completed"); + } +} + +impl S3TestContext { + pub(crate) fn track_bucket(&self, bucket_name: &str) { + let mut buckets = self.created_buckets.lock().unwrap(); + buckets.insert(bucket_name.to_string()); + info!("📝 Tracking bucket for cleanup: {}", bucket_name); + } + + pub(crate) fn untrack_bucket(&self, bucket_name: &str) { + let mut buckets = self.created_buckets.lock().unwrap(); + buckets.remove(bucket_name); + info!( + "✅ Bucket {} successfully cleaned up and untracked", + bucket_name + ); + } + + async fn cleanup_bucket(&self, bucket_name: &str) { + info!("🧹 Cleaning up bucket: {}", bucket_name); + + match self.client.empty_bucket(bucket_name).await { + Ok(_) => { + if let Err(e) = self.client.delete_bucket(bucket_name).await { + if !matches!( + e, + Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + } + ) { + warn!( + "Failed to delete bucket {} during cleanup: {:?}", + bucket_name, e + ); + } + } else { + info!("✅ Bucket {} deleted successfully", bucket_name); + } + } + Err(Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + }) => { + info!("🔍 Bucket {} was already deleted", bucket_name); + } + Err(e) => { + warn!( + "Failed to empty bucket {} during cleanup: {:?}", + bucket_name, e + ); + // Try deleting anyway, it might be empty from a previous failed empty attempt + if let Err(e_del) = self.client.delete_bucket(bucket_name).await { + if !matches!( + e_del, + Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + } + ) { + warn!( + "Failed to delete bucket {} during cleanup (after empty failed): {:?}", + bucket_name, e_del + ); + } + } + } + } + } + + pub(crate) fn generate_unique_bucket_name(&self) -> String { + format!( + "alien-test-bucket-{}", + Uuid::new_v4().as_simple().to_string() + ) + } + + pub(crate) async fn create_test_bucket(&self, bucket_name: &str) -> Result<(), Error> { + let result = self.client.create_bucket(bucket_name).await; + if result.is_ok() { + self.track_bucket(bucket_name); + } + result + } +} diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests/location.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests/location.rs new file mode 100644 index 000000000..574da6ddb --- /dev/null +++ b/crates/alien-aws-clients/tests/aws_s3_client_tests/location.rs @@ -0,0 +1,233 @@ +use crate::context::S3TestContext; +use alien_aws_clients::s3::{S3Api, S3Client}; +use alien_aws_clients::AwsCredentialProvider; +use alien_client_core::Error; +use alien_client_core::ErrorData; +use reqwest::Client; +use std::env; +use std::path::PathBuf as StdPathBuf; +use test_context::test_context; +use tracing::info; + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_bucket_location_basic(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for location test"); + + let location_result = ctx.client.get_bucket_location(&bucket_name).await; + assert!( + location_result.is_ok(), + "Failed to get bucket location: {:?}", + location_result.err() + ); + + let location_output = location_result.unwrap(); + let region = location_output.region(); + + // The bucket should be in the same region as our client + assert_eq!( + region, + ctx.client.region(), + "Bucket region '{}' doesn't match client region '{}'", + region, + ctx.client.region() + ); + + // Log the raw location constraint for debugging + info!( + "Bucket {} location constraint: {:?}, resolved region: {}", + bucket_name, location_output.location_constraint, region + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_bucket_location_us_east_1_special_case(ctx: &mut S3TestContext) { + // Rebuild client with us-east-1 region + let root: StdPathBuf = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); + let access_key_id = + env::var("AWS_MANAGEMENT_ACCESS_KEY_ID").expect("AWS_MANAGEMENT_ACCESS_KEY_ID not set"); + let secret_access_key = env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") + .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY not set"); + let session_token = env::var("AWS_SESSION_TOKEN").ok(); + let account_id = + env::var("AWS_MANAGEMENT_ACCOUNT_ID").unwrap_or_else(|_| "123456789012".to_string()); + let aws_config = alien_aws_clients::AwsClientConfig { + account_id, + region: "us-east-1".to_string(), + credentials: alien_aws_clients::AwsCredentials::AccessKeys { + access_key_id, + secret_access_key, + session_token, + }, + service_overrides: None, + }; + ctx.client = S3Client::new( + Client::new(), + AwsCredentialProvider::from_config_sync(aws_config), + ); + + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket in us-east-1"); + + let location_result = ctx.client.get_bucket_location(&bucket_name).await; + assert!( + location_result.is_ok(), + "Failed to get bucket location in us-east-1: {:?}", + location_result.err() + ); + + let location_output = location_result.unwrap(); + + // In us-east-1, S3 returns null/empty LocationConstraint + assert!( + location_output.location_constraint.is_none() + || location_output.location_constraint.as_deref() == Some(""), + "Expected null/empty LocationConstraint for us-east-1, got: {:?}", + location_output.location_constraint + ); + + // But the region() method should still return "us-east-1" + assert_eq!(location_output.region(), "us-east-1"); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_bucket_location_non_existent_bucket(ctx: &mut S3TestContext) { + let non_existent_bucket = ctx.generate_unique_bucket_name(); + + let location_result = ctx.client.get_bucket_location(&non_existent_bucket).await; + assert!( + location_result.is_err(), + "Expected error for non-existent bucket" + ); + + // Should get a RemoteResourceNotFound error + match location_result.unwrap_err() { + Error { + error: + Some(ErrorData::RemoteResourceNotFound { + resource_type, + resource_name, + .. + }), + .. + } => { + assert_eq!(resource_type, "Bucket"); + assert_eq!(resource_name, non_existent_bucket); + } + other_error => { + panic!("Expected RemoteResourceNotFound, got: {:?}", other_error); + } + } +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_bucket_location_multiple_buckets(ctx: &mut S3TestContext) { + // Test getting location for multiple buckets to ensure consistency + let bucket_names: Vec = (0..3).map(|_| ctx.generate_unique_bucket_name()).collect(); + + // Create all buckets + for bucket_name in &bucket_names { + ctx.create_test_bucket(bucket_name) + .await + .expect(&format!("Failed to create bucket {}", bucket_name)); + } + + // Get location for all buckets + for bucket_name in &bucket_names { + let location_result = ctx.client.get_bucket_location(bucket_name).await; + assert!( + location_result.is_ok(), + "Failed to get location for bucket {}: {:?}", + bucket_name, + location_result.err() + ); + + let location_output = location_result.unwrap(); + let region = location_output.region(); + + assert_eq!( + region, + ctx.client.region(), + "Bucket {} region '{}' doesn't match client region '{}'", + bucket_name, + region, + ctx.client.region() + ); + } +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_bucket_location_concurrent_requests(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for concurrent test"); + + // Make multiple concurrent requests for the same bucket location + let mut handles = Vec::new(); + for i in 0..5 { + let root: StdPathBuf = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); + + let access_key_id = + env::var("AWS_MANAGEMENT_ACCESS_KEY_ID").expect("AWS_MANAGEMENT_ACCESS_KEY_ID not set"); + let secret_access_key = env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") + .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY not set"); + let region = env::var("AWS_MANAGEMENT_REGION").unwrap_or_else(|_| "us-east-1".to_string()); + let session_token = env::var("AWS_SESSION_TOKEN").ok(); + + let account_id = + env::var("AWS_MANAGEMENT_ACCOUNT_ID").unwrap_or_else(|_| "123456789012".to_string()); + let aws_config = alien_aws_clients::AwsClientConfig { + account_id, + region: region.clone(), + credentials: alien_aws_clients::AwsCredentials::AccessKeys { + access_key_id, + secret_access_key, + session_token, + }, + service_overrides: None, + }; + let client_clone = S3Client::new( + Client::new(), + AwsCredentialProvider::from_config_sync(aws_config), + ); + let bucket_name_clone = bucket_name.clone(); + + let handle = tokio::spawn(async move { + let result = client_clone.get_bucket_location(&bucket_name_clone).await; + (i, result) + }); + handles.push(handle); + } + + // Wait for all requests to complete + for handle in handles { + let (request_id, result) = handle.await.expect("Task panicked"); + assert!( + result.is_ok(), + "Concurrent get_bucket_location request {} failed: {:?}", + request_id, + result.err() + ); + + let location_output = result.unwrap(); + assert_eq!( + location_output.region(), + ctx.client.region(), + "Request {} returned wrong region", + request_id + ); + } +} diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests/main.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests/main.rs new file mode 100644 index 000000000..c9fb6b607 --- /dev/null +++ b/crates/alien-aws-clients/tests/aws_s3_client_tests/main.rs @@ -0,0 +1,7 @@ +#![cfg(test)] + +mod bucket; +mod context; +mod location; +mod object; +mod versioning; diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests/object.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests/object.rs new file mode 100644 index 000000000..a379b6feb --- /dev/null +++ b/crates/alien-aws-clients/tests/aws_s3_client_tests/object.rs @@ -0,0 +1,856 @@ +use crate::context::{put_test_object, S3TestContext}; +use alien_aws_clients::s3::{ + GetObjectRequest, HeadObjectRequest, ObjectIdentifier, PutObjectRequest, S3Api, S3Client, +}; +use alien_aws_clients::AwsCredentialProvider; +use alien_client_core::Error; +use alien_client_core::ErrorData; +use reqwest::Client; +use std::env; +use std::path::PathBuf as StdPathBuf; +use test_context::test_context; +use tracing::warn; + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_list_objects_v2_basic_and_prefix(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for list_objects_v2 test"); + + // Test on empty bucket first + let list_empty_result = ctx.client.list_objects_v2(&bucket_name, None, None).await; + assert!( + list_empty_result.is_ok(), + "list_objects_v2 on empty bucket failed: {:?}", + list_empty_result.err() + ); + let empty_output = list_empty_result.unwrap(); + assert!(empty_output.contents.is_empty()); + assert_eq!(empty_output.key_count, 0); + assert!(!empty_output.is_truncated); + + // Add some objects + put_test_object( + &ctx.client, + &bucket_name, + "test1.txt", + b"hello".to_vec(), + Some("text/plain"), + ) + .await + .expect("Failed to put object test1.txt"); + put_test_object( + &ctx.client, + &bucket_name, + "test2.txt", + b"world".to_vec(), + Some("text/plain"), + ) + .await + .expect("Failed to put object test2.txt"); + put_test_object( + &ctx.client, + &bucket_name, + "prefix/obj1.txt", + b"data1".to_vec(), + Some("text/plain"), + ) + .await + .expect("Failed to put object prefix/obj1.txt"); + put_test_object( + &ctx.client, + &bucket_name, + "prefix/obj2.txt", + b"data2".to_vec(), + Some("text/plain"), + ) + .await + .expect("Failed to put object prefix/obj2.txt"); + + // Test listing all objects + let list_all_result = ctx.client.list_objects_v2(&bucket_name, None, None).await; + assert!( + list_all_result.is_ok(), + "list_objects_v2 failed: {:?}", + list_all_result.err() + ); + let all_output = list_all_result.unwrap(); + assert_eq!(all_output.contents.len(), 4); + assert_eq!(all_output.key_count, 4); + + // Test listing with prefix + let list_prefix_result = ctx + .client + .list_objects_v2(&bucket_name, Some("prefix/".to_string()), None) + .await; + assert!( + list_prefix_result.is_ok(), + "list_objects_v2 with prefix failed: {:?}", + list_prefix_result.err() + ); + let prefix_output = list_prefix_result.unwrap(); + assert_eq!(prefix_output.contents.len(), 2); + assert_eq!(prefix_output.key_count, 2); + assert!(prefix_output + .contents + .iter() + .all(|obj| obj.key.starts_with("prefix/"))); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_list_objects_v2_pagination(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for list_objects_v2 pagination test"); + + // Add more objects than a single page (e.g., S3 defaults to 1000, but our ListObjectsV2Output doesn't expose max-keys from request) + // For simplicity, let's assume a small number will trigger truncation if we could control max_keys in request. + // The current list_objects_v2 doesn't allow setting max_keys. We'll test continuation if the API returns truncated. + // This test relies on the S3 default behavior or if client.list_objects_v2 internally uses a small max_keys. + // For now, we put a few objects and check if a second call with a token (if provided) works. + + for i in 0..5 { + // Put 5 objects + put_test_object( + &ctx.client, + &bucket_name, + &format!("page_obj_{}.txt", i), + vec![i as u8], + Some("text/plain"), + ) + .await + .unwrap(); + } + + let mut all_keys_retrieved = std::collections::HashSet::new(); + let mut continuation_token: Option = None; + let mut total_key_count = 0; + + loop { + let list_result = ctx + .client + .list_objects_v2(&bucket_name, None, continuation_token) + .await; + assert!( + list_result.is_ok(), + "list_objects_v2 for pagination failed: {:?}", + list_result.err() + ); + let output = list_result.unwrap(); + + for obj in output.contents { + all_keys_retrieved.insert(obj.key.clone()); + } + total_key_count += output.key_count; + + if output.is_truncated { + continuation_token = output.next_continuation_token; + assert!( + continuation_token.is_some(), + "Expected next_continuation_token when is_truncated is true" + ); + } else { + break; + } + } + assert_eq!( + all_keys_retrieved.len(), + 5, + "Expected to retrieve all 5 keys via pagination (if any)" + ); + // The total_key_count from S3 responses might be tricky if it's per page. The primary check is retrieving all keys. +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_delete_objects(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for delete_objects test"); + + put_test_object( + &ctx.client, + &bucket_name, + "delete_me_1.txt", + vec![1], + Some("text/plain"), + ) + .await + .unwrap(); + put_test_object( + &ctx.client, + &bucket_name, + "delete_me_2.txt", + vec![2], + Some("text/plain"), + ) + .await + .unwrap(); + put_test_object( + &ctx.client, + &bucket_name, + "keep_me.txt", + vec![3], + Some("text/plain"), + ) + .await + .unwrap(); + + let objects_to_delete = [ + ObjectIdentifier::builder() + .key("delete_me_1.txt".to_string()) + .build(), + ObjectIdentifier::builder() + .key("delete_me_2.txt".to_string()) + .build(), + ObjectIdentifier::builder() + .key("does_not_exist.txt".to_string()) + .build(), // Test deleting non-existent + ]; + + // Test with Quiet = false (default behavior if not specified, but our client exposes it) + let delete_result_verbose = ctx + .client + .delete_objects(&bucket_name, &objects_to_delete, false) + .await; + assert!( + delete_result_verbose.is_ok(), + "delete_objects (verbose) failed: {:?}", + delete_result_verbose.err() + ); + let verbose_output = delete_result_verbose.unwrap(); + + // S3 behavior: non-existent keys might still be reported as "deleted" in some cases + // The important thing is that the real objects are deleted and no errors occurred + assert!( + verbose_output.deleted.len() >= 2, + "Expected at least 2 objects to be reported as deleted (verbose), got {}", + verbose_output.deleted.len() + ); + assert!(verbose_output + .deleted + .iter() + .any(|d| d.key == "delete_me_1.txt")); + assert!(verbose_output + .deleted + .iter() + .any(|d| d.key == "delete_me_2.txt")); + + // Errors should be empty or minimal for non-existent objects + assert!( + verbose_output.errors.is_empty(), + "Expected no errors in verbose output, got {:?}", + verbose_output.errors + ); + + // Put objects again for the quiet test + put_test_object( + &ctx.client, + &bucket_name, + "delete_me_quiet_1.txt", + vec![1], + Some("text/plain"), + ) + .await + .unwrap(); + put_test_object( + &ctx.client, + &bucket_name, + "delete_me_quiet_2.txt", + vec![2], + Some("text/plain"), + ) + .await + .unwrap(); + + let objects_to_delete_quiet = [ + ObjectIdentifier::builder() + .key("delete_me_quiet_1.txt".to_string()) + .build(), + ObjectIdentifier::builder() + .key("delete_me_quiet_2.txt".to_string()) + .build(), + ]; + + // Test with Quiet = true + let delete_result_quiet = ctx + .client + .delete_objects(&bucket_name, &objects_to_delete_quiet, true) + .await; + assert!( + delete_result_quiet.is_ok(), + "delete_objects (quiet) failed: {:?}", + delete_result_quiet.err() + ); + let quiet_output = delete_result_quiet.unwrap(); + assert!( + quiet_output.deleted.is_empty(), + "Expected no objects in Deleted list for quiet mode" + ); + assert!( + quiet_output.errors.is_empty(), + "Expected no errors in Errors list for quiet mode (for successful deletes)" + ); + + // Verify objects are deleted + let list_after_delete_result = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert_eq!( + list_after_delete_result.contents.len(), + 1, + "Not all specified objects were deleted" + ); + assert_eq!(list_after_delete_result.contents[0].key, "keep_me.txt"); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_empty_bucket_no_versions(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for empty_bucket test"); + + put_test_object( + &ctx.client, + &bucket_name, + "obj1.txt", + vec![1], + Some("text/plain"), + ) + .await + .unwrap(); + put_test_object( + &ctx.client, + &bucket_name, + "prefix/obj2.txt", + vec![2], + Some("text/plain"), + ) + .await + .unwrap(); + + let empty_result = ctx.client.empty_bucket(&bucket_name).await; + assert!( + empty_result.is_ok(), + "empty_bucket failed: {:?}", + empty_result.err() + ); + + let list_after_empty_result = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert!( + list_after_empty_result.contents.is_empty(), + "Bucket was not empty after empty_bucket call" + ); + assert_eq!(list_after_empty_result.key_count, 0); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_delete_objects_large_batch(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + // Create 100 objects (getting close to S3's 1000 object limit per delete request) + for i in 0..100 { + put_test_object( + &ctx.client, + &bucket_name, + &format!("batch_obj_{}.txt", i), + vec![i as u8], + Some("text/plain"), + ) + .await + .expect(&format!("Failed to put object {}", i)); + } + + // Delete all objects in one batch + let objects_to_delete: Vec = (0..100) + .map(|i| { + ObjectIdentifier::builder() + .key(format!("batch_obj_{}.txt", i)) + .build() + }) + .collect(); + + let delete_result = ctx + .client + .delete_objects(&bucket_name, &objects_to_delete, false) + .await; + assert!( + delete_result.is_ok(), + "Failed to delete batch of objects: {:?}", + delete_result.err() + ); + + let delete_output = delete_result.unwrap(); + assert_eq!( + delete_output.deleted.len(), + 100, + "Not all objects were deleted" + ); + assert!( + delete_output.errors.is_empty(), + "Unexpected errors during batch delete" + ); + + // Verify bucket is empty + let list_result = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert!( + list_result.contents.is_empty(), + "Bucket should be empty after batch delete" + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_concurrent_object_operations(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + // Test concurrent put operations + let mut handles = Vec::new(); + for i in 0..10 { + let root: StdPathBuf = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); + + let region = std::env::var("AWS_MANAGEMENT_REGION") + .expect("AWS_MANAGEMENT_REGION must be set in .env.test"); + let access_key = std::env::var("AWS_MANAGEMENT_ACCESS_KEY_ID") + .expect("AWS_MANAGEMENT_ACCESS_KEY_ID must be set in .env.test"); + let secret_key = std::env::var("AWS_MANAGEMENT_SECRET_ACCESS_KEY") + .expect("AWS_MANAGEMENT_SECRET_ACCESS_KEY must be set in .env.test"); + let account_id = std::env::var("AWS_MANAGEMENT_ACCOUNT_ID") + .expect("AWS_MANAGEMENT_ACCOUNT_ID must be set in .env.test"); + + let account_id = + env::var("AWS_MANAGEMENT_ACCOUNT_ID").unwrap_or_else(|_| "123456789012".to_string()); + let aws_config = alien_aws_clients::AwsClientConfig { + account_id, + region: region.clone(), + credentials: alien_aws_clients::AwsCredentials::AccessKeys { + access_key_id: access_key, + secret_access_key: secret_key, + session_token: None, + }, + service_overrides: None, + }; + let client_clone = S3Client::new( + Client::new(), + AwsCredentialProvider::from_config_sync(aws_config), + ); + let bucket_name_clone = bucket_name.clone(); + + let handle = tokio::spawn(async move { + let key = format!("concurrent_obj_{}.txt", i); + let data = format!("Data for object {}", i).into_bytes(); + put_test_object( + &client_clone, + &bucket_name_clone, + &key, + data, + Some("text/plain"), + ) + .await + }); + handles.push(handle); + } + + // Wait for all operations to complete + for handle in handles { + let result = handle.await.expect("Task panicked"); + assert!( + result.is_ok(), + "Concurrent put operation failed: {:?}", + result.err() + ); + } + + // Verify all objects were created + let list_result = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert_eq!( + list_result.contents.len(), + 10, + "Not all concurrent objects were created" + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_object_key_length_limits(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + // Test very long object key (S3 limit is 1024 UTF-8 characters) + let long_key = "a".repeat(1020); // Close to but under the limit + let result = put_test_object( + &ctx.client, + &bucket_name, + &long_key, + b"test".to_vec(), + Some("text/plain"), + ) + .await; + assert!( + result.is_ok(), + "Failed to put object with long key: {:?}", + result.err() + ); + + // Test key that exceeds the limit + let too_long_key = "a".repeat(1025); // Over the limit + let result = put_test_object( + &ctx.client, + &bucket_name, + &too_long_key, + b"test".to_vec(), + Some("text/plain"), + ) + .await; + // This should fail - either client-side validation or server-side rejection + if result.is_ok() { + warn!( + "Warning: Unexpectedly long key was accepted: {} chars", + too_long_key.len() + ); + } +} + +// ------------------------------------------------------------------------- +// Object Operations Tests (PutObject, GetObject, HeadObject) +// ------------------------------------------------------------------------- + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_and_get_object(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let object_key = "test-object.txt"; + let content = b"Hello, World!"; + + // Put object + let put_request = PutObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .body(content.to_vec()) + .content_type("text/plain".to_string()) + .build(); + + let put_result = ctx.client.put_object(&put_request).await; + assert!( + put_result.is_ok(), + "Failed to put object: {:?}", + put_result.err() + ); + let put_output = put_result.unwrap(); + assert!( + put_output.e_tag.is_some(), + "Expected ETag in PutObject response" + ); + + // Get object + let get_request = GetObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .build(); + + let get_result = ctx.client.get_object(&get_request).await; + assert!( + get_result.is_ok(), + "Failed to get object: {:?}", + get_result.err() + ); + let get_output = get_result.unwrap(); + + assert_eq!(get_output.body, content, "Object content doesn't match"); + assert_eq!(get_output.content_type, Some("text/plain".to_string())); + assert!( + get_output.e_tag.is_some(), + "Expected ETag in GetObject response" + ); + assert!( + get_output.last_modified.is_some(), + "Expected LastModified in GetObject response" + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_head_object(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let object_key = "test-head-object.txt"; + let content = b"Test content for HEAD"; + + // Put object first + put_test_object( + &ctx.client, + &bucket_name, + object_key, + content.to_vec(), + Some("text/plain"), + ) + .await + .expect("Failed to put object"); + + // Head object + let head_request = HeadObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .build(); + + let head_result = ctx.client.head_object(&head_request).await; + assert!( + head_result.is_ok(), + "Failed to head object: {:?}", + head_result.err() + ); + let head_output = head_result.unwrap(); + + assert_eq!(head_output.content_type, Some("text/plain".to_string())); + assert_eq!(head_output.content_length, Some(content.len() as i64)); + assert!( + head_output.e_tag.is_some(), + "Expected ETag in HeadObject response" + ); + assert!( + head_output.last_modified.is_some(), + "Expected LastModified in HeadObject response" + ); + assert_eq!( + head_output.delete_marker, None, + "Should not have delete marker" + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_object_non_existent(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let get_request = GetObjectRequest::builder() + .bucket(bucket_name) + .key("non-existent-key.txt".to_string()) + .build(); + + let get_result = ctx.client.get_object(&get_request).await; + assert!( + get_result.is_err(), + "Expected error for non-existent object" + ); + + match get_result.unwrap_err() { + Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + } => { + // Expected error + } + other_error => { + panic!("Expected RemoteResourceNotFound, got: {:?}", other_error); + } + } +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_head_object_non_existent(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let head_request = HeadObjectRequest::builder() + .bucket(bucket_name) + .key("non-existent-key.txt".to_string()) + .build(); + + let head_result = ctx.client.head_object(&head_request).await; + assert!( + head_result.is_err(), + "Expected error for non-existent object" + ); + + match head_result.unwrap_err() { + Error { + error: Some(ErrorData::RemoteResourceNotFound { .. }), + .. + } => { + // Expected error + } + other_error => { + panic!("Expected RemoteResourceNotFound, got: {:?}", other_error); + } + } +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_get_object_with_range(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let object_key = "test-range-object.txt"; + let content = b"0123456789ABCDEFGHIJ"; + + // Put object + put_test_object( + &ctx.client, + &bucket_name, + object_key, + content.to_vec(), + Some("text/plain"), + ) + .await + .expect("Failed to put object"); + + // Get object with range (first 10 bytes) + let get_request = GetObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .range("bytes=0-9".to_string()) + .build(); + + let get_result = ctx.client.get_object(&get_request).await; + assert!( + get_result.is_ok(), + "Failed to get object with range: {:?}", + get_result.err() + ); + let get_output = get_result.unwrap(); + + assert_eq!( + get_output.body, b"0123456789", + "Range request didn't return correct bytes" + ); + assert_eq!(get_output.content_length, Some(10)); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_object_with_metadata(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let object_key = "test-metadata-object.txt"; + let content = b"Test content"; + + // Put object with storage class + let put_request = PutObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .body(content.to_vec()) + .content_type("text/plain".to_string()) + .storage_class("STANDARD".to_string()) + .build(); + + let put_result = ctx.client.put_object(&put_request).await; + assert!( + put_result.is_ok(), + "Failed to put object with metadata: {:?}", + put_result.err() + ); + + // Verify with HEAD + let head_request = HeadObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .build(); + + let head_result = ctx.client.head_object(&head_request).await; + assert!( + head_result.is_ok(), + "Failed to head object: {:?}", + head_result.err() + ); + let head_output = head_result.unwrap(); + + assert_eq!(head_output.content_type, Some("text/plain".to_string())); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_get_large_object(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + + let object_key = "large-object.bin"; + // Create a 1MB object + let content: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); + + // Put object + let put_request = PutObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .body(content.clone()) + .content_type("application/octet-stream".to_string()) + .build(); + + let put_result = ctx.client.put_object(&put_request).await; + assert!( + put_result.is_ok(), + "Failed to put large object: {:?}", + put_result.err() + ); + + // Get object + let get_request = GetObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .build(); + + let get_result = ctx.client.get_object(&get_request).await; + assert!( + get_result.is_ok(), + "Failed to get large object: {:?}", + get_result.err() + ); + let get_output = get_result.unwrap(); + + assert_eq!( + get_output.body, content, + "Large object content doesn't match" + ); + assert_eq!(get_output.content_length, Some(content.len() as i64)); +} diff --git a/crates/alien-aws-clients/tests/aws_s3_client_tests/versioning.rs b/crates/alien-aws-clients/tests/aws_s3_client_tests/versioning.rs new file mode 100644 index 000000000..9995c081a --- /dev/null +++ b/crates/alien-aws-clients/tests/aws_s3_client_tests/versioning.rs @@ -0,0 +1,336 @@ +use crate::context::{put_test_object, S3TestContext}; +use alien_aws_clients::s3::{ + GetObjectRequest, HeadObjectRequest, ObjectIdentifier, PutObjectRequest, S3Api, + VersioningStatus, +}; +use test_context::test_context; + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_put_bucket_versioning(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for versioning test"); + + // Check initial versioning status (should be None/undefined) + let initial_versioning_result = ctx.client.get_bucket_versioning(&bucket_name).await; + assert!( + initial_versioning_result.is_ok(), + "Failed to get initial bucket versioning: {:?}", + initial_versioning_result.err() + ); + let initial_versioning = initial_versioning_result.unwrap(); + // Initial status should be None (versioning not enabled) + assert!( + initial_versioning.status.is_none(), + "Expected initial versioning status to be None, got {:?}", + initial_versioning.status + ); + + let result_enable = ctx + .client + .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) + .await; + assert!( + result_enable.is_ok(), + "Failed to enable versioning: {:?}", + result_enable.err() + ); + + // Check versioning status after enabling + let enabled_versioning_result = ctx.client.get_bucket_versioning(&bucket_name).await; + assert!( + enabled_versioning_result.is_ok(), + "Failed to get bucket versioning after enabling: {:?}", + enabled_versioning_result.err() + ); + let enabled_versioning = enabled_versioning_result.unwrap(); + assert!( + matches!(enabled_versioning.status, Some(VersioningStatus::Enabled)), + "Expected versioning status to be Enabled, got {:?}", + enabled_versioning.status + ); + + let result_suspend = ctx + .client + .put_bucket_versioning(&bucket_name, VersioningStatus::Suspended) + .await; + assert!( + result_suspend.is_ok(), + "Failed to suspend versioning: {:?}", + result_suspend.err() + ); + + // Check versioning status after suspending + let suspended_versioning_result = ctx.client.get_bucket_versioning(&bucket_name).await; + assert!( + suspended_versioning_result.is_ok(), + "Failed to get bucket versioning after suspending: {:?}", + suspended_versioning_result.err() + ); + let suspended_versioning = suspended_versioning_result.unwrap(); + assert!( + matches!( + suspended_versioning.status, + Some(VersioningStatus::Suspended) + ), + "Expected versioning status to be Suspended, got {:?}", + suspended_versioning.status + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_empty_bucket_with_versions(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket for versioned empty_bucket test"); + ctx.client + .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) + .await + .expect("Failed to enable versioning"); + + // Create multiple versions of an object + put_test_object( + &ctx.client, + &bucket_name, + "versioned_obj.txt", + b"version1".to_vec(), + Some("text/plain"), + ) + .await + .unwrap(); + put_test_object( + &ctx.client, + &bucket_name, + "versioned_obj.txt", + b"version2".to_vec(), + Some("text/plain"), + ) + .await + .unwrap(); + put_test_object( + &ctx.client, + &bucket_name, + "other_obj.txt", + b"other".to_vec(), + Some("text/plain"), + ) + .await + .unwrap(); + + // Add a delete marker + let objects_to_delete_marker = [ObjectIdentifier::builder() + .key("versioned_obj.txt".to_string()) + .build()]; + ctx.client + .delete_objects(&bucket_name, &objects_to_delete_marker, false) + .await + .expect("Failed to create delete marker"); + + let empty_result = ctx.client.empty_bucket(&bucket_name).await; + assert!( + empty_result.is_ok(), + "empty_bucket with versions failed: {:?}", + empty_result.err() + ); + + // Verify bucket is empty (no objects or versions left) + // ListObjectVersions would be the definitive check, but it's not directly exposed. + // empty_bucket aims to delete all versions and markers. + // A ListObjectsV2 might show empty if only delete markers for all objects are left and then removed. + let list_after_empty_result = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert!( + list_after_empty_result.contents.is_empty(), + "Bucket (current versions) not empty after versioned empty_bucket" + ); + + // To be absolutely sure, try to delete the bucket. If it fails due to contents, empty_bucket didn't fully work. + let delete_bucket_result = ctx.client.delete_bucket(&bucket_name).await; + if delete_bucket_result.is_ok() { + ctx.untrack_bucket(&bucket_name); + } + assert!( + delete_bucket_result.is_ok(), + "Failed to delete bucket after versioned empty_bucket, likely not empty: {:?}. List output: {:?}", + delete_bucket_result.err(), + list_after_empty_result + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_versioned_object_operations(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + ctx.client + .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) + .await + .expect("Failed to enable versioning"); + + let object_key = "versioned-object.txt"; + + // Create multiple versions + for i in 1..=3 { + let content = format!("Version {} content", i); + let result = put_test_object( + &ctx.client, + &bucket_name, + object_key, + content.into_bytes(), + Some("text/plain"), + ) + .await; + assert!( + result.is_ok(), + "Failed to put version {}: {:?}", + i, + result.err() + ); + } + + // List objects should show only the latest version + let list_result = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert_eq!(list_result.contents.len(), 1); + assert_eq!(list_result.contents[0].key, object_key); + + // Delete the current version (creates a delete marker) + let delete_objects = [ObjectIdentifier::builder() + .key(object_key.to_string()) + .build()]; + let delete_result = ctx + .client + .delete_objects(&bucket_name, &delete_objects, false) + .await; + assert!( + delete_result.is_ok(), + "Failed to delete versioned object: {:?}", + delete_result.err() + ); + + // Object should no longer appear in regular listing + let list_after_delete = ctx + .client + .list_objects_v2(&bucket_name, None, None) + .await + .unwrap(); + assert!( + list_after_delete.contents.is_empty(), + "Object should not appear after delete marker" + ); +} + +#[test_context(S3TestContext)] +#[tokio::test] +async fn test_versioned_get_and_head(ctx: &mut S3TestContext) { + let bucket_name = ctx.generate_unique_bucket_name(); + ctx.create_test_bucket(&bucket_name) + .await + .expect("Failed to create bucket"); + ctx.client + .put_bucket_versioning(&bucket_name, VersioningStatus::Enabled) + .await + .expect("Failed to enable versioning"); + + let object_key = "versioned-object.txt"; + let content_v1 = b"Version 1"; + let content_v2 = b"Version 2"; + + // Put version 1 + let put_request_v1 = PutObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .body(content_v1.to_vec()) + .content_type("text/plain".to_string()) + .build(); + + let put_result_v1 = ctx.client.put_object(&put_request_v1).await; + assert!( + put_result_v1.is_ok(), + "Failed to put version 1: {:?}", + put_result_v1.err() + ); + let version_id_1 = put_result_v1 + .unwrap() + .version_id + .expect("Expected version ID for v1"); + + // Put version 2 + let put_request_v2 = PutObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .body(content_v2.to_vec()) + .content_type("text/plain".to_string()) + .build(); + + let put_result_v2 = ctx.client.put_object(&put_request_v2).await; + assert!( + put_result_v2.is_ok(), + "Failed to put version 2: {:?}", + put_result_v2.err() + ); + let version_id_2 = put_result_v2 + .unwrap() + .version_id + .expect("Expected version ID for v2"); + + // Get version 1 + let get_request_v1 = GetObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .version_id(version_id_1.clone()) + .build(); + + let get_result_v1 = ctx.client.get_object(&get_request_v1).await; + assert!( + get_result_v1.is_ok(), + "Failed to get version 1: {:?}", + get_result_v1.err() + ); + assert_eq!(get_result_v1.unwrap().body, content_v1); + + // Get version 2 (latest) + let get_request_v2 = GetObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .build(); + + let get_result_v2 = ctx.client.get_object(&get_request_v2).await; + assert!( + get_result_v2.is_ok(), + "Failed to get version 2: {:?}", + get_result_v2.err() + ); + assert_eq!(get_result_v2.unwrap().body, content_v2); + + // Head version 1 + let head_request_v1 = HeadObjectRequest::builder() + .bucket(bucket_name.clone()) + .key(object_key.to_string()) + .version_id(version_id_1) + .build(); + + let head_result_v1 = ctx.client.head_object(&head_request_v1).await; + assert!( + head_result_v1.is_ok(), + "Failed to head version 1: {:?}", + head_result_v1.err() + ); + assert_eq!( + head_result_v1.unwrap().content_length, + Some(content_v1.len() as i64) + ); +} diff --git a/crates/alien-build/src/base_images.rs b/crates/alien-build/src/base_images.rs new file mode 100644 index 000000000..3c4b45728 --- /dev/null +++ b/crates/alien-build/src/base_images.rs @@ -0,0 +1,337 @@ +use crate::cache::{compute_function_content_hash, finalize_artifact_dir, temp_artifact_dir}; +use crate::command_output::image_build_error_with_output; +use crate::error::{ErrorData, Result}; +use crate::settings::{BinaryTargetExt, BuildSettings}; +use crate::toolchain; +use alien_core::{alien_event, AlienEvent}; +use alien_error::{Context, IntoAlienError}; +use std::error::Error as StdError; +use std::path::Path; +use std::time::Duration; +use tokio::fs; +use tracing::info; + +pub(crate) const BASE_IMAGE_BUILD_MAX_ATTEMPTS: usize = 3; + +/// Return the ordered base-image inputs that affect a source artifact. +/// Host-process and Dockerfile builds do not use the source toolchain bases. +pub(crate) fn effective_source_base_images( + toolchain_config: &alien_core::ToolchainConfig, + settings: &BuildSettings, + workload: toolchain::WorkloadKind, + host_process: bool, +) -> Vec { + if host_process || matches!(toolchain_config, alien_core::ToolchainConfig::Docker { .. }) { + return vec![]; + } + + let defaults = match workload { + toolchain::WorkloadKind::Worker => toolchain::WORKER_BASE_IMAGES, + toolchain::WorkloadKind::Container | toolchain::WorkloadKind::Daemon => { + toolchain::DIRECT_BASE_IMAGES + } + } + .iter() + .map(|image| (*image).to_string()) + .collect::>(); + + base_images_for_workload(&defaults, settings.override_base_image.as_deref(), workload) +} + +/// Apply a feature-versioned runtime base only to Worker source images. +pub(crate) fn base_images_for_workload( + base_images: &[String], + override_base_image: Option<&str>, + workload: toolchain::WorkloadKind, +) -> Vec { + if workload == toolchain::WorkloadKind::Worker { + if let Some(override_image) = override_base_image { + return vec![override_image.to_string()]; + } + } + + base_images.to_vec() +} + +/// Decide the ENTRYPOINT/CMD pair an image gets from a [`ToolchainOutput`]. +/// +/// - `entrypoint: Some` — direct-entrypoint images (source-built +/// Containers/Daemons): the compiled binary overrides the plain base +/// image's entrypoint and clears inherited CMD. CMD is set only when +/// `runtime_command` is nonempty — direct images carry no runtime wrapper +/// and no `--` separator. +/// - `entrypoint: None` — keep the base image's entrypoint (e.g. alien-base's +/// `alien-worker-runtime`) and always set CMD from `runtime_command`. +/// +/// The resulting image shapes are pinned by `tests/image_shape_tests.rs`. +pub(crate) fn image_entrypoint_and_cmd( + output: &toolchain::ToolchainOutput, +) -> (Option>, Option>) { + match &output.entrypoint { + Some(entrypoint) => { + let cmd = if output.runtime_command.is_empty() { + None + } else { + Some(output.runtime_command.clone()) + }; + (Some(entrypoint.clone()), cmd) + } + None => (None, Some(output.runtime_command.clone())), + } +} + +/// Apply the [`image_entrypoint_and_cmd`] contract to a dockdash image +/// builder. Used by both the base-image and from-scratch build paths. +pub(crate) fn apply_image_command( + mut builder: dockdash::ImageBuilder, + output: &toolchain::ToolchainOutput, +) -> dockdash::ImageBuilder { + let (entrypoint, cmd) = image_entrypoint_and_cmd(output); + if let Some(entrypoint) = entrypoint { + builder = builder.entrypoint(entrypoint); + } + if let Some(cmd) = cmd { + builder = builder.cmd(cmd); + } + builder +} + +pub(crate) fn base_image_build_retry_delay(attempt: usize) -> Duration { + match attempt { + 1 => Duration::from_secs(2), + 2 => Duration::from_secs(5), + _ => Duration::from_secs(10), + } +} + +pub(crate) fn is_retryable_dockdash_image_pull_error(error: &dockdash::Error) -> bool { + match error { + dockdash::Error::ImagePull { source, .. } => { + source + .as_deref() + .map(is_retryable_image_pull_source) + .unwrap_or(false) + || is_retryable_image_pull_text(&error.to_string()) + } + _ => false, + } +} + +fn is_retryable_image_pull_text(message: &str) -> bool { + const RETRYABLE_MARKERS: &[&str] = &[ + "error sending request", + "client error (sendrequest)", + "connection error", + "connection aborted", + "connection reset", + "connection refused", + "connection closed", + "timed out", + "unexpected eof", + "broken pipe", + "temporary failure in name resolution", + "dns error", + ]; + + let message = message.to_ascii_lowercase(); + RETRYABLE_MARKERS + .iter() + .any(|marker| message.contains(marker)) +} + +fn is_retryable_image_pull_source(source: &(dyn StdError + Send + Sync + 'static)) -> bool { + let mut current = Some(source as &(dyn StdError + 'static)); + + while let Some(error) = current { + if let Some(oci_error) = error.downcast_ref::() { + return is_retryable_oci_error(oci_error); + } + + if let Some(reqwest_error) = error.downcast_ref::() { + return reqwest_error.is_timeout() + || reqwest_error.is_connect() + || reqwest_error + .status() + .map(|status| status.is_server_error() || status.as_u16() == 429) + .unwrap_or(false); + } + + if let Some(io_error) = error.downcast_ref::() { + return matches!( + io_error.kind(), + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::WouldBlock + ); + } + + current = error.source(); + } + + false +} + +fn is_retryable_oci_error(error: &oci_client::errors::OciDistributionError) -> bool { + match error { + oci_client::errors::OciDistributionError::ServerError { code, .. } => { + *code >= 500 || *code == 429 + } + oci_client::errors::OciDistributionError::RequestError(error) => { + error.is_timeout() + || error.is_connect() + || error + .status() + .map(|status| status.is_server_error() || status.as_u16() == 429) + .unwrap_or(false) + } + oci_client::errors::OciDistributionError::IoError(error) => matches!( + error.kind(), + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::WouldBlock + ), + _ => false, + } +} + +/// Pull a Docker image and export it to OCI tarballs for each target architecture. +/// This handles both registry images (nginx:latest) and local images (my-app:v1). +#[alien_event(AlienEvent::BuildingResource { + resource_name: container_name.to_string(), + resource_type: "container".to_string(), + related_resources: vec![], +})] +pub(crate) async fn pull_and_export_image( + image: &str, + container_name: &str, + _stack_id: &str, + settings: &BuildSettings, + build_output_dir: &Path, +) -> Result { + info!( + "Pulling and exporting image '{}' for container '{}'", + image, container_name + ); + + let targets = settings.get_targets(); + + info!( + "Exporting image '{}' for {} target(s): {:?}", + image, + targets.len(), + targets + ); + + let container_dir = temp_artifact_dir(build_output_dir, container_name); + fs::create_dir_all(&container_dir) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "create directory".to_string(), + file_path: container_dir.display().to_string(), + reason: "Failed to create container directory for export".to_string(), + })?; + + // Pull the image for each target architecture + use std::process::Stdio; + use tokio::process::Command; + + for target in targets { + let arch_str = match target.to_dockdash_arch() { + dockdash::Arch::Amd64 => "amd64", + dockdash::Arch::ARM64 => "arm64", + _ => "amd64", // Fallback for other architectures + }; + let platform_str = format!("linux/{}", arch_str); + + info!("Pulling image '{}' for platform {}", image, platform_str); + + // Pull the image with specific platform + let pull_output = Command::new("docker") + .args(&["pull", "--platform", &platform_str, image]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: container_name.to_string(), + reason: "Failed to execute docker pull".to_string(), + build_output: None, + })?; + + if !pull_output.status.success() { + return Err(image_build_error_with_output( + container_name, + format!("docker pull failed for image '{}'", image), + &pull_output, + )); + } + + info!("Successfully pulled image '{}' for {}", image, platform_str); + + // Export to OCI tarball + let target_filename = format!("{}.oci.tar", target.runtime_platform_id()); + let output_tarball = container_dir.join(&target_filename); + + info!("Exporting to OCI tarball: {}", output_tarball.display()); + + let save_output = Command::new("docker") + .args(&["save", "-o", &output_tarball.to_string_lossy(), image]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: container_name.to_string(), + reason: "Failed to execute docker save".to_string(), + build_output: None, + })?; + + if !save_output.status.success() { + return Err(image_build_error_with_output( + container_name, + "docker save failed", + &save_output, + )); + } + + // Flatten the saved archive to a single image manifest before the OCI reader sees it. + crate::toolchain::docker::DockerToolchain::normalize_oci_archive( + &output_tarball, + arch_str, + )?; + + info!( + "Successfully exported {} to {}", + image, + output_tarball.display() + ); + } + + // Compute content hash + let content_hash = compute_function_content_hash(&container_dir).await?; + let short_hash = &content_hash[..8]; + + // Rename directory to include content hash + let hashed_dir_name = format!("{}-{}", container_name, short_hash); + let final_output_dir = build_output_dir.join(&hashed_dir_name); + + let finalized_dir = finalize_artifact_dir(&container_dir, &final_output_dir, "export").await?; + + info!( + "Completed export for container '{}'. Images directory: {} (hash: {})", + container_name, + final_output_dir.display(), + short_hash + ); + + Ok(finalized_dir) +} diff --git a/crates/alien-build/src/cache.rs b/crates/alien-build/src/cache.rs new file mode 100644 index 000000000..ffca18c59 --- /dev/null +++ b/crates/alien-build/src/cache.rs @@ -0,0 +1,671 @@ +use crate::base_images::effective_source_base_images; +use crate::error::{ErrorData, Result}; +use crate::push::generate_unique_tag; +use crate::settings::BuildSettings; +use crate::toolchain; +use alien_core::{BinaryTarget, Platform, ToolchainConfig}; +use alien_error::{AlienError, Context, IntoAlienError}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use tokio::fs; +use tokio::process::Command; +use tracing::info; + +pub(crate) const ARTIFACT_CACHE_METADATA_FILE: &str = ".alien-build-cache.json"; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub(crate) struct ArtifactCacheMetadata { + cache_key: String, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct CargoMetadata { + packages: Vec, + resolve: Option, + workspace_root: PathBuf, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct CargoMetadataPackage { + id: String, + manifest_path: PathBuf, + source: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct CargoMetadataResolve { + root: Option, + nodes: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct CargoMetadataNode { + id: String, + dependencies: Vec, +} + +pub(crate) fn temp_artifact_dir(build_output_dir: &Path, resource_name: &str) -> PathBuf { + build_output_dir.join(format!(".{}-tmp-{}", resource_name, generate_unique_tag())) +} + +pub(crate) async fn finalize_artifact_dir( + temp_dir: &Path, + final_dir: &Path, + artifact_kind: &str, +) -> Result { + match fs::rename(temp_dir, final_dir).await { + Ok(()) => Ok(final_dir.to_string_lossy().into_owned()), + Err(_rename_error) if final_dir.exists() => { + let _ = fs::remove_dir_all(temp_dir).await; + info!( + "Reusing existing {} artifacts at {}", + artifact_kind, + final_dir.display() + ); + Ok(final_dir.to_string_lossy().into_owned()) + } + Err(rename_error) => { + Err(rename_error) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "rename directory".to_string(), + file_path: temp_dir.display().to_string(), + reason: format!( + "Failed to rename {} directory to {}", + artifact_kind, + final_dir.display() + ), + }) + } + } +} + +pub(crate) async fn compute_source_artifact_cache_key( + src: &str, + toolchain_config: &alien_core::ToolchainConfig, + settings: &BuildSettings, + targets: &[BinaryTarget], + workload: toolchain::WorkloadKind, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"alien-build-artifact-cache-v3"); + hasher.update(src.as_bytes()); + hasher.update( + serde_json::to_vec(toolchain_config) + .into_alien_error() + .context(ErrorData::JsonSerializationError { + message: "Failed to serialize toolchain config for build cache key".to_string(), + })?, + ); + // Source artifact bytes are platform-independent for equivalent target sets. + // The actual differences are target triples, debug/release mode, workload + // kind (which decides the image shape: runtime for Workers, direct + // entrypoint for Containers/Daemons), base image, and whether the built + // binary runs as a host process. This lets e.g. GCP and Azure reuse the + // same linux-x64 artifacts. + let host_process = workload != toolchain::WorkloadKind::Container + && settings.platform.runtime_platform() == Platform::Local; + hasher.update(host_process.to_string().as_bytes()); + hasher.update(settings.debug_mode.to_string().as_bytes()); + hasher.update(workload.as_str().as_bytes()); + for base_image in + effective_source_base_images(toolchain_config, settings, workload, host_process) + { + hasher.update(b"\0base-image\0"); + hasher.update(base_image.as_bytes()); + } + for target in targets { + hasher.update(target.runtime_platform_id().as_bytes()); + } + + hash_build_input_source(src, toolchain_config, targets, &mut hasher).await?; + + Ok(format!("{:x}", hasher.finalize())) +} + +pub(crate) async fn hash_build_input_source( + src: &str, + toolchain_config: &alien_core::ToolchainConfig, + targets: &[BinaryTarget], + hasher: &mut Sha256, +) -> Result<()> { + match toolchain_config { + ToolchainConfig::Rust { .. } => hash_rust_build_input_graph(Path::new(src), hasher).await, + ToolchainConfig::TypeScript { .. } => { + hash_source_directory(Path::new(src), hasher).await?; + hash_typescript_dependency_inputs(Path::new(src), targets, hasher).await + } + _ => hash_source_directory(Path::new(src), hasher).await, + } +} + +/// Hash the build inputs a TypeScript app pulls in from OUTSIDE its source +/// directory, which `hash_source_directory` cannot see (`node_modules` is +/// excluded from the source walk): +/// +/// - the `dist/` content of every `@alienplatform/*` package the app has +/// installed, resolved through its symlink to the real location — a +/// `workspace:`/`file:` dependency changes content without changing any +/// version number, and the compiled binary bundles that content; +/// - the workspace dev addon files for the requested targets — the compiled +/// binary embeds the addon, so a rebuilt addon must invalidate the cached +/// artifact. +/// +/// Registry-installed dependencies are content-addressed by the lockfile, +/// which lives in the source directory and is already hashed. +pub(crate) async fn hash_typescript_dependency_inputs( + src_dir: &Path, + targets: &[BinaryTarget], + hasher: &mut Sha256, +) -> Result<()> { + let scope_dir = src_dir.join("node_modules").join("@alienplatform"); + let Ok(entries) = std::fs::read_dir(&scope_dir) else { + // No workspace packages installed — nothing extra to hash. + return Ok(()); + }; + + let mut packages: Vec = entries.flatten().map(|entry| entry.path()).collect(); + packages.sort(); + + let mut bindings_realpath: Option = None; + for package_dir in packages { + let Ok(realpath) = package_dir.canonicalize() else { + continue; + }; + if package_dir.file_name().is_some_and(|n| n == "bindings") { + bindings_realpath = Some(realpath.clone()); + } + hasher.update(b"alienplatform-package"); + hasher.update(package_dir.to_string_lossy().as_bytes()); + let dist = realpath.join("dist"); + if dist.is_dir() { + hash_source_directory(&dist, hasher).await?; + } + } + + // Workspace dev addons for the requested targets, anchored on the real + // bindings package location (mirrors the staging lookup's anchor). + if let Some(bindings) = bindings_realpath { + for addon in toolchain::native_addon::workspace_addon_inputs(&bindings, targets) { + hasher.update(b"native-addon"); + hasher.update(addon.to_string_lossy().as_bytes()); + let bytes = fs::read(&addon).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read file".to_string(), + file_path: addon.display().to_string(), + reason: "Failed to read native addon for build cache key".to_string(), + }, + )?; + hasher.update(&bytes); + } + } + + Ok(()) +} + +pub(crate) async fn hash_rust_build_input_graph(src_dir: &Path, hasher: &mut Sha256) -> Result<()> { + let metadata = read_cargo_metadata(src_dir).await?; + hasher.update(b"rust-cargo-metadata-v1"); + + let lockfile = metadata.workspace_root.join("Cargo.lock"); + if lockfile.is_file() { + hasher.update(b"cargo-lock"); + let lockfile_bytes = fs::read(&lockfile).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read file".to_string(), + file_path: lockfile.display().to_string(), + reason: "Failed to read Cargo.lock for build cache key".to_string(), + }, + )?; + hasher.update(lockfile_bytes); + } + + // Workspace-level toolchain configuration changes the produced binary even + // when no package source changes: rust-toolchain pins the compiler, + // .cargo/config.toml can change rustflags, linker, or profile settings. + // These live at the workspace root, outside the per-package directories + // hashed below, so hash them explicitly. Absent files contribute nothing, + // which keeps existing cache keys stable for projects without them. + for toolchain_file in [ + "rust-toolchain.toml", + "rust-toolchain", + ".cargo/config.toml", + ".cargo/config", + ] { + let path = metadata.workspace_root.join(toolchain_file); + if path.is_file() { + let contents = fs::read(&path).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read file".to_string(), + file_path: path.display().to_string(), + reason: "Failed to read toolchain config file for build cache key".to_string(), + }, + )?; + hasher.update(b"toolchain-config-file"); + hasher.update(toolchain_file.as_bytes()); + hasher.update(contents); + } + } + + let local_package_ids = local_cargo_package_ids(&metadata); + let mut local_packages: Vec<_> = metadata + .packages + .iter() + .filter(|package| local_package_ids.contains(&package.id)) + .collect(); + local_packages.sort_by(|left, right| left.id.cmp(&right.id)); + + for package in local_packages { + hasher.update(b"local-package"); + hasher.update(package.id.as_bytes()); + hasher.update(package.manifest_path.to_string_lossy().as_bytes()); + let package_dir = package.manifest_path.parent().ok_or_else(|| { + AlienError::new(ErrorData::BuildConfigInvalid { + message: format!( + "Cargo metadata package '{}' has manifest path without parent: {}", + package.id, + package.manifest_path.display() + ), + }) + })?; + hash_source_directory(package_dir, hasher).await?; + } + + Ok(()) +} + +pub(crate) async fn read_cargo_metadata(src_dir: &Path) -> Result { + let manifest_path = src_dir.join("Cargo.toml"); + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--manifest-path"]) + .arg(&manifest_path) + .output() + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: src_dir.display().to_string(), + reason: "Failed to execute cargo metadata for build cache key".to_string(), + build_output: None, + })?; + + if !output.status.success() { + let mut build_output = String::new(); + build_output.push_str(&String::from_utf8_lossy(&output.stdout)); + build_output.push_str(&String::from_utf8_lossy(&output.stderr)); + return Err(AlienError::new(ErrorData::ImageBuildFailed { + resource_name: src_dir.display().to_string(), + reason: "cargo metadata failed while computing build cache key".to_string(), + build_output: Some(build_output), + })); + } + + serde_json::from_slice(&output.stdout) + .into_alien_error() + .context(ErrorData::JsonSerializationError { + message: "Failed to parse cargo metadata JSON for build cache key".to_string(), + }) +} + +pub(crate) fn local_cargo_package_ids(metadata: &CargoMetadata) -> HashSet { + let packages_by_id: HashMap<_, _> = metadata + .packages + .iter() + .map(|package| (package.id.as_str(), package)) + .collect(); + + let Some(resolve) = &metadata.resolve else { + return metadata + .packages + .iter() + .filter(|package| package.source.is_none()) + .map(|package| package.id.clone()) + .collect(); + }; + let Some(root) = &resolve.root else { + return metadata + .packages + .iter() + .filter(|package| package.source.is_none()) + .map(|package| package.id.clone()) + .collect(); + }; + + let nodes_by_id: HashMap<_, _> = resolve + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect(); + let mut visited = HashSet::new(); + let mut stack = vec![root.as_str()]; + + while let Some(id) = stack.pop() { + if !visited.insert(id.to_string()) { + continue; + } + + let Some(node) = nodes_by_id.get(id) else { + continue; + }; + + for dependency in &node.dependencies { + stack.push(dependency); + } + } + + visited + .into_iter() + .filter(|id| { + packages_by_id + .get(id.as_str()) + .map(|package| package.source.is_none()) + .unwrap_or(false) + }) + .collect() +} + +pub(crate) async fn hash_source_directory(src_dir: &Path, hasher: &mut Sha256) -> Result<()> { + let mut files = Vec::new(); + collect_source_files(src_dir, src_dir, &mut files)?; + files.sort(); + + for relative_path in files { + let full_path = src_dir.join(&relative_path); + let contents = fs::read(&full_path).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read file".to_string(), + file_path: full_path.display().to_string(), + reason: "Failed to read source file for build cache key".to_string(), + }, + )?; + hasher.update(relative_path.to_string_lossy().as_bytes()); + hasher.update(&contents); + } + + Ok(()) +} + +pub(crate) fn collect_source_files( + base_dir: &Path, + dir: &Path, + files: &mut Vec, +) -> Result<()> { + let entries = + std::fs::read_dir(dir) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory".to_string(), + file_path: dir.display().to_string(), + reason: "Failed to read source directory for build cache key".to_string(), + })?; + + for entry in entries { + let entry = entry + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory entry".to_string(), + file_path: dir.display().to_string(), + reason: "Failed to iterate source directory for build cache key".to_string(), + })?; + let path = entry.path(); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + + if is_ignored_source_cache_path(file_name.as_ref()) { + continue; + } + + let file_type = + entry + .file_type() + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read metadata".to_string(), + file_path: path.display().to_string(), + reason: "Failed to read source file type for build cache key".to_string(), + })?; + + if file_type.is_dir() { + collect_source_files(base_dir, &path, files)?; + } else if file_type.is_file() { + let relative_path = path.strip_prefix(base_dir).into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "strip prefix".to_string(), + file_path: path.display().to_string(), + reason: "Failed to compute relative source path for build cache key" + .to_string(), + }, + )?; + files.push(relative_path.to_path_buf()); + } + } + + Ok(()) +} + +pub(crate) fn is_ignored_source_cache_path(file_name: &str) -> bool { + matches!( + file_name, + ".git" | ".alien" | ".alien-build" | "target" | "node_modules" | "alien-bindings.node" // staged addon: derived artifact, hashed via its source + ) || file_name.ends_with(".bun-build") +} + +pub(crate) async fn find_cached_artifact_dir( + build_output_dir: &Path, + resource_name: &str, + targets: &[BinaryTarget], + artifact_cache_key: &str, +) -> Result> { + if let Some(path) = + find_cached_artifact_dir_in(build_output_dir, resource_name, targets, artifact_cache_key) + .await? + { + return Ok(Some(path)); + } + + let Some(parent_dir) = build_output_dir.parent() else { + return Ok(None); + }; + + let mut platform_entries = fs::read_dir(parent_dir).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read directory".to_string(), + file_path: parent_dir.display().to_string(), + reason: "Failed to read sibling build directories for artifact cache".to_string(), + }, + )?; + + while let Some(entry) = platform_entries + .next_entry() + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory entry".to_string(), + file_path: parent_dir.display().to_string(), + reason: "Failed to iterate sibling build directories for artifact cache".to_string(), + })? + { + let path = entry.path(); + if path == build_output_dir { + continue; + } + + let file_type = + entry + .file_type() + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read metadata".to_string(), + file_path: path.display().to_string(), + reason: "Failed to read sibling artifact cache directory metadata".to_string(), + })?; + if !file_type.is_dir() { + continue; + } + + if let Some(cached) = + find_cached_artifact_dir_in(&path, resource_name, targets, artifact_cache_key).await? + { + return Ok(Some(cached)); + } + } + + Ok(None) +} + +pub(crate) async fn find_cached_artifact_dir_in( + build_output_dir: &Path, + resource_name: &str, + targets: &[BinaryTarget], + artifact_cache_key: &str, +) -> Result> { + let mut entries = fs::read_dir(build_output_dir) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory".to_string(), + file_path: build_output_dir.display().to_string(), + reason: "Failed to read build output directory for artifact cache".to_string(), + })?; + + let prefix = format!("{resource_name}-"); + while let Some(entry) = + entries + .next_entry() + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory entry".to_string(), + file_path: build_output_dir.display().to_string(), + reason: "Failed to iterate build output directory for artifact cache".to_string(), + })? + { + let path = entry.path(); + let file_type = + entry + .file_type() + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read metadata".to_string(), + file_path: path.display().to_string(), + reason: "Failed to read artifact cache entry metadata".to_string(), + })?; + if !file_type.is_dir() { + continue; + } + + let Some(dir_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !dir_name.starts_with(&prefix) { + continue; + } + + let has_all_targets = targets.iter().all(|target| { + path.join(format!("{}.oci.tar", target.runtime_platform_id())) + .is_file() + }); + if !has_all_targets { + continue; + } + + let Ok(metadata_content) = + fs::read_to_string(path.join(ARTIFACT_CACHE_METADATA_FILE)).await + else { + continue; + }; + let Ok(metadata) = serde_json::from_str::(&metadata_content) else { + continue; + }; + if metadata.cache_key == artifact_cache_key { + return Ok(Some(path)); + } + } + + Ok(None) +} + +pub(crate) async fn write_artifact_cache_metadata( + artifact_dir: &Path, + artifact_cache_key: &str, +) -> Result<()> { + let metadata = ArtifactCacheMetadata { + cache_key: artifact_cache_key.to_string(), + }; + let content = serde_json::to_string_pretty(&metadata) + .into_alien_error() + .context(ErrorData::JsonSerializationError { + message: "Failed to serialize build artifact cache metadata".to_string(), + })?; + + fs::write(artifact_dir.join(ARTIFACT_CACHE_METADATA_FILE), content) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "write".to_string(), + file_path: artifact_dir + .join(ARTIFACT_CACHE_METADATA_FILE) + .display() + .to_string(), + reason: "Failed to write build artifact cache metadata".to_string(), + })?; + + Ok(()) +} + +/// Compute a content hash of all OCI tarballs in a directory. +/// +/// This hash is used to detect code changes between builds. When the source code +/// changes, the OCI tarball contents change, producing a different hash. This hash +/// is then included in the output directory name, ensuring the executor detects +/// config changes and plans an UPDATE. +pub(crate) async fn compute_function_content_hash(dir: &Path) -> Result { + let mut hasher = Sha256::new(); + let mut entries = + fs::read_dir(dir) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory".to_string(), + file_path: dir.display().to_string(), + reason: "Failed to read function build directory for hashing".to_string(), + })?; + + // Collect all OCI tarball paths and sort for deterministic hashing + let mut tarball_paths: Vec = vec![]; + while let Some(entry) = + entries + .next_entry() + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory entry".to_string(), + file_path: dir.display().to_string(), + reason: "Failed to iterate build directory entries".to_string(), + })? + { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "tar") { + tarball_paths.push(path); + } + } + tarball_paths.sort(); + + // Hash contents of all tarballs in deterministic order + for path in tarball_paths { + let contents = + fs::read(&path) + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read file".to_string(), + file_path: path.display().to_string(), + reason: "Failed to read OCI tarball for hashing".to_string(), + })?; + hasher.update(&contents); + } + + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/crates/alien-build/src/lib.rs b/crates/alien-build/src/lib.rs index 23f9ba07a..a0fa534a8 100644 --- a/crates/alien-build/src/lib.rs +++ b/crates/alien-build/src/lib.rs @@ -1,75 +1,46 @@ +mod base_images; +mod cache; pub(crate) mod command_output; pub mod dependencies; pub mod error; pub mod merge; pub mod plan; +mod push; pub mod settings; pub mod toolchain; +#[cfg(test)] +mod tests; + +pub use push::push_stack; + use alien_core::{ alien_event, AlienEvent, BinaryTarget, Container, ContainerCode, Daemon, DaemonCode, Platform, - Stack, ToolchainConfig, Worker, WorkerCode, + Stack, ToolchainConfig, WorkerCode, }; use alien_error::{AlienError, Context, IntoAlienError}; use alien_preflights::runner::PreflightRunner; -use command_output::image_build_error_with_output; +use base_images::{ + apply_image_command, base_image_build_retry_delay, base_images_for_workload, + is_retryable_dockdash_image_pull_error, pull_and_export_image, BASE_IMAGE_BUILD_MAX_ATTEMPTS, +}; +use cache::{ + compute_function_content_hash, compute_source_artifact_cache_key, finalize_artifact_dir, + find_cached_artifact_dir, temp_artifact_dir, write_artifact_cache_metadata, +}; use dockdash::{Image as DockDashImage, Layer as DockDashLayer, PullPolicy}; use error::{DockdashResultExt, ErrorData, Result}; -use oci_client::client::{Client as OciClient, ClientConfig as OciClientConfig}; -use oci_client::manifest::{ - ImageIndexEntry, OciImageIndex, Platform as OciPlatform, IMAGE_MANIFEST_MEDIA_TYPE, - OCI_IMAGE_INDEX_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE, -}; -use oci_client::Reference; -use rand::distr::Alphanumeric; -use rand::Rng; +use push::generate_unique_tag; use reqwest::Url; -use settings::{BinaryTargetExt, BuildSettings, PlatformBuildSettings, PushSettings}; -use sha2::{Digest, Sha256}; +use settings::{BinaryTargetExt, BuildSettings, PlatformBuildSettings}; use std::collections::{HashMap, HashSet}; -use std::error::Error as StdError; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::Instant; use tokio::fs; -use tokio::process::Command; use tokio::time::sleep; use tracing::{info, warn}; -const BASE_IMAGE_BUILD_MAX_ATTEMPTS: usize = 3; -const ARTIFACT_CACHE_METADATA_FILE: &str = ".alien-build-cache.json"; - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -struct ArtifactCacheMetadata { - cache_key: String, -} - -#[derive(Debug, serde::Deserialize)] -struct CargoMetadata { - packages: Vec, - resolve: Option, - workspace_root: PathBuf, -} - -#[derive(Debug, serde::Deserialize)] -struct CargoMetadataPackage { - id: String, - manifest_path: PathBuf, - source: Option, -} - -#[derive(Debug, serde::Deserialize)] -struct CargoMetadataResolve { - root: Option, - nodes: Vec, -} - -#[derive(Debug, serde::Deserialize)] -struct CargoMetadataNode { - id: String, - dependencies: Vec, -} - /// Dedupe key for identifying containers that can share the same binary build. /// Containers with the same (src, toolchain_type, binary_name) produce identical binaries. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -1068,710 +1039,6 @@ fn strip_local_daemon_only_compute_clusters(stack: &mut Stack, platform: Platfor } } -/// A compute resource that has a locally-built image directory and needs to be pushed to a registry. -struct ResourcePushTarget { - /// Stack resource keys that should be updated with the pushed image URI. - resource_ids: Vec, - /// Resource IDs sharing this push target. The first name is used for logging and image tagging. - resource_names: Vec, - /// Display name for events/logging ("worker", "container", etc.) - resource_type: &'static str, - /// Local directory containing OCI tarballs produced by `alien build` - local_image_dir: PathBuf, -} - -impl ResourcePushTarget { - fn resource_name(&self) -> &str { - self.resource_names - .first() - .expect("push target should have at least one resource name") - } - - fn display_resource_name(&self) -> String { - if self.resource_names.len() > 1 { - format!("{} (shared)", self.resource_names.join(", ")) - } else { - self.resource_name().to_string() - } - } - - fn push_result_updates(&self, image_uri: String) -> Vec<(String, String)> { - self.resource_ids - .iter() - .map(|resource_id| (resource_id.clone(), image_uri.clone())) - .collect() - } -} - -fn push_target_for_local_image<'a>( - targets: &'a mut Vec, - resource_type: &'static str, - local_image_dir: &Path, -) -> Option<&'a mut ResourcePushTarget> { - targets.iter_mut().find(|target| { - target.resource_type == resource_type && target.local_image_dir == local_image_dir - }) -} - -fn add_push_target_resource( - targets: &mut Vec, - resource_id: String, - resource_name: String, - resource_type: &'static str, - local_image_dir: PathBuf, -) { - if let Some(target) = push_target_for_local_image(targets, resource_type, &local_image_dir) { - target.resource_ids.push(resource_id); - target.resource_names.push(resource_name); - return; - } - - targets.push(ResourcePushTarget { - resource_ids: vec![resource_id], - resource_names: vec![resource_name], - resource_type, - local_image_dir, - }); -} - -/// Scans all resources in the stack and returns those with locally-built images that need -/// pushing to a registry. -/// -/// Returns an error if any compute resource still has unbuilt source code — that means -/// `alien build` was not run first. -/// -/// To add support for a new compute resource type, add an `else if` branch here and in -/// [`apply_pushed_images`]. -fn collect_push_targets(stack: &Stack) -> Result> { - let mut targets = Vec::new(); - - for (resource_id, resource_entry) in stack.resources() { - if let Some(func) = resource_entry.config.downcast_ref::() { - match &func.code { - WorkerCode::Image { image } => { - let path = PathBuf::from(image); - if path.exists() && path.is_dir() { - info!( - "Worker '{}' has local image directory, queuing for push", - func.id - ); - add_push_target_resource( - &mut targets, - resource_id.clone(), - func.id.clone(), - "worker", - path, - ); - } else { - info!("Worker '{}' already has remote image: {}", func.id, image); - } - } - WorkerCode::Source { .. } => { - return Err(AlienError::new(ErrorData::InvalidResourceConfig { - resource_id: func.id.clone(), - reason: "Worker has source code instead of built image. Run 'alien build' first.".to_string(), - })); - } - } - } else if let Some(container) = resource_entry.config.downcast_ref::() { - match &container.code { - ContainerCode::Image { image } => { - let path = PathBuf::from(image); - if path.exists() && path.is_dir() { - info!( - "Container '{}' has local image directory, queuing for push", - container.id - ); - add_push_target_resource( - &mut targets, - resource_id.clone(), - container.id.clone(), - "container", - path, - ); - } else { - info!( - "Container '{}' already has remote image: {}", - container.id, image - ); - } - } - ContainerCode::Source { .. } => { - return Err(AlienError::new(ErrorData::InvalidResourceConfig { - resource_id: container.id.clone(), - reason: "Container has source code instead of built image. Run 'alien build' first.".to_string(), - })); - } - } - } else if let Some(daemon) = resource_entry.config.downcast_ref::() { - match &daemon.code { - DaemonCode::Image { image } => { - let path = PathBuf::from(image); - if path.exists() && path.is_dir() { - info!( - "Daemon '{}' has local image directory, queuing for push", - daemon.id - ); - add_push_target_resource( - &mut targets, - resource_id.clone(), - daemon.id.clone(), - "daemon", - path, - ); - } else { - info!("Daemon '{}' already has remote image: {}", daemon.id, image); - } - } - DaemonCode::Source { .. } => { - return Err(AlienError::new(ErrorData::InvalidResourceConfig { - resource_id: daemon.id.clone(), - reason: "Daemon has source code instead of built image. Run 'alien build' first.".to_string(), - })); - } - } - } - } - - Ok(targets) -} - -/// Applies pushed registry URIs back to their respective resources in the stack. -/// -/// To add support for a new compute resource type, add an `else if` branch here and in -/// [`collect_push_targets`]. -fn apply_pushed_images(stack: &mut Stack, updates: Vec<(String, String)>) { - for (resource_id, image_uri) in updates { - if let Some(resource_entry) = stack.resources_mut().find(|(id, _)| *id == &resource_id) { - if let Some(func) = resource_entry.1.config.downcast_mut::() { - func.code = WorkerCode::Image { image: image_uri }; - } else if let Some(container) = resource_entry.1.config.downcast_mut::() { - container.code = ContainerCode::Image { image: image_uri }; - } else if let Some(daemon) = resource_entry.1.config.downcast_mut::() { - daemon.code = DaemonCode::Image { image: image_uri }; - } - } - } -} - -/// Push built images from local OCI tarballs to a container registry. -/// Reads a stack with local image directory references, pushes them to the registry, -/// and returns an updated stack with pushed image URLs (in memory, not saved to disk). -#[alien_event(AlienEvent::PushingStack { - stack: stack.id().to_string(), - platform: platform.as_str().to_string(), - destination: push_settings.destination_label.clone(), -})] -pub async fn push_stack( - mut stack: Stack, - platform: Platform, - push_settings: &PushSettings, -) -> Result { - let push_started = Instant::now(); - info!( - "Starting image push process to registry: {}", - push_settings.repository - ); - - let to_push = collect_push_targets(&stack)?; - - let resource_count = to_push - .iter() - .map(|target| target.resource_ids.len()) - .sum::(); - - info!( - "Pushing {} artifact(s) for {} resource(s) to registry", - to_push.len(), - resource_count - ); - - if to_push.is_empty() { - info!("Image push process completed. No local images to push."); - return Ok(stack); - } - - // Push all resources in parallel with fail-fast behavior - let current_bus = alien_core::EventBus::current(); - let cancel_token = tokio_util::sync::CancellationToken::new(); - - let push_tasks: Vec<_> = to_push - .into_iter() - .map(|target| { - let resource_name = target.resource_name().to_string(); - let display_resource_name = target.display_resource_name(); - let resource_names = target.resource_names.clone(); - let repository = push_settings.repository.clone(); - let push_opts = push_settings.options.clone(); - let bus = current_bus.clone(); - let cancel_token = cancel_token.clone(); - - tokio::spawn(async move { - let resource_name_for_warning = resource_name.clone(); - - let push_work = async move { - let target_resource_count = target.resource_ids.len(); - - if resource_names.len() > 1 { - info!( - "Starting parallel push for shared {} artifact '{}': {:?}", - target.resource_type, resource_name, resource_names - ); - } else { - info!( - "Starting parallel push for {} '{}'", - target.resource_type, resource_name - ); - } - - if cancel_token.is_cancelled() { - return Err(AlienError::new(ErrorData::BuildCanceled { - resource_name: resource_name.clone(), - })); - } - - let artifact_push_started = Instant::now(); - let result = tokio::select! { - result = push_resource_images( - &display_resource_name, - &resource_name, - target.resource_type, - &target.local_image_dir, - &repository, - &push_opts, - ) => result, - _ = cancel_token.cancelled() => { - info!("Push for {} '{}' was cancelled", target.resource_type, resource_name); - Err(AlienError::new(ErrorData::BuildCanceled { - resource_name: resource_name.clone(), - })) - } - }; - - match &result { - Ok(image_uri) => info!( - resource = resource_name.as_str(), - push_secs = format!("{:.2}", artifact_push_started.elapsed().as_secs_f64()).as_str(), - "Successfully pushed {} '{}' in {:.2}s to: {}", - target.resource_type, - resource_name, - artifact_push_started.elapsed().as_secs_f64(), - image_uri - ), - Err(e) => info!("Failed to push {} '{}': {}", target.resource_type, resource_name, e), - } - - result.map(|image_uri| { - ( - target_resource_count, - target.push_result_updates(image_uri), - ) - }) - }; - - match bus { - Some(bus) => bus.run(|| push_work).await, - None => { - tracing::warn!("No event bus context available for parallel push of '{}'", resource_name_for_warning); - push_work.await - } - } - }) - }) - .collect(); - - // Wait for first failure or all completions (fail-fast) - let mut push_results: Vec<(String, String)> = Vec::new(); - let mut completed_tasks = 0; - let mut remaining_tasks = push_tasks; - let mut first_error: Option> = None; - - while !remaining_tasks.is_empty() { - let (result, _index, rest) = futures::future::select_all(remaining_tasks).await; - remaining_tasks = rest; - - match result { - Ok(push_result) => match push_result { - Ok((target_resource_count, updates)) => { - push_results.extend(updates); - completed_tasks += 1; - info!( - "Applied pushed image to {} resource(s)", - target_resource_count - ); - } - Err(e) => { - if first_error.is_none() { - first_error = Some(e); - cancel_token.cancel(); - for task in remaining_tasks { - task.abort(); - } - break; - } - } - }, - Err(join_error) => { - if join_error.is_cancelled() { - info!("Push task was cancelled"); - } else { - tracing::warn!("Push task failed: {}", join_error); - if first_error.is_none() { - first_error = Some(AlienError::new(ErrorData::ImagePushFailed { - image: "unknown".to_string(), - reason: format!("Push task failed: {}", join_error), - })); - cancel_token.cancel(); - } - } - } - } - } - - if let Some(error) = first_error { - return Err(error); - } - - info!( - "Completed parallel pushing of {} artifact(s)", - completed_tasks - ); - - apply_pushed_images(&mut stack, push_results); - - info!( - "Image push process completed in {:.2}s. Stack updated with {} resource image URL(s).", - push_started.elapsed().as_secs_f64(), - resource_count - ); - - Ok(stack) -} - -/// Push all OCI tarballs for a resource to the registry -#[alien_event(AlienEvent::PushingResource { - resource_name: display_resource_name.to_string(), - resource_type: resource_type.to_string(), -})] -async fn push_resource_images( - display_resource_name: &str, - resource_name: &str, - resource_type: &str, - images_dir: &Path, - repository: &str, - push_options: &dockdash::PushOptions, -) -> Result { - let push_resource_started = Instant::now(); - info!( - "Pushing images for resource '{}' from {}", - display_resource_name, - images_dir.display() - ); - - // Generate unique tag for this push - let image_tag = generate_unique_tag(); - // Resource name is part of the tag, not a path segment - let full_tag = format!("{}-{}", resource_name, image_tag); - let image_uri = format!("{}:{}", repository, full_tag); - - // Find all OCI tarball files in the directory - let mut oci_files = Vec::new(); - let mut entries = fs::read_dir(images_dir).await.into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "read directory".to_string(), - file_path: images_dir.display().to_string(), - reason: "Failed to list OCI tarballs".to_string(), - }, - )?; - - while let Some(entry) = - entries - .next_entry() - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory entry".to_string(), - file_path: images_dir.display().to_string(), - reason: "Failed to read directory entry".to_string(), - })? - { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("tar") - && path - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.contains(".oci.")) - .unwrap_or(false) - { - oci_files.push(path); - } - } - - if oci_files.is_empty() { - return Err(AlienError::new(ErrorData::InvalidResourceConfig { - resource_id: resource_name.to_string(), - reason: format!("No OCI tarball files found in {}", images_dir.display()), - })); - } - - info!( - "Found {} OCI tarball(s) to push for resource '{}'", - oci_files.len(), - resource_name - ); - - // Create push options with progress callback - let mut push_opts_with_progress = push_options.clone(); - - // Add progress callback that emits alien events - struct AlienPushProgressCallback { - resource_name: String, - } - - #[async_trait::async_trait] - impl dockdash::PushProgressCallback for AlienPushProgressCallback { - async fn on_progress(&self, progress: dockdash::PushProgressInfo) { - // Emit PushingImage event with progress - let _ = AlienEvent::PushingImage { - image: self.resource_name.clone(), - progress: Some(alien_core::PushProgress { - operation: progress.operation, - layers_uploaded: progress.layers_uploaded, - total_layers: progress.total_layers, - bytes_uploaded: progress.bytes_uploaded, - total_bytes: progress.total_bytes, - }), - } - .emit() - .await; - } - } - - push_opts_with_progress.progress_callback = Some(Box::new(AlienPushProgressCallback { - resource_name: resource_name.to_string(), - })); - - // Container images are linux; darwin/windows tarballs (produced for `local` host - // binaries) are not registry container images, so they're excluded from the push. - let linux_tarballs = select_linux_tarballs(&oci_files); - - // No linux image (unusual) — push whatever tarballs are present. - if linux_tarballs.is_empty() { - for oci_file in &oci_files { - let image = DockDashImage::from_tarball(oci_file).map_dockdash_err()?; - image - .push(&image_uri, &push_opts_with_progress) - .await - .map_dockdash_err()?; - } - info!( - "Pushed resource '{}' in {:.2}s", - display_resource_name, - push_resource_started.elapsed().as_secs_f64() - ); - - return Ok(image_uri); - } - - // Single arch: push the image straight to the tag — no index needed. - if let [(_, only)] = linux_tarballs.as_slice() { - info!("Pushing {} to {}", only.display(), image_uri); - let image = DockDashImage::from_tarball(only).map_dockdash_err()?; - image - .push(&image_uri, &push_opts_with_progress) - .await - .map_dockdash_err()?; - info!( - "Pushed resource '{}' in {:.2}s", - display_resource_name, - push_resource_started.elapsed().as_secs_f64() - ); - - return Ok(image_uri); - } - - // Multi-arch: push each image to a per-arch child tag, then publish a manifest list - // (OCI image index) at the shared tag — otherwise the last single-arch push wins the tag. - let oci_client = OciClient::new(OciClientConfig { - protocol: push_options.protocol.clone(), - ..Default::default() - }); - let mut entries = Vec::new(); - for (target, oci_file) in &linux_tarballs { - let child_uri = format!("{}-{}", image_uri, target.runtime_platform_id()); - info!("Pushing {} as {}", oci_file.display(), child_uri); - let image = DockDashImage::from_tarball(oci_file).map_dockdash_err()?; - image - .push(&child_uri, &push_opts_with_progress) - .await - .map_dockdash_err()?; - - // The index entry's digest+size must reflect the manifest the registry stored - // (dockdash pushes a converted manifest), so read it back rather than hashing the tarball. - let child_ref = Reference::try_from(child_uri.as_str()) - .into_alien_error() - .context(ErrorData::InvalidResourceConfig { - resource_id: resource_name.to_string(), - reason: format!("Invalid image reference '{child_uri}'"), - })?; - let (manifest_bytes, digest) = oci_client - .pull_manifest_raw( - &child_ref, - &push_options.auth, - &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE], - ) - .await - .into_alien_error() - .context(ErrorData::ImagePushFailed { - image: child_uri.clone(), - reason: "Failed to read back the pushed manifest".to_string(), - })?; - let media_type = manifest_media_type(&manifest_bytes) - .unwrap_or_else(|| OCI_IMAGE_MEDIA_TYPE.to_string()); - entries.push(image_index_entry( - *target, - digest, - manifest_bytes.len() as i64, - media_type, - )); - } - - let index = assemble_image_index(entries); - let index_ref = Reference::try_from(image_uri.as_str()) - .into_alien_error() - .context(ErrorData::InvalidResourceConfig { - resource_id: resource_name.to_string(), - reason: format!("Invalid image reference '{image_uri}'"), - })?; - oci_client - .push_manifest_list(&index_ref, &push_options.auth, index) - .await - .into_alien_error() - .context(ErrorData::ImagePushFailed { - image: image_uri.clone(), - reason: "Failed to push the multi-arch manifest list".to_string(), - })?; - info!( - "Pushed multi-arch image {} ({} arches)", - image_uri, - linux_tarballs.len() - ); - - info!( - "Pushed resource '{}' in {:.2}s", - display_resource_name, - push_resource_started.elapsed().as_secs_f64() - ); - - Ok(image_uri) -} - -/// Pair each linux OCI tarball with its target, sorted for deterministic ordering. -/// darwin/windows tarballs are excluded — they are host binaries, not container images. -fn select_linux_tarballs(oci_files: &[PathBuf]) -> Vec<(BinaryTarget, PathBuf)> { - let mut out: Vec<(BinaryTarget, PathBuf)> = oci_files - .iter() - .filter_map(|path| oci_tarball_target(path).map(|target| (target, path.clone()))) - .filter(|(target, _)| target.oci_os() == "linux") - .collect(); - out.sort_by_key(|(target, _)| target.runtime_platform_id()); - out -} - -/// `.oci.tar` → its `BinaryTarget`. -fn oci_tarball_target(path: &Path) -> Option { - let name = path.file_name()?.to_str()?; - BinaryTarget::from_runtime_platform_id(name.strip_suffix(".oci.tar")?) -} - -/// One OCI image index entry for a built target. -fn image_index_entry( - target: BinaryTarget, - digest: String, - size: i64, - media_type: String, -) -> ImageIndexEntry { - ImageIndexEntry { - media_type, - digest, - size, - platform: Some(OciPlatform { - architecture: target.oci_arch().to_string(), - os: target.oci_os().to_string(), - os_version: None, - os_features: None, - variant: None, - features: None, - }), - annotations: None, - } -} - -/// Wrap per-arch manifest entries in an OCI image index (manifest list). -fn assemble_image_index(manifests: Vec) -> OciImageIndex { - OciImageIndex { - schema_version: 2, - media_type: Some(OCI_IMAGE_INDEX_MEDIA_TYPE.to_string()), - manifests, - artifact_type: None, - annotations: None, - } -} - -/// Read the `mediaType` field from a raw manifest, if present. -fn manifest_media_type(bytes: &[u8]) -> Option { - serde_json::from_slice::(bytes) - .ok()? - .get("mediaType")? - .as_str() - .map(|s| s.to_string()) -} - -fn generate_unique_tag() -> String { - rand::rng() - .sample_iter(&Alphanumeric) - .take(8) - .map(char::from) - .collect::() - .to_lowercase() -} - -fn temp_artifact_dir(build_output_dir: &Path, resource_name: &str) -> PathBuf { - build_output_dir.join(format!(".{}-tmp-{}", resource_name, generate_unique_tag())) -} - -async fn finalize_artifact_dir( - temp_dir: &Path, - final_dir: &Path, - artifact_kind: &str, -) -> Result { - match fs::rename(temp_dir, final_dir).await { - Ok(()) => Ok(final_dir.to_string_lossy().into_owned()), - Err(_rename_error) if final_dir.exists() => { - let _ = fs::remove_dir_all(temp_dir).await; - info!( - "Reusing existing {} artifacts at {}", - artifact_kind, - final_dir.display() - ); - Ok(final_dir.to_string_lossy().into_owned()) - } - Err(rename_error) => { - Err(rename_error) - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "rename directory".to_string(), - file_path: temp_dir.display().to_string(), - reason: format!( - "Failed to rename {} directory to {}", - artifact_kind, - final_dir.display() - ), - }) - } - } -} - /// Build a resource (worker, container, or dependency) for one or more OS/architecture targets /// /// Always saves OCI tarballs to a consistent directory structure: @@ -1961,545 +1228,15 @@ async fn build_resource( Ok(finalized_dir) } -async fn compute_source_artifact_cache_key( - src: &str, - toolchain_config: &alien_core::ToolchainConfig, - settings: &BuildSettings, - targets: &[BinaryTarget], - workload: toolchain::WorkloadKind, -) -> Result { - let mut hasher = Sha256::new(); - hasher.update(b"alien-build-artifact-cache-v3"); - hasher.update(src.as_bytes()); - hasher.update( - serde_json::to_vec(toolchain_config) - .into_alien_error() - .context(ErrorData::JsonSerializationError { - message: "Failed to serialize toolchain config for build cache key".to_string(), - })?, - ); - // Source artifact bytes are platform-independent for equivalent target sets. - // The actual differences are target triples, debug/release mode, workload - // kind (which decides the image shape: runtime for Workers, direct - // entrypoint for Containers/Daemons), base image, and whether the built - // binary runs as a host process. This lets e.g. GCP and Azure reuse the - // same linux-x64 artifacts. - let host_process = workload != toolchain::WorkloadKind::Container - && settings.platform.runtime_platform() == Platform::Local; - hasher.update(host_process.to_string().as_bytes()); - hasher.update(settings.debug_mode.to_string().as_bytes()); - hasher.update(workload.as_str().as_bytes()); - for base_image in - effective_source_base_images(toolchain_config, settings, workload, host_process) - { - hasher.update(b"\0base-image\0"); - hasher.update(base_image.as_bytes()); - } - for target in targets { - hasher.update(target.runtime_platform_id().as_bytes()); +fn paths_resolve_to_same_file(left: &Path, right: &Path) -> bool { + if left == right { + return true; } - hash_build_input_source(src, toolchain_config, targets, &mut hasher).await?; - - Ok(format!("{:x}", hasher.finalize())) -} - -async fn hash_build_input_source( - src: &str, - toolchain_config: &alien_core::ToolchainConfig, - targets: &[BinaryTarget], - hasher: &mut Sha256, -) -> Result<()> { - match toolchain_config { - ToolchainConfig::Rust { .. } => hash_rust_build_input_graph(Path::new(src), hasher).await, - ToolchainConfig::TypeScript { .. } => { - hash_source_directory(Path::new(src), hasher).await?; - hash_typescript_dependency_inputs(Path::new(src), targets, hasher).await - } - _ => hash_source_directory(Path::new(src), hasher).await, - } -} - -/// Hash the build inputs a TypeScript app pulls in from OUTSIDE its source -/// directory, which `hash_source_directory` cannot see (`node_modules` is -/// excluded from the source walk): -/// -/// - the `dist/` content of every `@alienplatform/*` package the app has -/// installed, resolved through its symlink to the real location — a -/// `workspace:`/`file:` dependency changes content without changing any -/// version number, and the compiled binary bundles that content; -/// - the workspace dev addon files for the requested targets — the compiled -/// binary embeds the addon, so a rebuilt addon must invalidate the cached -/// artifact. -/// -/// Registry-installed dependencies are content-addressed by the lockfile, -/// which lives in the source directory and is already hashed. -async fn hash_typescript_dependency_inputs( - src_dir: &Path, - targets: &[BinaryTarget], - hasher: &mut Sha256, -) -> Result<()> { - let scope_dir = src_dir.join("node_modules").join("@alienplatform"); - let Ok(entries) = std::fs::read_dir(&scope_dir) else { - // No workspace packages installed — nothing extra to hash. - return Ok(()); - }; - - let mut packages: Vec = entries.flatten().map(|entry| entry.path()).collect(); - packages.sort(); - - let mut bindings_realpath: Option = None; - for package_dir in packages { - let Ok(realpath) = package_dir.canonicalize() else { - continue; - }; - if package_dir.file_name().is_some_and(|n| n == "bindings") { - bindings_realpath = Some(realpath.clone()); - } - hasher.update(b"alienplatform-package"); - hasher.update(package_dir.to_string_lossy().as_bytes()); - let dist = realpath.join("dist"); - if dist.is_dir() { - hash_source_directory(&dist, hasher).await?; - } - } - - // Workspace dev addons for the requested targets, anchored on the real - // bindings package location (mirrors the staging lookup's anchor). - if let Some(bindings) = bindings_realpath { - for addon in toolchain::native_addon::workspace_addon_inputs(&bindings, targets) { - hasher.update(b"native-addon"); - hasher.update(addon.to_string_lossy().as_bytes()); - let bytes = fs::read(&addon).await.into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "read file".to_string(), - file_path: addon.display().to_string(), - reason: "Failed to read native addon for build cache key".to_string(), - }, - )?; - hasher.update(&bytes); - } - } - - Ok(()) -} - -async fn hash_rust_build_input_graph(src_dir: &Path, hasher: &mut Sha256) -> Result<()> { - let metadata = read_cargo_metadata(src_dir).await?; - hasher.update(b"rust-cargo-metadata-v1"); - - let lockfile = metadata.workspace_root.join("Cargo.lock"); - if lockfile.is_file() { - hasher.update(b"cargo-lock"); - let lockfile_bytes = fs::read(&lockfile).await.into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "read file".to_string(), - file_path: lockfile.display().to_string(), - reason: "Failed to read Cargo.lock for build cache key".to_string(), - }, - )?; - hasher.update(lockfile_bytes); - } - - // Workspace-level toolchain configuration changes the produced binary even - // when no package source changes: rust-toolchain pins the compiler, - // .cargo/config.toml can change rustflags, linker, or profile settings. - // These live at the workspace root, outside the per-package directories - // hashed below, so hash them explicitly. Absent files contribute nothing, - // which keeps existing cache keys stable for projects without them. - for toolchain_file in [ - "rust-toolchain.toml", - "rust-toolchain", - ".cargo/config.toml", - ".cargo/config", - ] { - let path = metadata.workspace_root.join(toolchain_file); - if path.is_file() { - let contents = fs::read(&path).await.into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "read file".to_string(), - file_path: path.display().to_string(), - reason: "Failed to read toolchain config file for build cache key".to_string(), - }, - )?; - hasher.update(b"toolchain-config-file"); - hasher.update(toolchain_file.as_bytes()); - hasher.update(contents); - } - } - - let local_package_ids = local_cargo_package_ids(&metadata); - let mut local_packages: Vec<_> = metadata - .packages - .iter() - .filter(|package| local_package_ids.contains(&package.id)) - .collect(); - local_packages.sort_by(|left, right| left.id.cmp(&right.id)); - - for package in local_packages { - hasher.update(b"local-package"); - hasher.update(package.id.as_bytes()); - hasher.update(package.manifest_path.to_string_lossy().as_bytes()); - let package_dir = package.manifest_path.parent().ok_or_else(|| { - AlienError::new(ErrorData::BuildConfigInvalid { - message: format!( - "Cargo metadata package '{}' has manifest path without parent: {}", - package.id, - package.manifest_path.display() - ), - }) - })?; - hash_source_directory(package_dir, hasher).await?; - } - - Ok(()) -} - -async fn read_cargo_metadata(src_dir: &Path) -> Result { - let manifest_path = src_dir.join("Cargo.toml"); - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--manifest-path"]) - .arg(&manifest_path) - .output() - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: src_dir.display().to_string(), - reason: "Failed to execute cargo metadata for build cache key".to_string(), - build_output: None, - })?; - - if !output.status.success() { - let mut build_output = String::new(); - build_output.push_str(&String::from_utf8_lossy(&output.stdout)); - build_output.push_str(&String::from_utf8_lossy(&output.stderr)); - return Err(AlienError::new(ErrorData::ImageBuildFailed { - resource_name: src_dir.display().to_string(), - reason: "cargo metadata failed while computing build cache key".to_string(), - build_output: Some(build_output), - })); - } - - serde_json::from_slice(&output.stdout) - .into_alien_error() - .context(ErrorData::JsonSerializationError { - message: "Failed to parse cargo metadata JSON for build cache key".to_string(), - }) -} - -fn local_cargo_package_ids(metadata: &CargoMetadata) -> HashSet { - let packages_by_id: HashMap<_, _> = metadata - .packages - .iter() - .map(|package| (package.id.as_str(), package)) - .collect(); - - let Some(resolve) = &metadata.resolve else { - return metadata - .packages - .iter() - .filter(|package| package.source.is_none()) - .map(|package| package.id.clone()) - .collect(); - }; - let Some(root) = &resolve.root else { - return metadata - .packages - .iter() - .filter(|package| package.source.is_none()) - .map(|package| package.id.clone()) - .collect(); - }; - - let nodes_by_id: HashMap<_, _> = resolve - .nodes - .iter() - .map(|node| (node.id.as_str(), node)) - .collect(); - let mut visited = HashSet::new(); - let mut stack = vec![root.as_str()]; - - while let Some(id) = stack.pop() { - if !visited.insert(id.to_string()) { - continue; - } - - let Some(node) = nodes_by_id.get(id) else { - continue; - }; - - for dependency in &node.dependencies { - stack.push(dependency); - } - } - - visited - .into_iter() - .filter(|id| { - packages_by_id - .get(id.as_str()) - .map(|package| package.source.is_none()) - .unwrap_or(false) - }) - .collect() -} - -async fn hash_source_directory(src_dir: &Path, hasher: &mut Sha256) -> Result<()> { - let mut files = Vec::new(); - collect_source_files(src_dir, src_dir, &mut files)?; - files.sort(); - - for relative_path in files { - let full_path = src_dir.join(&relative_path); - let contents = fs::read(&full_path).await.into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "read file".to_string(), - file_path: full_path.display().to_string(), - reason: "Failed to read source file for build cache key".to_string(), - }, - )?; - hasher.update(relative_path.to_string_lossy().as_bytes()); - hasher.update(&contents); - } - - Ok(()) -} - -fn collect_source_files(base_dir: &Path, dir: &Path, files: &mut Vec) -> Result<()> { - let entries = - std::fs::read_dir(dir) - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory".to_string(), - file_path: dir.display().to_string(), - reason: "Failed to read source directory for build cache key".to_string(), - })?; - - for entry in entries { - let entry = entry - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory entry".to_string(), - file_path: dir.display().to_string(), - reason: "Failed to iterate source directory for build cache key".to_string(), - })?; - let path = entry.path(); - let file_name = entry.file_name(); - let file_name = file_name.to_string_lossy(); - - if is_ignored_source_cache_path(file_name.as_ref()) { - continue; - } - - let file_type = - entry - .file_type() - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read metadata".to_string(), - file_path: path.display().to_string(), - reason: "Failed to read source file type for build cache key".to_string(), - })?; - - if file_type.is_dir() { - collect_source_files(base_dir, &path, files)?; - } else if file_type.is_file() { - let relative_path = path.strip_prefix(base_dir).into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "strip prefix".to_string(), - file_path: path.display().to_string(), - reason: "Failed to compute relative source path for build cache key" - .to_string(), - }, - )?; - files.push(relative_path.to_path_buf()); - } - } - - Ok(()) -} - -fn is_ignored_source_cache_path(file_name: &str) -> bool { - matches!( - file_name, - ".git" | ".alien" | ".alien-build" | "target" | "node_modules" | "alien-bindings.node" // staged addon: derived artifact, hashed via its source - ) || file_name.ends_with(".bun-build") -} - -async fn find_cached_artifact_dir( - build_output_dir: &Path, - resource_name: &str, - targets: &[BinaryTarget], - artifact_cache_key: &str, -) -> Result> { - if let Some(path) = - find_cached_artifact_dir_in(build_output_dir, resource_name, targets, artifact_cache_key) - .await? - { - return Ok(Some(path)); - } - - let Some(parent_dir) = build_output_dir.parent() else { - return Ok(None); - }; - - let mut platform_entries = fs::read_dir(parent_dir).await.into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "read directory".to_string(), - file_path: parent_dir.display().to_string(), - reason: "Failed to read sibling build directories for artifact cache".to_string(), - }, - )?; - - while let Some(entry) = platform_entries - .next_entry() - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory entry".to_string(), - file_path: parent_dir.display().to_string(), - reason: "Failed to iterate sibling build directories for artifact cache".to_string(), - })? - { - let path = entry.path(); - if path == build_output_dir { - continue; - } - - let file_type = - entry - .file_type() - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read metadata".to_string(), - file_path: path.display().to_string(), - reason: "Failed to read sibling artifact cache directory metadata".to_string(), - })?; - if !file_type.is_dir() { - continue; - } - - if let Some(cached) = - find_cached_artifact_dir_in(&path, resource_name, targets, artifact_cache_key).await? - { - return Ok(Some(cached)); - } - } - - Ok(None) -} - -async fn find_cached_artifact_dir_in( - build_output_dir: &Path, - resource_name: &str, - targets: &[BinaryTarget], - artifact_cache_key: &str, -) -> Result> { - let mut entries = fs::read_dir(build_output_dir) - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory".to_string(), - file_path: build_output_dir.display().to_string(), - reason: "Failed to read build output directory for artifact cache".to_string(), - })?; - - let prefix = format!("{resource_name}-"); - while let Some(entry) = - entries - .next_entry() - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory entry".to_string(), - file_path: build_output_dir.display().to_string(), - reason: "Failed to iterate build output directory for artifact cache".to_string(), - })? - { - let path = entry.path(); - let file_type = - entry - .file_type() - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read metadata".to_string(), - file_path: path.display().to_string(), - reason: "Failed to read artifact cache entry metadata".to_string(), - })?; - if !file_type.is_dir() { - continue; - } - - let Some(dir_name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !dir_name.starts_with(&prefix) { - continue; - } - - let has_all_targets = targets.iter().all(|target| { - path.join(format!("{}.oci.tar", target.runtime_platform_id())) - .is_file() - }); - if !has_all_targets { - continue; - } - - let Ok(metadata_content) = - fs::read_to_string(path.join(ARTIFACT_CACHE_METADATA_FILE)).await - else { - continue; - }; - let Ok(metadata) = serde_json::from_str::(&metadata_content) else { - continue; - }; - if metadata.cache_key == artifact_cache_key { - return Ok(Some(path)); - } - } - - Ok(None) -} - -async fn write_artifact_cache_metadata( - artifact_dir: &Path, - artifact_cache_key: &str, -) -> Result<()> { - let metadata = ArtifactCacheMetadata { - cache_key: artifact_cache_key.to_string(), - }; - let content = serde_json::to_string_pretty(&metadata) - .into_alien_error() - .context(ErrorData::JsonSerializationError { - message: "Failed to serialize build artifact cache metadata".to_string(), - })?; - - fs::write(artifact_dir.join(ARTIFACT_CACHE_METADATA_FILE), content) - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "write".to_string(), - file_path: artifact_dir - .join(ARTIFACT_CACHE_METADATA_FILE) - .display() - .to_string(), - reason: "Failed to write build artifact cache metadata".to_string(), - })?; - - Ok(()) -} - -fn paths_resolve_to_same_file(left: &Path, right: &Path) -> bool { - if left == right { - return true; - } - - match (std::fs::canonicalize(left), std::fs::canonicalize(right)) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } + match (std::fs::canonicalize(left), std::fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } } async fn materialize_complete_oci_tarball(tarball_path: &Path, output_path: &Path) -> Result { @@ -2828,2027 +1565,3 @@ async fn build_target_to_file( Ok(output_path.to_string_lossy().into_owned()) } - -/// Return the ordered base-image inputs that affect a source artifact. -/// Host-process and Dockerfile builds do not use the source toolchain bases. -fn effective_source_base_images( - toolchain_config: &alien_core::ToolchainConfig, - settings: &BuildSettings, - workload: toolchain::WorkloadKind, - host_process: bool, -) -> Vec { - if host_process || matches!(toolchain_config, alien_core::ToolchainConfig::Docker { .. }) { - return vec![]; - } - - let defaults = match workload { - toolchain::WorkloadKind::Worker => toolchain::WORKER_BASE_IMAGES, - toolchain::WorkloadKind::Container | toolchain::WorkloadKind::Daemon => { - toolchain::DIRECT_BASE_IMAGES - } - } - .iter() - .map(|image| (*image).to_string()) - .collect::>(); - - base_images_for_workload(&defaults, settings.override_base_image.as_deref(), workload) -} - -/// Apply a feature-versioned runtime base only to Worker source images. -fn base_images_for_workload( - base_images: &[String], - override_base_image: Option<&str>, - workload: toolchain::WorkloadKind, -) -> Vec { - if workload == toolchain::WorkloadKind::Worker { - if let Some(override_image) = override_base_image { - return vec![override_image.to_string()]; - } - } - - base_images.to_vec() -} - -/// Decide the ENTRYPOINT/CMD pair an image gets from a [`ToolchainOutput`]. -/// -/// - `entrypoint: Some` — direct-entrypoint images (source-built -/// Containers/Daemons): the compiled binary overrides the plain base -/// image's entrypoint and clears inherited CMD. CMD is set only when -/// `runtime_command` is nonempty — direct images carry no runtime wrapper -/// and no `--` separator. -/// - `entrypoint: None` — keep the base image's entrypoint (e.g. alien-base's -/// `alien-worker-runtime`) and always set CMD from `runtime_command`. -/// -/// The resulting image shapes are pinned by `tests/image_shape_tests.rs`. -fn image_entrypoint_and_cmd( - output: &toolchain::ToolchainOutput, -) -> (Option>, Option>) { - match &output.entrypoint { - Some(entrypoint) => { - let cmd = if output.runtime_command.is_empty() { - None - } else { - Some(output.runtime_command.clone()) - }; - (Some(entrypoint.clone()), cmd) - } - None => (None, Some(output.runtime_command.clone())), - } -} - -/// Apply the [`image_entrypoint_and_cmd`] contract to a dockdash image -/// builder. Used by both the base-image and from-scratch build paths. -fn apply_image_command( - mut builder: dockdash::ImageBuilder, - output: &toolchain::ToolchainOutput, -) -> dockdash::ImageBuilder { - let (entrypoint, cmd) = image_entrypoint_and_cmd(output); - if let Some(entrypoint) = entrypoint { - builder = builder.entrypoint(entrypoint); - } - if let Some(cmd) = cmd { - builder = builder.cmd(cmd); - } - builder -} - -fn base_image_build_retry_delay(attempt: usize) -> Duration { - match attempt { - 1 => Duration::from_secs(2), - 2 => Duration::from_secs(5), - _ => Duration::from_secs(10), - } -} - -fn is_retryable_dockdash_image_pull_error(error: &dockdash::Error) -> bool { - match error { - dockdash::Error::ImagePull { source, .. } => { - source - .as_deref() - .map(is_retryable_image_pull_source) - .unwrap_or(false) - || is_retryable_image_pull_text(&error.to_string()) - } - _ => false, - } -} - -fn is_retryable_image_pull_text(message: &str) -> bool { - const RETRYABLE_MARKERS: &[&str] = &[ - "error sending request", - "client error (sendrequest)", - "connection error", - "connection aborted", - "connection reset", - "connection refused", - "connection closed", - "timed out", - "unexpected eof", - "broken pipe", - "temporary failure in name resolution", - "dns error", - ]; - - let message = message.to_ascii_lowercase(); - RETRYABLE_MARKERS - .iter() - .any(|marker| message.contains(marker)) -} - -fn is_retryable_image_pull_source(source: &(dyn StdError + Send + Sync + 'static)) -> bool { - let mut current = Some(source as &(dyn StdError + 'static)); - - while let Some(error) = current { - if let Some(oci_error) = error.downcast_ref::() { - return is_retryable_oci_error(oci_error); - } - - if let Some(reqwest_error) = error.downcast_ref::() { - return reqwest_error.is_timeout() - || reqwest_error.is_connect() - || reqwest_error - .status() - .map(|status| status.is_server_error() || status.as_u16() == 429) - .unwrap_or(false); - } - - if let Some(io_error) = error.downcast_ref::() { - return matches!( - io_error.kind(), - std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::Interrupted - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::WouldBlock - ); - } - - current = error.source(); - } - - false -} - -fn is_retryable_oci_error(error: &oci_client::errors::OciDistributionError) -> bool { - match error { - oci_client::errors::OciDistributionError::ServerError { code, .. } => { - *code >= 500 || *code == 429 - } - oci_client::errors::OciDistributionError::RequestError(error) => { - error.is_timeout() - || error.is_connect() - || error - .status() - .map(|status| status.is_server_error() || status.as_u16() == 429) - .unwrap_or(false) - } - oci_client::errors::OciDistributionError::IoError(error) => matches!( - error.kind(), - std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::Interrupted - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::WouldBlock - ), - _ => false, - } -} - -/// Pull a Docker image and export it to OCI tarballs for each target architecture. -/// This handles both registry images (nginx:latest) and local images (my-app:v1). -#[alien_event(AlienEvent::BuildingResource { - resource_name: container_name.to_string(), - resource_type: "container".to_string(), - related_resources: vec![], -})] -async fn pull_and_export_image( - image: &str, - container_name: &str, - _stack_id: &str, - settings: &BuildSettings, - build_output_dir: &Path, -) -> Result { - info!( - "Pulling and exporting image '{}' for container '{}'", - image, container_name - ); - - let targets = settings.get_targets(); - - info!( - "Exporting image '{}' for {} target(s): {:?}", - image, - targets.len(), - targets - ); - - let container_dir = temp_artifact_dir(build_output_dir, container_name); - fs::create_dir_all(&container_dir) - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "create directory".to_string(), - file_path: container_dir.display().to_string(), - reason: "Failed to create container directory for export".to_string(), - })?; - - // Pull the image for each target architecture - use std::process::Stdio; - use tokio::process::Command; - - for target in targets { - let arch_str = match target.to_dockdash_arch() { - dockdash::Arch::Amd64 => "amd64", - dockdash::Arch::ARM64 => "arm64", - _ => "amd64", // Fallback for other architectures - }; - let platform_str = format!("linux/{}", arch_str); - - info!("Pulling image '{}' for platform {}", image, platform_str); - - // Pull the image with specific platform - let pull_output = Command::new("docker") - .args(&["pull", "--platform", &platform_str, image]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: container_name.to_string(), - reason: "Failed to execute docker pull".to_string(), - build_output: None, - })?; - - if !pull_output.status.success() { - return Err(image_build_error_with_output( - container_name, - format!("docker pull failed for image '{}'", image), - &pull_output, - )); - } - - info!("Successfully pulled image '{}' for {}", image, platform_str); - - // Export to OCI tarball - let target_filename = format!("{}.oci.tar", target.runtime_platform_id()); - let output_tarball = container_dir.join(&target_filename); - - info!("Exporting to OCI tarball: {}", output_tarball.display()); - - let save_output = Command::new("docker") - .args(&["save", "-o", &output_tarball.to_string_lossy(), image]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: container_name.to_string(), - reason: "Failed to execute docker save".to_string(), - build_output: None, - })?; - - if !save_output.status.success() { - return Err(image_build_error_with_output( - container_name, - "docker save failed", - &save_output, - )); - } - - // Flatten the saved archive to a single image manifest before the OCI reader sees it. - crate::toolchain::docker::DockerToolchain::normalize_oci_archive( - &output_tarball, - arch_str, - )?; - - info!( - "Successfully exported {} to {}", - image, - output_tarball.display() - ); - } - - // Compute content hash - let content_hash = compute_function_content_hash(&container_dir).await?; - let short_hash = &content_hash[..8]; - - // Rename directory to include content hash - let hashed_dir_name = format!("{}-{}", container_name, short_hash); - let final_output_dir = build_output_dir.join(&hashed_dir_name); - - let finalized_dir = finalize_artifact_dir(&container_dir, &final_output_dir, "export").await?; - - info!( - "Completed export for container '{}'. Images directory: {} (hash: {})", - container_name, - final_output_dir.display(), - short_hash - ); - - Ok(finalized_dir) -} - -/// Compute a content hash of all OCI tarballs in a directory. -/// -/// This hash is used to detect code changes between builds. When the source code -/// changes, the OCI tarball contents change, producing a different hash. This hash -/// is then included in the output directory name, ensuring the executor detects -/// config changes and plans an UPDATE. -async fn compute_function_content_hash(dir: &Path) -> Result { - let mut hasher = Sha256::new(); - let mut entries = - fs::read_dir(dir) - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory".to_string(), - file_path: dir.display().to_string(), - reason: "Failed to read function build directory for hashing".to_string(), - })?; - - // Collect all OCI tarball paths and sort for deterministic hashing - let mut tarball_paths: Vec = vec![]; - while let Some(entry) = - entries - .next_entry() - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read directory entry".to_string(), - file_path: dir.display().to_string(), - reason: "Failed to iterate build directory entries".to_string(), - })? - { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "tar") { - tarball_paths.push(path); - } - } - tarball_paths.sort(); - - // Hash contents of all tarballs in deterministic order - for path in tarball_paths { - let contents = - fs::read(&path) - .await - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "read file".to_string(), - file_path: path.display().to_string(), - reason: "Failed to read OCI tarball for hashing".to_string(), - })?; - hasher.update(&contents); - } - - Ok(format!("{:x}", hasher.finalize())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn materializing_same_artifact_preserves_its_contents() { - let directory = tempfile::tempdir_in(".").unwrap(); - let absolute = std::fs::canonicalize(directory.path()) - .unwrap() - .join("image.oci.tar"); - std::fs::write(&absolute, b"oci archive").unwrap(); - let relative = absolute - .strip_prefix(std::env::current_dir().unwrap()) - .unwrap(); - - assert_ne!(relative, absolute); - assert!(!materialize_complete_oci_tarball(&absolute, relative) - .await - .unwrap()); - assert_eq!(std::fs::read(absolute).unwrap(), b"oci archive"); - } - use dockdash::Image; - use std::path::PathBuf; - use std::process::Command; - use tempfile::tempdir; - - fn toolchain_output( - entrypoint: Option>, - runtime_command: Vec, - ) -> toolchain::ToolchainOutput { - toolchain::ToolchainOutput { - build_strategy: toolchain::ImageBuildStrategy::FromScratch { layers: vec![] }, - entrypoint, - runtime_command, - } - } - - /// Pins the ENTRYPOINT/CMD contract shared by the base-image and - /// from-scratch build paths (see also tests/image_shape_tests.rs). - #[test] - fn image_entrypoint_and_cmd_contract() { - // Worker: base entrypoint kept, CMD is the separator + binary. - let worker = toolchain_output(None, vec!["--".to_string(), "./bin".to_string()]); - assert_eq!( - image_entrypoint_and_cmd(&worker), - (None, Some(vec!["--".to_string(), "./bin".to_string()])) - ); - - // Direct entrypoint (Container/Daemon): binary is the entrypoint, no CMD. - let direct = toolchain_output(Some(vec!["/app/bin".to_string()]), vec![]); - assert_eq!( - image_entrypoint_and_cmd(&direct), - (Some(vec!["/app/bin".to_string()]), None) - ); - - // Local from-scratch (host process): no entrypoint, CMD is the binary. - let local = toolchain_output(None, vec!["./bin".to_string()]); - assert_eq!( - image_entrypoint_and_cmd(&local), - (None, Some(vec!["./bin".to_string()])) - ); - - // Explicit entrypoint with a nonempty command keeps both. - let both = toolchain_output( - Some(vec!["/app/bin".to_string()]), - vec!["serve".to_string()], - ); - assert_eq!( - image_entrypoint_and_cmd(&both), - ( - Some(vec!["/app/bin".to_string()]), - Some(vec!["serve".to_string()]) - ) - ); - } - - #[test] - fn runtime_base_override_only_applies_to_workers() { - let direct_bases = vec!["cgr.dev/chainguard/wolfi-base:latest".to_string()]; - let runtime_base = "registry.example.com/alien-base:feature"; - - assert_eq!( - base_images_for_workload(&direct_bases, None, toolchain::WorkloadKind::Worker), - direct_bases, - "without an override the declared default bases must be preserved" - ); - assert_eq!( - base_images_for_workload( - &direct_bases, - Some(runtime_base), - toolchain::WorkloadKind::Worker, - ), - vec![runtime_base.to_string()] - ); - for workload in [ - toolchain::WorkloadKind::Container, - toolchain::WorkloadKind::Daemon, - ] { - assert_eq!( - base_images_for_workload(&direct_bases, Some(runtime_base), workload), - direct_bases, - "{} must not inherit the Worker runtime base", - workload.as_str() - ); - } - } - - #[test] - fn requested_host_binary_only_gates_container_skip() { - use BinaryTarget::*; - // None (defaults to host OS) and empty → containers still build. - assert!(!requested_host_binary_only(None)); - assert!(!requested_host_binary_only(Some(&[]))); - // Explicit non-Linux-only → nothing for a container to build, skip it. - assert!(requested_host_binary_only(Some(&[DarwinArm64]))); - assert!(requested_host_binary_only(Some(&[WindowsX64]))); - assert!(requested_host_binary_only(Some(&[DarwinArm64, WindowsX64]))); - // Any Linux target present → containers build for it. - assert!(!requested_host_binary_only(Some(&[LinuxArm64]))); - assert!(!requested_host_binary_only(Some(&[LinuxX64]))); - assert!(!requested_host_binary_only(Some(&[ - DarwinArm64, - LinuxArm64 - ]))); - } - - #[test] - fn local_build_strips_daemon_only_compute_cluster() { - let cluster = alien_core::ComputeCluster::new("host-runtime".to_string()) - .capacity_group(alien_core::CapacityGroup { - group_id: "general".to_string(), - instance_type: Some("m8i.xlarge".to_string()), - profile: None, - min_size: 1, - max_size: 1, - scale_policy: None, - nested_virtualization: Some(true), - }) - .build(); - let daemon = Daemon::new("host-loader".to_string()) - .cluster("host-runtime".to_string()) - .permissions("loader".to_string()) - .code(DaemonCode::Image { - image: "registry.example.com/host-loader:latest".to_string(), - }) - .build(); - let mut stack = Stack::new("host-loader-stack".to_string()) - .add(cluster, alien_core::ResourceLifecycle::Frozen) - .add(daemon, alien_core::ResourceLifecycle::Live) - .build(); - - strip_local_daemon_only_compute_clusters(&mut stack, Platform::Local); - - assert!(!stack.resources.contains_key("host-runtime")); - let daemon = stack - .resources() - .find(|(id, _)| *id == "host-loader") - .and_then(|(_, entry)| entry.config.downcast_ref::()) - .expect("daemon should remain"); - assert_eq!(daemon.cluster, None); - } - - #[tokio::test] - async fn machines_build_rejects_workers_before_writing_artifacts() { - let output = tempdir().unwrap(); - let worker = Worker::new("job".to_string()) - .permissions("execution".to_string()) - .code(WorkerCode::Image { - image: "registry.example.com/job:latest".to_string(), - }) - .build(); - let stack = Stack::new("machines-worker".to_string()) - .add(worker, alien_core::ResourceLifecycle::Live) - .build(); - let settings = BuildSettings { - output_directory: output.path().display().to_string(), - platform: PlatformBuildSettings::Machines {}, - targets: Some(BinaryTarget::LINUX.to_vec()), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - - let error = build_stack(stack, &settings) - .await - .expect_err("machines worker should fail build-time preflight"); - - assert_eq!(error.code, "STACK_PROCESSOR_FAILED"); - let serialized = serde_json::to_string(&error).expect("error should serialize"); - assert!(serialized.contains("MACHINES_UNSUPPORTED_RESOURCE")); - assert!(!output.path().join("build").join("machines").exists()); - } - - #[test] - fn source_cache_hash_ignores_build_artifacts() { - let src = tempdir().unwrap(); - std::fs::create_dir_all(src.path().join(".alien-build")).unwrap(); - std::fs::create_dir_all(src.path().join("node_modules")).unwrap(); - std::fs::write(src.path().join("package.json"), "{}").unwrap(); - std::fs::write( - src.path().join(".alien-build/__alien_bootstrap.ts"), - "generated", - ) - .unwrap(); - std::fs::write( - src.path().join(".18ba89dff9ff58bf-00000000.bun-build"), - "generated", - ) - .unwrap(); - std::fs::write(src.path().join("node_modules/module.js"), "dependency").unwrap(); - - let mut files = Vec::new(); - collect_source_files(src.path(), src.path(), &mut files).unwrap(); - files.sort(); - - assert_eq!(files, vec![PathBuf::from("package.json")]); - } - - fn docker_available() -> bool { - Command::new("docker") - .arg("info") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } - - /// True if a real OCI registry answers at `base/v2/` (200 or 401). Used to gate the - /// multi-arch push test. Run one with: `docker run -d -p 5050:5000 registry:2`. - async fn registry_available(base: &str) -> bool { - match reqwest::get(format!("{base}/v2/")).await { - Ok(resp) => resp.status().is_success() || resp.status().as_u16() == 401, - Err(_) => false, - } - } - - fn test_container(name: &str, image: String) -> Container { - Container::new(name.to_string()) - .code(ContainerCode::Image { image }) - .cpu(alien_core::ResourceSpec { - min: "0.5".to_string(), - desired: "1".to_string(), - }) - .memory(alien_core::ResourceSpec { - min: "512Mi".to_string(), - desired: "1Gi".to_string(), - }) - .permissions("container-execution".to_string()) - .build() - } - - #[test] - fn retryable_image_pull_detects_oci_server_errors() { - let error = dockdash::Error::ImagePull { - image_ref: "ghcr.io/example/base:tag".to_string(), - message: "Failed to pull layer blob sha256:abc".to_string(), - source: Some(Box::new( - oci_client::errors::OciDistributionError::ServerError { - code: 502, - url: "https://ghcr.io/v2/example/base/blobs/sha256:abc".to_string(), - message: "Bad Gateway".to_string(), - }, - )), - }; - - assert!(is_retryable_dockdash_image_pull_error(&error)); - } - - #[test] - fn retryable_image_pull_detects_opaque_transport_errors() { - let error = dockdash::Error::ImagePull { - image_ref: "ghcr.io/example/base:tag".to_string(), - message: "Failed to pull layer blob sha256:abc".to_string(), - source: Some(Box::new(std::io::Error::other( - "error sending request for url (https://ghcr.io/v2/example/base/blobs/sha256:abc): client error (SendRequest): connection error", - ))), - }; - - assert!(is_retryable_dockdash_image_pull_error(&error)); - } - - #[test] - fn retryable_image_pull_rejects_auth_and_not_found_errors() { - let auth_error = dockdash::Error::ImagePull { - image_ref: "ghcr.io/example/base:tag".to_string(), - message: "Failed to pull layer blob sha256:abc".to_string(), - source: Some(Box::new( - oci_client::errors::OciDistributionError::UnauthorizedError { - url: "https://ghcr.io/v2/example/base/blobs/sha256:abc".to_string(), - }, - )), - }; - let missing_error = dockdash::Error::ImagePull { - image_ref: "ghcr.io/example/base:tag".to_string(), - message: "Failed to pull manifest".to_string(), - source: Some(Box::new( - oci_client::errors::OciDistributionError::ImageManifestNotFoundError( - "ghcr.io/example/base:tag".to_string(), - ), - )), - }; - - assert!(!is_retryable_dockdash_image_pull_error(&auth_error)); - assert!(!is_retryable_dockdash_image_pull_error(&missing_error)); - } - - #[test] - fn oci_tarball_target_maps_runtime_platform_ids() { - assert_eq!( - oci_tarball_target(Path::new("/x/linux-aarch64.oci.tar")), - Some(BinaryTarget::LinuxArm64) - ); - assert_eq!( - oci_tarball_target(Path::new("linux-x64.oci.tar")), - Some(BinaryTarget::LinuxX64) - ); - assert_eq!(oci_tarball_target(Path::new("stack.json")), None); - assert_eq!(oci_tarball_target(Path::new("linux-arm64.oci.tar")), None); // CLI spelling, not a tarball name - } - - #[test] - fn select_linux_tarballs_keeps_only_linux_sorted() { - let files = vec![ - PathBuf::from("/b/windows-x64.oci.tar"), - PathBuf::from("/b/linux-x64.oci.tar"), - PathBuf::from("/b/darwin-aarch64.oci.tar"), - PathBuf::from("/b/linux-aarch64.oci.tar"), - ]; - let selected = select_linux_tarballs(&files); - assert_eq!( - selected.iter().map(|(t, _)| *t).collect::>(), - vec![BinaryTarget::LinuxArm64, BinaryTarget::LinuxX64], // sorted by runtime id: linux-aarch64 < linux-x64 - ); - } - - #[test] - fn assemble_image_index_sets_oci_index_shape() { - let entry = image_index_entry( - BinaryTarget::LinuxArm64, - "sha256:abc".to_string(), - 123, - OCI_IMAGE_MEDIA_TYPE.to_string(), - ); - let platform = entry.platform.as_ref().unwrap(); - assert_eq!(platform.architecture, "arm64"); - assert_eq!(platform.os, "linux"); - - let index = assemble_image_index(vec![entry]); - assert_eq!(index.schema_version, 2); - assert_eq!( - index.media_type.as_deref(), - Some(OCI_IMAGE_INDEX_MEDIA_TYPE) - ); - assert_eq!(index.manifests.len(), 1); - assert_eq!(index.manifests[0].digest, "sha256:abc"); - assert_eq!(index.manifests[0].size, 123); - } - - #[test] - fn manifest_media_type_reads_field_or_none() { - assert_eq!( - manifest_media_type(br#"{"mediaType":"application/vnd.oci.image.manifest.v1+json"}"#), - Some("application/vnd.oci.image.manifest.v1+json".to_string()) - ); - assert_eq!(manifest_media_type(br#"{"schemaVersion":2}"#), None); - assert_eq!(manifest_media_type(b"not json"), None); - } - - #[test] - fn collect_push_targets_groups_resources_that_share_local_image_directory() { - let temp_root = tempdir().unwrap(); - let shared_dir = temp_root.path().join("shared-image"); - let unique_dir = temp_root.path().join("unique-image"); - std::fs::create_dir_all(&shared_dir).unwrap(); - std::fs::create_dir_all(&unique_dir).unwrap(); - - let shared_image = shared_dir.to_string_lossy().into_owned(); - let unique_image = unique_dir.to_string_lossy().into_owned(); - - let messaging_gateway = test_container("messaging-gateway", shared_image.clone()); - let billing_worker = test_container("billing-worker", shared_image); - let postgres = test_container("postgres", unique_image); - let remote = test_container("remote", "registry.example.com/remote:latest".to_string()); - - let mut stack = Stack::new("push-dedupe".to_string()) - .add(messaging_gateway, alien_core::ResourceLifecycle::Frozen) - .add(billing_worker, alien_core::ResourceLifecycle::Frozen) - .add(postgres, alien_core::ResourceLifecycle::Frozen) - .add(remote, alien_core::ResourceLifecycle::Frozen) - .build(); - - let targets = collect_push_targets(&stack).unwrap(); - - assert_eq!(targets.len(), 2); - assert_eq!( - targets[0].resource_names, - vec![ - "messaging-gateway".to_string(), - "billing-worker".to_string() - ] - ); - assert_eq!( - targets[0].resource_ids, - vec![ - "messaging-gateway".to_string(), - "billing-worker".to_string() - ] - ); - assert_eq!(targets[0].resource_type, "container"); - assert_eq!(targets[0].local_image_dir, shared_dir); - assert_eq!(targets[1].resource_names, vec!["postgres".to_string()]); - - let mut updates = targets[0].push_result_updates("registry.example.com/shared:tag".into()); - updates.extend(targets[1].push_result_updates("registry.example.com/postgres:tag".into())); - apply_pushed_images(&mut stack, updates); - - let images = stack - .resources() - .filter_map(|(id, entry)| { - entry - .config - .downcast_ref::() - .and_then(|container| match &container.code { - ContainerCode::Image { image } => Some((id.clone(), image.clone())), - ContainerCode::Source { .. } => None, - }) - }) - .collect::>(); - - assert_eq!( - images.get("messaging-gateway").unwrap(), - "registry.example.com/shared:tag" - ); - assert_eq!( - images.get("billing-worker").unwrap(), - "registry.example.com/shared:tag" - ); - assert_eq!( - images.get("postgres").unwrap(), - "registry.example.com/postgres:tag" - ); - assert_eq!( - images.get("remote").unwrap(), - "registry.example.com/remote:latest" - ); - } - - #[test] - fn collect_push_targets_handles_daemons_like_other_compute() { - let temp_root = tempdir().unwrap(); - let daemon_dir = temp_root.path().join("daemon-image"); - std::fs::create_dir_all(&daemon_dir).unwrap(); - - let local_daemon = Daemon::new("agent".to_string()) - .permissions("execution".to_string()) - .code(DaemonCode::Image { - image: daemon_dir.to_string_lossy().into_owned(), - }) - .build(); - let remote_daemon = Daemon::new("collector".to_string()) - .permissions("execution".to_string()) - .code(DaemonCode::Image { - image: "registry.example.com/collector:latest".to_string(), - }) - .build(); - - let mut stack = Stack::new("daemon-push".to_string()) - .add(local_daemon, alien_core::ResourceLifecycle::Live) - .add(remote_daemon, alien_core::ResourceLifecycle::Live) - .build(); - - let targets = collect_push_targets(&stack).unwrap(); - assert_eq!( - targets.len(), - 1, - "only the local-dir daemon is queued for push" - ); - assert_eq!(targets[0].resource_names, vec!["agent".to_string()]); - assert_eq!(targets[0].resource_type, "daemon"); - assert_eq!(targets[0].local_image_dir, daemon_dir); - - let updates = targets[0].push_result_updates("registry.example.com/agent:tag".into()); - apply_pushed_images(&mut stack, updates); - let agent = stack - .resources() - .find(|(id, _)| *id == "agent") - .and_then(|(_, e)| e.config.downcast_ref::().cloned()) - .expect("agent daemon should exist"); - assert_eq!( - agent.code, - DaemonCode::Image { - image: "registry.example.com/agent:tag".to_string() - } - ); - - // An unbuilt source daemon fails fast, same as workers and containers. - let source_daemon = Daemon::new("raw".to_string()) - .permissions("execution".to_string()) - .code(DaemonCode::Source { - src: ".".to_string(), - toolchain: ToolchainConfig::Rust { - binary_name: "raw".to_string(), - }, - }) - .build(); - let source_stack = Stack::new("daemon-source".to_string()) - .add(source_daemon, alien_core::ResourceLifecycle::Live) - .build(); - let error = match collect_push_targets(&source_stack) { - Err(error) => error, - Ok(_) => panic!("source daemon must be rejected"), - }; - assert!(error.to_string().contains("Run 'alien build' first")); - } - - #[tokio::test] - async fn test_pull_and_export_alpine() { - if !docker_available() { - eprintln!("Skipping test_pull_and_export_alpine: docker not available"); - return; - } - - tracing_subscriber::fmt::try_init().ok(); - - let build_dir = tempdir().unwrap(); - let settings = BuildSettings { - output_directory: build_dir.path().to_str().unwrap().to_string(), - platform: PlatformBuildSettings::Test {}, - targets: Some(vec![BinaryTarget::LinuxX64]), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - - // Pull alpine:latest (small, always available) - let result = pull_and_export_image( - "alpine:latest", - "test-alpine", - "test-stack", - &settings, - build_dir.path(), - ) - .await; - - assert!( - result.is_ok(), - "Should successfully pull and export alpine:latest" - ); - - let image_dir = result.unwrap(); - let image_path = PathBuf::from(&image_dir); - - // Verify directory exists and has content hash - assert!(image_path.exists(), "Image directory should exist"); - assert!( - image_path - .file_name() - .unwrap() - .to_str() - .unwrap() - .starts_with("test-alpine-"), - "Directory should have content hash suffix" - ); - - // Verify OCI tarball was created - let tarball_path = image_path.join("linux-x64.oci.tar"); - assert!(tarball_path.exists(), "OCI tarball should exist"); - - // Verify tarball is valid OCI format - let image = Image::from_tarball(&tarball_path).expect("OCI tarball should be valid"); - - let metadata = image - .get_metadata() - .expect("Should be able to read image metadata"); - - // Alpine has a CMD - assert!( - metadata.cmd.is_some() || metadata.entrypoint.is_some(), - "Alpine image should have entrypoint or cmd" - ); - } - - #[tokio::test] - async fn test_pull_nonexistent_image_fails() { - if !docker_available() { - eprintln!("Skipping test_pull_nonexistent_image_fails: docker not available"); - return; - } - - tracing_subscriber::fmt::try_init().ok(); - - let build_dir = tempdir().unwrap(); - let settings = BuildSettings { - output_directory: build_dir.path().to_str().unwrap().to_string(), - platform: PlatformBuildSettings::Test {}, - targets: Some(vec![BinaryTarget::LinuxX64]), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - - // Try to pull non-existent image - let result = pull_and_export_image( - "this-image-definitely-does-not-exist-xyz123:nonexistent", - "test-nonexistent", - "test-stack", - &settings, - build_dir.path(), - ) - .await; - - // Should fail with docker pull error - assert!(result.is_err(), "Should fail for non-existent image"); - let err = result.unwrap_err(); - let err_str = err.to_string(); - assert!( - err_str.contains("docker pull failed") || err_str.contains("not found"), - "Error should mention docker pull failure: {}", - err_str - ); - } - - #[tokio::test] - async fn test_pull_and_export_produces_hash() { - if !docker_available() { - eprintln!("Skipping test_pull_and_export_produces_hash: docker not available"); - return; - } - - tracing_subscriber::fmt::try_init().ok(); - - let build_dir = tempdir().unwrap(); - let settings = BuildSettings { - output_directory: build_dir.path().to_str().unwrap().to_string(), - platform: PlatformBuildSettings::Test {}, - targets: Some(vec![BinaryTarget::LinuxX64]), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - - // Pull alpine image - let result = pull_and_export_image( - "alpine:latest", - "test-alpine", - "test-stack", - &settings, - build_dir.path(), - ) - .await - .expect("Pull should succeed"); - - // Verify directory name has hash suffix - let path = PathBuf::from(&result); - let dir_name = path.file_name().unwrap().to_str().unwrap(); - - // Should be in format: test-alpine-XXXXXXXX (8 char hash) - assert!( - dir_name.starts_with("test-alpine-"), - "Should have container name prefix" - ); - - let hash_part = dir_name.strip_prefix("test-alpine-").unwrap(); - assert_eq!(hash_part.len(), 8, "Hash should be 8 characters"); - assert!( - hash_part.chars().all(|c| c.is_ascii_hexdigit()), - "Hash should be hexadecimal" - ); - - // Verify hash is based on tarball content - // (Pulling same tag multiple times might get different content if image updated, - // which is exactly why we hash - to detect changes!) - let tarball_path = path.join("linux-x64.oci.tar"); - assert!(tarball_path.exists(), "Tarball should exist"); - } - - #[tokio::test] - async fn source_artifact_cache_key_is_shared_for_equivalent_cloud_builds() { - let src_dir = tempdir().unwrap(); - std::fs::create_dir_all(src_dir.path().join("src")).unwrap(); - std::fs::write( - src_dir.path().join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - std::fs::write(src_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); - - let toolchain = ToolchainConfig::Rust { - binary_name: "app".to_string(), - }; - let targets = vec![BinaryTarget::LinuxX64]; - let gcp = BuildSettings { - output_directory: src_dir.path().join("out").to_string_lossy().into_owned(), - platform: PlatformBuildSettings::Gcp {}, - targets: Some(targets.clone()), - cache_url: None, - override_base_image: Some("registry.example.com/base:tag".to_string()), - debug_mode: false, - }; - let azure = BuildSettings { - platform: PlatformBuildSettings::Azure {}, - override_base_image: Some("registry.example.com/base:other-tag".to_string()), - ..gcp.clone() - }; - - let gcp_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &gcp, - &targets, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - let azure_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &azure, - &targets, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - - assert_eq!( - gcp_key, azure_key, - "direct workloads must ignore the Worker runtime-base override" - ); - let gcp_daemon_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &gcp, - &targets, - crate::toolchain::WorkloadKind::Daemon, - ) - .await - .unwrap(); - let azure_daemon_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &azure, - &targets, - crate::toolchain::WorkloadKind::Daemon, - ) - .await - .unwrap(); - assert_eq!( - gcp_daemon_key, azure_daemon_key, - "Daemon artifacts must ignore the Worker runtime-base override" - ); - - let gcp_worker_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &gcp, - &targets, - crate::toolchain::WorkloadKind::Worker, - ) - .await - .unwrap(); - let azure_worker_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &azure, - &targets, - crate::toolchain::WorkloadKind::Worker, - ) - .await - .unwrap(); - assert_ne!( - gcp_worker_key, azure_worker_key, - "Worker artifacts must include their runtime base in the cache key" - ); - - let docker_toolchain = ToolchainConfig::Docker { - dockerfile: None, - build_args: None, - target: None, - }; - let gcp_docker_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &docker_toolchain, - &gcp, - &targets, - crate::toolchain::WorkloadKind::Worker, - ) - .await - .unwrap(); - let azure_docker_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &docker_toolchain, - &azure, - &targets, - crate::toolchain::WorkloadKind::Worker, - ) - .await - .unwrap(); - assert_eq!( - gcp_docker_key, azure_docker_key, - "Dockerfile builds own their base and must ignore the source Worker override" - ); - - let local_a = BuildSettings { - platform: PlatformBuildSettings::Local {}, - ..gcp - }; - let local_b = BuildSettings { - platform: PlatformBuildSettings::Local {}, - ..azure - }; - let local_a_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &local_a, - &targets, - crate::toolchain::WorkloadKind::Worker, - ) - .await - .unwrap(); - let local_b_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &local_b, - &targets, - crate::toolchain::WorkloadKind::Worker, - ) - .await - .unwrap(); - assert_eq!( - local_a_key, local_b_key, - "Local Workers run from scratch and must ignore the cloud runtime base" - ); - } - - #[tokio::test] - async fn rust_source_artifact_cache_key_includes_local_path_dependencies() { - let workspace_dir = tempdir().unwrap(); - let app_dir = workspace_dir.path().join("app"); - let dep_dir = workspace_dir.path().join("dep"); - std::fs::create_dir_all(app_dir.join("src")).unwrap(); - std::fs::create_dir_all(dep_dir.join("src")).unwrap(); - std::fs::write( - workspace_dir.path().join("Cargo.toml"), - "[workspace]\nmembers = [\"app\", \"dep\"]\nresolver = \"2\"\n", - ) - .unwrap(); - std::fs::write( - app_dir.join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ndep = { path = \"../dep\" }\n", - ) - .unwrap(); - std::fs::write(app_dir.join("src/main.rs"), "fn main() { dep::value(); }\n").unwrap(); - std::fs::write( - dep_dir.join("Cargo.toml"), - "[package]\nname = \"dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - std::fs::write(dep_dir.join("src/lib.rs"), "pub fn value() -> u32 { 1 }\n").unwrap(); - - let toolchain = ToolchainConfig::Rust { - binary_name: "app".to_string(), - }; - let targets = vec![BinaryTarget::LinuxX64]; - let settings = BuildSettings { - output_directory: workspace_dir - .path() - .join("out") - .to_string_lossy() - .into_owned(), - platform: PlatformBuildSettings::Gcp {}, - targets: Some(targets.clone()), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - - let first_key = compute_source_artifact_cache_key( - app_dir.to_str().unwrap(), - &toolchain, - &settings, - &targets, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - - std::fs::write(dep_dir.join("src/lib.rs"), "pub fn value() -> u32 { 2 }\n").unwrap(); - - let second_key = compute_source_artifact_cache_key( - app_dir.to_str().unwrap(), - &toolchain, - &settings, - &targets, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - - assert_ne!(first_key, second_key); - } - - #[tokio::test] - async fn rust_source_artifact_cache_key_includes_workspace_toolchain_files() { - // Toolchain files live at the workspace root, not inside the member's - // package directory, so this must use a real `[workspace]` layout — - // otherwise package_dir == workspace_root and hash_source_directory - // picks the files up as ordinary source, masking a broken/deleted - // workspace-root hashing loop. - let workspace_dir = tempdir().unwrap(); - let app_dir = workspace_dir.path().join("app"); - std::fs::create_dir_all(app_dir.join("src")).unwrap(); - std::fs::write( - workspace_dir.path().join("Cargo.toml"), - "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n", - ) - .unwrap(); - std::fs::write( - app_dir.join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); - - let toolchain = ToolchainConfig::Rust { - binary_name: "app".to_string(), - }; - let targets = vec![BinaryTarget::LinuxX64]; - let settings = BuildSettings { - output_directory: workspace_dir - .path() - .join("out") - .to_string_lossy() - .into_owned(), - platform: PlatformBuildSettings::Gcp {}, - targets: Some(targets.clone()), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - - let key = |dir: &Path| { - let dir = dir.to_str().unwrap().to_string(); - let toolchain = toolchain.clone(); - let settings = settings.clone(); - let targets = targets.clone(); - async move { - compute_source_artifact_cache_key( - &dir, - &toolchain, - &settings, - &targets, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap() - } - }; - - let without_toolchain_file = key(&app_dir).await; - - std::fs::write( - workspace_dir.path().join("rust-toolchain.toml"), - "[toolchain]\nchannel = \"1.84.0\"\n", - ) - .unwrap(); - let with_pinned_toolchain = key(&app_dir).await; - assert_ne!( - without_toolchain_file, with_pinned_toolchain, - "pinning the compiler via a workspace-root rust-toolchain.toml must invalidate the artifact cache key" - ); - - std::fs::write( - workspace_dir.path().join("rust-toolchain.toml"), - "[toolchain]\nchannel = \"1.85.0\"\n", - ) - .unwrap(); - let with_changed_toolchain = key(&app_dir).await; - assert_ne!( - with_pinned_toolchain, with_changed_toolchain, - "changing the content of the workspace-root rust-toolchain.toml must invalidate the artifact cache key" - ); - - std::fs::create_dir_all(workspace_dir.path().join(".cargo")).unwrap(); - std::fs::write( - workspace_dir.path().join(".cargo/config.toml"), - "[build]\nrustflags = [\"-C\", \"target-cpu=native\"]\n", - ) - .unwrap(); - let with_cargo_config = key(&app_dir).await; - assert_ne!( - with_changed_toolchain, with_cargo_config, - "changing rustflags via workspace-root .cargo/config.toml must invalidate the artifact cache key" - ); - } - - #[tokio::test] - async fn source_artifact_cache_key_differs_across_target_triples() { - let src_dir = tempdir().unwrap(); - std::fs::create_dir_all(src_dir.path().join("src")).unwrap(); - std::fs::write( - src_dir.path().join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - std::fs::write(src_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); - - let toolchain = ToolchainConfig::Rust { - binary_name: "app".to_string(), - }; - let key_for = |targets: Vec| { - let dir = src_dir.path().to_str().unwrap().to_string(); - let out = src_dir.path().join("out").to_string_lossy().into_owned(); - let toolchain = toolchain.clone(); - async move { - let settings = BuildSettings { - output_directory: out, - platform: PlatformBuildSettings::Gcp {}, - targets: Some(targets.clone()), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - compute_source_artifact_cache_key( - &dir, - &toolchain, - &settings, - &targets, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap() - } - }; - - let x64_key = key_for(vec![BinaryTarget::LinuxX64]).await; - let arm64_key = key_for(vec![BinaryTarget::LinuxArm64]).await; - assert_ne!( - x64_key, arm64_key, - "different target triples must not share build artifacts" - ); - } - - /// Reuse invariant, end to end at the cache layer: after one platform's build - /// produces artifacts, an equivalent-target build for another platform finds - /// them (one build total), while a build for a different triple misses even - /// though the tarball file exists (two builds total). - #[tokio::test] - async fn equivalent_platform_build_reuses_artifact_but_differing_triple_rebuilds() { - let src_dir = tempdir().unwrap(); - std::fs::create_dir_all(src_dir.path().join("src")).unwrap(); - std::fs::write( - src_dir.path().join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", - ) - .unwrap(); - std::fs::write(src_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); - - let toolchain = ToolchainConfig::Rust { - binary_name: "app".to_string(), - }; - let out_root = tempdir().unwrap(); - let settings_for = - |platform: PlatformBuildSettings, targets: &[BinaryTarget]| BuildSettings { - output_directory: out_root.path().to_string_lossy().into_owned(), - platform, - targets: Some(targets.to_vec()), - cache_url: None, - override_base_image: None, - debug_mode: false, - }; - let x64 = vec![BinaryTarget::LinuxX64]; - let arm64 = vec![BinaryTarget::LinuxArm64]; - - // "First build" (gcp, linux-x64): produce the hashed artifact directory - // exactly as build_resource finalizes it. - let gcp_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &settings_for(PlatformBuildSettings::Gcp {}, &x64), - &x64, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - let gcp_dir = out_root.path().join("build").join("gcp"); - let artifact_dir = gcp_dir.join("app-12345678"); - fs::create_dir_all(&artifact_dir).await.unwrap(); - fs::write(artifact_dir.join("linux-x64.oci.tar"), b"oci") - .await - .unwrap(); - // Also stage an arm64 tarball so the differing-triple case below is - // decided by the cache key, not by a missing target file. - fs::write(artifact_dir.join("linux-arm64.oci.tar"), b"oci") - .await - .unwrap(); - write_artifact_cache_metadata(&artifact_dir, &gcp_key) - .await - .unwrap(); - - // "Second build" (azure, same source, same linux-x64 target): the key - // matches and the sibling-platform lookup finds the gcp artifacts, so - // no second compile happens. - let azure_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &settings_for(PlatformBuildSettings::Azure {}, &x64), - &x64, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - assert_eq!(gcp_key, azure_key, "equivalent platforms must share keys"); - - let azure_dir = out_root.path().join("build").join("azure"); - fs::create_dir_all(&azure_dir).await.unwrap(); - let reused = find_cached_artifact_dir(&azure_dir, "app", &x64, &azure_key) - .await - .unwrap(); - assert_eq!( - reused, - Some(artifact_dir.clone()), - "same inputs + equivalent targets must reuse the one built artifact" - ); - - // "Third build" (aws, linux-arm64): the tarball file exists, but the - // key differs, so the lookup misses and a real build would run. - let aws_key = compute_source_artifact_cache_key( - src_dir.path().to_str().unwrap(), - &toolchain, - &settings_for( - PlatformBuildSettings::Aws { - managing_account_id: None, - }, - &arm64, - ), - &arm64, - crate::toolchain::WorkloadKind::Container, - ) - .await - .unwrap(); - assert_ne!(gcp_key, aws_key); - - let aws_dir = out_root.path().join("build").join("aws"); - fs::create_dir_all(&aws_dir).await.unwrap(); - let miss = find_cached_artifact_dir(&aws_dir, "app", &arm64, &aws_key) - .await - .unwrap(); - assert_eq!(miss, None, "a differing triple must trigger its own build"); - } - - #[tokio::test] - async fn artifact_cache_lookup_reuses_sibling_platform_directory() { - let temp_root = tempdir().unwrap(); - let build_root = temp_root.path().join("build"); - let gcp_dir = build_root.join("gcp"); - let azure_dir = build_root.join("azure"); - let cached_dir = gcp_dir.join("alien-manager-abcdef12"); - - fs::create_dir_all(&cached_dir).await.unwrap(); - fs::create_dir_all(&azure_dir).await.unwrap(); - fs::write(cached_dir.join("linux-x64.oci.tar"), b"oci") - .await - .unwrap(); - write_artifact_cache_metadata(&cached_dir, "cache-key") - .await - .unwrap(); - - let found = find_cached_artifact_dir( - &azure_dir, - "alien-manager", - &[BinaryTarget::LinuxX64], - "cache-key", - ) - .await - .unwrap(); - - assert_eq!(found, Some(cached_dir)); - } - - #[tokio::test] - async fn finalize_artifact_dir_reuses_existing_final_directory() { - let temp_root = tempdir().unwrap(); - let temp_dir = temp_root.path().join(".agent-tmp-1234"); - let final_dir = temp_root.path().join("agent-abcdef12"); - - fs::create_dir_all(&temp_dir).await.unwrap(); - fs::write(temp_dir.join("linux-x64.oci.tar"), b"new-build") - .await - .unwrap(); - - fs::create_dir_all(&final_dir).await.unwrap(); - fs::write(final_dir.join("linux-x64.oci.tar"), b"existing-build") - .await - .unwrap(); - - let resolved = finalize_artifact_dir(&temp_dir, &final_dir, "build") - .await - .unwrap(); - - assert_eq!(resolved, final_dir.to_string_lossy()); - assert!(final_dir.exists()); - assert!(!temp_dir.exists()); - assert_eq!( - fs::read(final_dir.join("linux-x64.oci.tar")).await.unwrap(), - b"existing-build" - ); - } - - #[test] - fn temp_artifact_dir_is_hidden_and_unique() { - let build_output_dir = PathBuf::from("/tmp/build-output"); - - let first = temp_artifact_dir(&build_output_dir, "agent"); - let second = temp_artifact_dir(&build_output_dir, "agent"); - - assert_ne!(first, second); - assert_eq!(first.parent().unwrap(), build_output_dir.as_path()); - assert!(first - .file_name() - .unwrap() - .to_string_lossy() - .starts_with(".agent-tmp-")); - assert!(second - .file_name() - .unwrap() - .to_string_lossy() - .starts_with(".agent-tmp-")); - } - - /// End-to-end: build two arches into one resource dir, push, and assert the pushed tag - /// resolves to a real multi-arch manifest list (not a single overwritten arch). - /// Gated on docker + a local registry (`docker run -d -p 5050:5000 registry:2`). - #[tokio::test] - async fn multiarch_push_produces_manifest_list() { - use crate::toolchain::{docker::DockerToolchain, Toolchain, ToolchainContext}; - - const REGISTRY: &str = "localhost:5050"; - if !docker_available() { - eprintln!("Skipping multiarch_push_produces_manifest_list: docker not available"); - return; - } - if !registry_available(&format!("http://{REGISTRY}")).await { - eprintln!( - "Skipping multiarch_push_produces_manifest_list: no registry at {REGISTRY} (run: docker run -d -p 5050:5000 registry:2)" - ); - return; - } - - let src = tempfile::tempdir().unwrap(); - let build_dir = tempfile::tempdir().unwrap(); - std::fs::write( - src.path().join("Dockerfile"), - "FROM alpine:latest\nCMD [\"echo\", \"hi\"]\n", - ) - .unwrap(); - - // Build both linux arches into the same resource dir. - for target in [BinaryTarget::LinuxArm64, BinaryTarget::LinuxX64] { - let toolchain = DockerToolchain { - dockerfile: None, - build_args: None, - target: None, - }; - let context = ToolchainContext { - src_dir: src.path().to_path_buf(), - build_dir: build_dir.path().to_path_buf(), - cache_store: None, - cache_prefix: "test".to_string(), - build_target: target, - runtime_platform_name: "aws".to_string(), - debug_mode: false, - workload: crate::toolchain::WorkloadKind::Container, - }; - toolchain - .build(&context) - .await - .expect("docker build should succeed"); - } - assert!(build_dir.path().join("linux-aarch64.oci.tar").exists()); - assert!(build_dir.path().join("linux-x64.oci.tar").exists()); - - let container = Container::new("web".to_string()) - .code(ContainerCode::Image { - image: build_dir.path().to_string_lossy().into_owned(), - }) - .cpu(alien_core::ResourceSpec { - min: "0.5".to_string(), - desired: "1".to_string(), - }) - .memory(alien_core::ResourceSpec { - min: "512Mi".to_string(), - desired: "1Gi".to_string(), - }) - .permissions("web".to_string()) - .build(); - let stack = Stack::new("multiarch-test".to_string()) - .add(container, alien_core::ResourceLifecycle::Live) - .build(); - - let push_settings = PushSettings { - repository: format!("{REGISTRY}/alien-multiarch-test"), - destination_label: None, - options: dockdash::PushOptions { - auth: dockdash::RegistryAuth::Anonymous, - protocol: dockdash::ClientProtocol::Http, - ..Default::default() - }, - }; - - let pushed = push_stack(stack, Platform::Aws, &push_settings) - .await - .expect("push should succeed"); - - let image_uri = pushed - .resources() - .filter_map(|(_, entry)| entry.config.downcast_ref::()) - .find_map(|c| match &c.code { - ContainerCode::Image { image } => Some(image.clone()), - _ => None, - }) - .expect("container should carry a pushed image URI"); - assert!( - image_uri.contains(REGISTRY), - "expected a registry URI, got {image_uri}" - ); - - // The pushed tag must resolve to an image index with both linux arches. - let client = OciClient::new(OciClientConfig { - protocol: dockdash::ClientProtocol::Http, - ..Default::default() - }); - let reference = Reference::try_from(image_uri.as_str()).unwrap(); - let (bytes, _digest) = client - .pull_manifest_raw( - &reference, - &dockdash::RegistryAuth::Anonymous, - &[ - OCI_IMAGE_INDEX_MEDIA_TYPE, - "application/vnd.docker.distribution.manifest.list.v2+json", - ], - ) - .await - .expect("should pull a manifest list"); - let index: OciImageIndex = - serde_json::from_slice(&bytes).expect("pushed tag should be an image index"); - let mut platforms: Vec<(String, String)> = index - .manifests - .iter() - .filter_map(|m| { - m.platform - .as_ref() - .map(|p| (p.os.clone(), p.architecture.clone())) - }) - .collect(); - platforms.sort(); - assert_eq!( - platforms, - vec![ - ("linux".to_string(), "amd64".to_string()), - ("linux".to_string(), "arm64".to_string()), - ], - "pushed tag must be a real multi-arch index" - ); - } - - /// End-to-end: build a single arch into a resource dir, push, and assert the pushed tag - /// resolves to a plain image manifest (not an index). This is the path every current - /// single-platform release (aws/gcp/azure) takes, so the direct branch must stay intact. - /// Gated on docker + a local registry (`docker run -d -p 5050:5000 registry:2`). - #[tokio::test] - async fn singlearch_push_produces_single_manifest() { - use crate::toolchain::{docker::DockerToolchain, Toolchain, ToolchainContext}; - - const REGISTRY: &str = "localhost:5050"; - if !docker_available() { - eprintln!("Skipping singlearch_push_produces_single_manifest: docker not available"); - return; - } - if !registry_available(&format!("http://{REGISTRY}")).await { - eprintln!( - "Skipping singlearch_push_produces_single_manifest: no registry at {REGISTRY} (run: docker run -d -p 5050:5000 registry:2)" - ); - return; - } - - let src = tempfile::tempdir().unwrap(); - let build_dir = tempfile::tempdir().unwrap(); - std::fs::write( - src.path().join("Dockerfile"), - "FROM alpine:latest\nCMD [\"echo\", \"hi\"]\n", - ) - .unwrap(); - - // Build a single linux arch into the resource dir. - let toolchain = DockerToolchain { - dockerfile: None, - build_args: None, - target: None, - }; - let context = ToolchainContext { - src_dir: src.path().to_path_buf(), - build_dir: build_dir.path().to_path_buf(), - cache_store: None, - cache_prefix: "test".to_string(), - build_target: BinaryTarget::LinuxArm64, - runtime_platform_name: "aws".to_string(), - debug_mode: false, - workload: crate::toolchain::WorkloadKind::Container, - }; - toolchain - .build(&context) - .await - .expect("docker build should succeed"); - assert!(build_dir.path().join("linux-aarch64.oci.tar").exists()); - - let container = Container::new("web".to_string()) - .code(ContainerCode::Image { - image: build_dir.path().to_string_lossy().into_owned(), - }) - .cpu(alien_core::ResourceSpec { - min: "0.5".to_string(), - desired: "1".to_string(), - }) - .memory(alien_core::ResourceSpec { - min: "512Mi".to_string(), - desired: "1Gi".to_string(), - }) - .permissions("web".to_string()) - .build(); - let stack = Stack::new("singlearch-test".to_string()) - .add(container, alien_core::ResourceLifecycle::Live) - .build(); - - let push_settings = PushSettings { - repository: format!("{REGISTRY}/alien-singlearch-test"), - destination_label: None, - options: dockdash::PushOptions { - auth: dockdash::RegistryAuth::Anonymous, - protocol: dockdash::ClientProtocol::Http, - ..Default::default() - }, - }; - - let pushed = push_stack(stack, Platform::Aws, &push_settings) - .await - .expect("push should succeed"); - - let image_uri = pushed - .resources() - .filter_map(|(_, entry)| entry.config.downcast_ref::()) - .find_map(|c| match &c.code { - ContainerCode::Image { image } => Some(image.clone()), - _ => None, - }) - .expect("container should carry a pushed image URI"); - assert!( - image_uri.contains(REGISTRY), - "expected a registry URI, got {image_uri}" - ); - - // The pushed tag must resolve to a plain image manifest, NOT an index: it has a - // `config` descriptor and no `manifests` array. - let client = OciClient::new(OciClientConfig { - protocol: dockdash::ClientProtocol::Http, - ..Default::default() - }); - let reference = Reference::try_from(image_uri.as_str()).unwrap(); - let (bytes, _digest) = client - .pull_manifest_raw( - &reference, - &dockdash::RegistryAuth::Anonymous, - &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE], - ) - .await - .expect("should pull a manifest"); - let value: serde_json::Value = - serde_json::from_slice(&bytes).expect("pushed tag should be valid JSON"); - assert!( - value.get("config").is_some(), - "single-arch push must produce an image manifest with a config descriptor, got: {value}" - ); - assert!( - value.get("manifests").is_none(), - "single-arch push must not produce a manifest index, got: {value}" - ); - } - - /// End-to-end seam: build two arches into two separate partial outputs (one per native - /// runner), run `merge_build_outputs` to combine them, load the merged stack exactly as - /// the release path does (deserialize stack.json), then push — asserting the merged dir - /// resolves to a real multi-arch index. This exercises the merge→load→push chain as one - /// flow, not as independent halves. Gated on docker + a local registry. - #[tokio::test] - async fn merge_then_push_produces_manifest_list() { - use crate::toolchain::{docker::DockerToolchain, Toolchain, ToolchainContext}; - - const REGISTRY: &str = "localhost:5050"; - if !docker_available() { - eprintln!("Skipping merge_then_push_produces_manifest_list: docker not available"); - return; - } - if !registry_available(&format!("http://{REGISTRY}")).await { - eprintln!( - "Skipping merge_then_push_produces_manifest_list: no registry at {REGISTRY} (run: docker run -d -p 5050:5000 registry:2)" - ); - return; - } - - let src = tempfile::tempdir().unwrap(); - let input_root = tempfile::tempdir().unwrap(); - let out = tempfile::tempdir().unwrap(); - std::fs::write( - src.path().join("Dockerfile"), - "FROM alpine:latest\nCMD [\"echo\", \"hi\"]\n", - ) - .unwrap(); - - // Build each arch into its own partial: //build/aws//, - // with a stack.json whose code.image is that partial's absolute artifact dir — the - // exact shape a native-runner `alien build --output-dir` upload produces. - for (partial, target, dir_name) in [ - ("arm", BinaryTarget::LinuxArm64, "web-aaaa1111"), - ("x64", BinaryTarget::LinuxX64, "web-bbbb2222"), - ] { - let platform_dir = input_root.path().join(partial).join("build").join("aws"); - let artifact_dir = platform_dir.join(dir_name); - std::fs::create_dir_all(&artifact_dir).unwrap(); - - let toolchain = DockerToolchain { - dockerfile: None, - build_args: None, - target: None, - }; - let context = ToolchainContext { - src_dir: src.path().to_path_buf(), - build_dir: artifact_dir.clone(), - cache_store: None, - cache_prefix: "test".to_string(), - build_target: target, - runtime_platform_name: "aws".to_string(), - debug_mode: false, - workload: crate::toolchain::WorkloadKind::Container, - }; - toolchain - .build(&context) - .await - .expect("docker build should succeed"); - - let image = artifact_dir - .canonicalize() - .unwrap() - .to_string_lossy() - .into_owned(); - let container = Container::new("web".to_string()) - .code(ContainerCode::Image { image }) - .cpu(alien_core::ResourceSpec { - min: "0.5".to_string(), - desired: "1".to_string(), - }) - .memory(alien_core::ResourceSpec { - min: "512Mi".to_string(), - desired: "1Gi".to_string(), - }) - .permissions("web".to_string()) - .build(); - let stack = Stack::new("merge-push-test".to_string()) - .add(container, alien_core::ResourceLifecycle::Live) - .build(); - std::fs::write( - platform_dir.join("stack.json"), - serde_json::to_string_pretty(&stack).unwrap(), - ) - .unwrap(); - } - - // Merge the two partials into one .alien. - let platforms = crate::merge::merge_build_outputs(input_root.path(), out.path()) - .expect("merge should succeed"); - assert_eq!(platforms, vec!["aws".to_string()]); - - // Load the merged stack the way the release path does, then push it. - let merged_json = std::fs::read_to_string(out.path().join("build/aws/stack.json")).unwrap(); - let merged_stack: Stack = - serde_json::from_str(&merged_json).expect("merged stack.json should deserialize"); - - let push_settings = PushSettings { - repository: format!("{REGISTRY}/alien-merge-push-test"), - destination_label: None, - options: dockdash::PushOptions { - auth: dockdash::RegistryAuth::Anonymous, - protocol: dockdash::ClientProtocol::Http, - ..Default::default() - }, - }; - - let pushed = push_stack(merged_stack, Platform::Aws, &push_settings) - .await - .expect("push of the merged stack should succeed"); - - let image_uri = pushed - .resources() - .filter_map(|(_, entry)| entry.config.downcast_ref::()) - .find_map(|c| match &c.code { - ContainerCode::Image { image } => Some(image.clone()), - _ => None, - }) - .expect("container should carry a pushed image URI"); - - let client = OciClient::new(OciClientConfig { - protocol: dockdash::ClientProtocol::Http, - ..Default::default() - }); - let reference = Reference::try_from(image_uri.as_str()).unwrap(); - let (bytes, _digest) = client - .pull_manifest_raw( - &reference, - &dockdash::RegistryAuth::Anonymous, - &[ - OCI_IMAGE_INDEX_MEDIA_TYPE, - "application/vnd.docker.distribution.manifest.list.v2+json", - ], - ) - .await - .expect("should pull a manifest list"); - let index: OciImageIndex = serde_json::from_slice(&bytes) - .expect("merged-then-pushed tag should be an image index"); - let mut platforms: Vec<(String, String)> = index - .manifests - .iter() - .filter_map(|m| { - m.platform - .as_ref() - .map(|p| (p.os.clone(), p.architecture.clone())) - }) - .collect(); - platforms.sort(); - assert_eq!( - platforms, - vec![ - ("linux".to_string(), "amd64".to_string()), - ("linux".to_string(), "arm64".to_string()), - ], - "merged stack must push as a real multi-arch index" - ); - } -} diff --git a/crates/alien-build/src/push.rs b/crates/alien-build/src/push.rs new file mode 100644 index 000000000..982ab3326 --- /dev/null +++ b/crates/alien-build/src/push.rs @@ -0,0 +1,688 @@ +use crate::error::{DockdashResultExt, ErrorData, Result}; +use crate::settings::PushSettings; +use alien_core::{ + alien_event, AlienEvent, BinaryTarget, Container, ContainerCode, Daemon, DaemonCode, Platform, + Stack, Worker, WorkerCode, +}; +use alien_error::{AlienError, Context, IntoAlienError}; +use dockdash::Image as DockDashImage; +use oci_client::client::{Client as OciClient, ClientConfig as OciClientConfig}; +use oci_client::manifest::{ + ImageIndexEntry, OciImageIndex, Platform as OciPlatform, IMAGE_MANIFEST_MEDIA_TYPE, + OCI_IMAGE_INDEX_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE, +}; +use oci_client::Reference; +use rand::distr::Alphanumeric; +use rand::Rng; +use std::path::{Path, PathBuf}; +use std::time::Instant; +use tokio::fs; +use tracing::info; + +/// A compute resource that has a locally-built image directory and needs to be pushed to a registry. +pub(crate) struct ResourcePushTarget { + /// Stack resource keys that should be updated with the pushed image URI. + pub(crate) resource_ids: Vec, + /// Resource IDs sharing this push target. The first name is used for logging and image tagging. + pub(crate) resource_names: Vec, + /// Display name for events/logging ("worker", "container", etc.) + pub(crate) resource_type: &'static str, + /// Local directory containing OCI tarballs produced by `alien build` + pub(crate) local_image_dir: PathBuf, +} + +impl ResourcePushTarget { + fn resource_name(&self) -> &str { + self.resource_names + .first() + .expect("push target should have at least one resource name") + } + + fn display_resource_name(&self) -> String { + if self.resource_names.len() > 1 { + format!("{} (shared)", self.resource_names.join(", ")) + } else { + self.resource_name().to_string() + } + } + + pub(crate) fn push_result_updates(&self, image_uri: String) -> Vec<(String, String)> { + self.resource_ids + .iter() + .map(|resource_id| (resource_id.clone(), image_uri.clone())) + .collect() + } +} + +pub(crate) fn push_target_for_local_image<'a>( + targets: &'a mut Vec, + resource_type: &'static str, + local_image_dir: &Path, +) -> Option<&'a mut ResourcePushTarget> { + targets.iter_mut().find(|target| { + target.resource_type == resource_type && target.local_image_dir == local_image_dir + }) +} + +pub(crate) fn add_push_target_resource( + targets: &mut Vec, + resource_id: String, + resource_name: String, + resource_type: &'static str, + local_image_dir: PathBuf, +) { + if let Some(target) = push_target_for_local_image(targets, resource_type, &local_image_dir) { + target.resource_ids.push(resource_id); + target.resource_names.push(resource_name); + return; + } + + targets.push(ResourcePushTarget { + resource_ids: vec![resource_id], + resource_names: vec![resource_name], + resource_type, + local_image_dir, + }); +} + +/// Scans all resources in the stack and returns those with locally-built images that need +/// pushing to a registry. +/// +/// Returns an error if any compute resource still has unbuilt source code — that means +/// `alien build` was not run first. +/// +/// To add support for a new compute resource type, add an `else if` branch here and in +/// [`apply_pushed_images`]. +pub(crate) fn collect_push_targets(stack: &Stack) -> Result> { + let mut targets = Vec::new(); + + for (resource_id, resource_entry) in stack.resources() { + if let Some(func) = resource_entry.config.downcast_ref::() { + match &func.code { + WorkerCode::Image { image } => { + let path = PathBuf::from(image); + if path.exists() && path.is_dir() { + info!( + "Worker '{}' has local image directory, queuing for push", + func.id + ); + add_push_target_resource( + &mut targets, + resource_id.clone(), + func.id.clone(), + "worker", + path, + ); + } else { + info!("Worker '{}' already has remote image: {}", func.id, image); + } + } + WorkerCode::Source { .. } => { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: func.id.clone(), + reason: "Worker has source code instead of built image. Run 'alien build' first.".to_string(), + })); + } + } + } else if let Some(container) = resource_entry.config.downcast_ref::() { + match &container.code { + ContainerCode::Image { image } => { + let path = PathBuf::from(image); + if path.exists() && path.is_dir() { + info!( + "Container '{}' has local image directory, queuing for push", + container.id + ); + add_push_target_resource( + &mut targets, + resource_id.clone(), + container.id.clone(), + "container", + path, + ); + } else { + info!( + "Container '{}' already has remote image: {}", + container.id, image + ); + } + } + ContainerCode::Source { .. } => { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: container.id.clone(), + reason: "Container has source code instead of built image. Run 'alien build' first.".to_string(), + })); + } + } + } else if let Some(daemon) = resource_entry.config.downcast_ref::() { + match &daemon.code { + DaemonCode::Image { image } => { + let path = PathBuf::from(image); + if path.exists() && path.is_dir() { + info!( + "Daemon '{}' has local image directory, queuing for push", + daemon.id + ); + add_push_target_resource( + &mut targets, + resource_id.clone(), + daemon.id.clone(), + "daemon", + path, + ); + } else { + info!("Daemon '{}' already has remote image: {}", daemon.id, image); + } + } + DaemonCode::Source { .. } => { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: daemon.id.clone(), + reason: "Daemon has source code instead of built image. Run 'alien build' first.".to_string(), + })); + } + } + } + } + + Ok(targets) +} + +/// Applies pushed registry URIs back to their respective resources in the stack. +/// +/// To add support for a new compute resource type, add an `else if` branch here and in +/// [`collect_push_targets`]. +pub(crate) fn apply_pushed_images(stack: &mut Stack, updates: Vec<(String, String)>) { + for (resource_id, image_uri) in updates { + if let Some(resource_entry) = stack.resources_mut().find(|(id, _)| *id == &resource_id) { + if let Some(func) = resource_entry.1.config.downcast_mut::() { + func.code = WorkerCode::Image { image: image_uri }; + } else if let Some(container) = resource_entry.1.config.downcast_mut::() { + container.code = ContainerCode::Image { image: image_uri }; + } else if let Some(daemon) = resource_entry.1.config.downcast_mut::() { + daemon.code = DaemonCode::Image { image: image_uri }; + } + } + } +} + +/// Push built images from local OCI tarballs to a container registry. +/// Reads a stack with local image directory references, pushes them to the registry, +/// and returns an updated stack with pushed image URLs (in memory, not saved to disk). +#[alien_event(AlienEvent::PushingStack { + stack: stack.id().to_string(), + platform: platform.as_str().to_string(), + destination: push_settings.destination_label.clone(), +})] +pub async fn push_stack( + mut stack: Stack, + platform: Platform, + push_settings: &PushSettings, +) -> Result { + let push_started = Instant::now(); + info!( + "Starting image push process to registry: {}", + push_settings.repository + ); + + let to_push = collect_push_targets(&stack)?; + + let resource_count = to_push + .iter() + .map(|target| target.resource_ids.len()) + .sum::(); + + info!( + "Pushing {} artifact(s) for {} resource(s) to registry", + to_push.len(), + resource_count + ); + + if to_push.is_empty() { + info!("Image push process completed. No local images to push."); + return Ok(stack); + } + + // Push all resources in parallel with fail-fast behavior + let current_bus = alien_core::EventBus::current(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + + let push_tasks: Vec<_> = to_push + .into_iter() + .map(|target| { + let resource_name = target.resource_name().to_string(); + let display_resource_name = target.display_resource_name(); + let resource_names = target.resource_names.clone(); + let repository = push_settings.repository.clone(); + let push_opts = push_settings.options.clone(); + let bus = current_bus.clone(); + let cancel_token = cancel_token.clone(); + + tokio::spawn(async move { + let resource_name_for_warning = resource_name.clone(); + + let push_work = async move { + let target_resource_count = target.resource_ids.len(); + + if resource_names.len() > 1 { + info!( + "Starting parallel push for shared {} artifact '{}': {:?}", + target.resource_type, resource_name, resource_names + ); + } else { + info!( + "Starting parallel push for {} '{}'", + target.resource_type, resource_name + ); + } + + if cancel_token.is_cancelled() { + return Err(AlienError::new(ErrorData::BuildCanceled { + resource_name: resource_name.clone(), + })); + } + + let artifact_push_started = Instant::now(); + let result = tokio::select! { + result = push_resource_images( + &display_resource_name, + &resource_name, + target.resource_type, + &target.local_image_dir, + &repository, + &push_opts, + ) => result, + _ = cancel_token.cancelled() => { + info!("Push for {} '{}' was cancelled", target.resource_type, resource_name); + Err(AlienError::new(ErrorData::BuildCanceled { + resource_name: resource_name.clone(), + })) + } + }; + + match &result { + Ok(image_uri) => info!( + resource = resource_name.as_str(), + push_secs = format!("{:.2}", artifact_push_started.elapsed().as_secs_f64()).as_str(), + "Successfully pushed {} '{}' in {:.2}s to: {}", + target.resource_type, + resource_name, + artifact_push_started.elapsed().as_secs_f64(), + image_uri + ), + Err(e) => info!("Failed to push {} '{}': {}", target.resource_type, resource_name, e), + } + + result.map(|image_uri| { + ( + target_resource_count, + target.push_result_updates(image_uri), + ) + }) + }; + + match bus { + Some(bus) => bus.run(|| push_work).await, + None => { + tracing::warn!("No event bus context available for parallel push of '{}'", resource_name_for_warning); + push_work.await + } + } + }) + }) + .collect(); + + // Wait for first failure or all completions (fail-fast) + let mut push_results: Vec<(String, String)> = Vec::new(); + let mut completed_tasks = 0; + let mut remaining_tasks = push_tasks; + let mut first_error: Option> = None; + + while !remaining_tasks.is_empty() { + let (result, _index, rest) = futures::future::select_all(remaining_tasks).await; + remaining_tasks = rest; + + match result { + Ok(push_result) => match push_result { + Ok((target_resource_count, updates)) => { + push_results.extend(updates); + completed_tasks += 1; + info!( + "Applied pushed image to {} resource(s)", + target_resource_count + ); + } + Err(e) => { + if first_error.is_none() { + first_error = Some(e); + cancel_token.cancel(); + for task in remaining_tasks { + task.abort(); + } + break; + } + } + }, + Err(join_error) => { + if join_error.is_cancelled() { + info!("Push task was cancelled"); + } else { + tracing::warn!("Push task failed: {}", join_error); + if first_error.is_none() { + first_error = Some(AlienError::new(ErrorData::ImagePushFailed { + image: "unknown".to_string(), + reason: format!("Push task failed: {}", join_error), + })); + cancel_token.cancel(); + } + } + } + } + } + + if let Some(error) = first_error { + return Err(error); + } + + info!( + "Completed parallel pushing of {} artifact(s)", + completed_tasks + ); + + apply_pushed_images(&mut stack, push_results); + + info!( + "Image push process completed in {:.2}s. Stack updated with {} resource image URL(s).", + push_started.elapsed().as_secs_f64(), + resource_count + ); + + Ok(stack) +} + +/// Push all OCI tarballs for a resource to the registry +#[alien_event(AlienEvent::PushingResource { + resource_name: display_resource_name.to_string(), + resource_type: resource_type.to_string(), +})] +pub(crate) async fn push_resource_images( + display_resource_name: &str, + resource_name: &str, + resource_type: &str, + images_dir: &Path, + repository: &str, + push_options: &dockdash::PushOptions, +) -> Result { + let push_resource_started = Instant::now(); + info!( + "Pushing images for resource '{}' from {}", + display_resource_name, + images_dir.display() + ); + + // Generate unique tag for this push + let image_tag = generate_unique_tag(); + // Resource name is part of the tag, not a path segment + let full_tag = format!("{}-{}", resource_name, image_tag); + let image_uri = format!("{}:{}", repository, full_tag); + + // Find all OCI tarball files in the directory + let mut oci_files = Vec::new(); + let mut entries = fs::read_dir(images_dir).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read directory".to_string(), + file_path: images_dir.display().to_string(), + reason: "Failed to list OCI tarballs".to_string(), + }, + )?; + + while let Some(entry) = + entries + .next_entry() + .await + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "read directory entry".to_string(), + file_path: images_dir.display().to_string(), + reason: "Failed to read directory entry".to_string(), + })? + { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("tar") + && path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.contains(".oci.")) + .unwrap_or(false) + { + oci_files.push(path); + } + } + + if oci_files.is_empty() { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: resource_name.to_string(), + reason: format!("No OCI tarball files found in {}", images_dir.display()), + })); + } + + info!( + "Found {} OCI tarball(s) to push for resource '{}'", + oci_files.len(), + resource_name + ); + + // Create push options with progress callback + let mut push_opts_with_progress = push_options.clone(); + + // Add progress callback that emits alien events + struct AlienPushProgressCallback { + resource_name: String, + } + + #[async_trait::async_trait] + impl dockdash::PushProgressCallback for AlienPushProgressCallback { + async fn on_progress(&self, progress: dockdash::PushProgressInfo) { + // Emit PushingImage event with progress + let _ = AlienEvent::PushingImage { + image: self.resource_name.clone(), + progress: Some(alien_core::PushProgress { + operation: progress.operation, + layers_uploaded: progress.layers_uploaded, + total_layers: progress.total_layers, + bytes_uploaded: progress.bytes_uploaded, + total_bytes: progress.total_bytes, + }), + } + .emit() + .await; + } + } + + push_opts_with_progress.progress_callback = Some(Box::new(AlienPushProgressCallback { + resource_name: resource_name.to_string(), + })); + + // Container images are linux; darwin/windows tarballs (produced for `local` host + // binaries) are not registry container images, so they're excluded from the push. + let linux_tarballs = select_linux_tarballs(&oci_files); + + // No linux image (unusual) — push whatever tarballs are present. + if linux_tarballs.is_empty() { + for oci_file in &oci_files { + let image = DockDashImage::from_tarball(oci_file).map_dockdash_err()?; + image + .push(&image_uri, &push_opts_with_progress) + .await + .map_dockdash_err()?; + } + info!( + "Pushed resource '{}' in {:.2}s", + display_resource_name, + push_resource_started.elapsed().as_secs_f64() + ); + + return Ok(image_uri); + } + + // Single arch: push the image straight to the tag — no index needed. + if let [(_, only)] = linux_tarballs.as_slice() { + info!("Pushing {} to {}", only.display(), image_uri); + let image = DockDashImage::from_tarball(only).map_dockdash_err()?; + image + .push(&image_uri, &push_opts_with_progress) + .await + .map_dockdash_err()?; + info!( + "Pushed resource '{}' in {:.2}s", + display_resource_name, + push_resource_started.elapsed().as_secs_f64() + ); + + return Ok(image_uri); + } + + // Multi-arch: push each image to a per-arch child tag, then publish a manifest list + // (OCI image index) at the shared tag — otherwise the last single-arch push wins the tag. + let oci_client = OciClient::new(OciClientConfig { + protocol: push_options.protocol.clone(), + ..Default::default() + }); + let mut entries = Vec::new(); + for (target, oci_file) in &linux_tarballs { + let child_uri = format!("{}-{}", image_uri, target.runtime_platform_id()); + info!("Pushing {} as {}", oci_file.display(), child_uri); + let image = DockDashImage::from_tarball(oci_file).map_dockdash_err()?; + image + .push(&child_uri, &push_opts_with_progress) + .await + .map_dockdash_err()?; + + // The index entry's digest+size must reflect the manifest the registry stored + // (dockdash pushes a converted manifest), so read it back rather than hashing the tarball. + let child_ref = Reference::try_from(child_uri.as_str()) + .into_alien_error() + .context(ErrorData::InvalidResourceConfig { + resource_id: resource_name.to_string(), + reason: format!("Invalid image reference '{child_uri}'"), + })?; + let (manifest_bytes, digest) = oci_client + .pull_manifest_raw( + &child_ref, + &push_options.auth, + &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE], + ) + .await + .into_alien_error() + .context(ErrorData::ImagePushFailed { + image: child_uri.clone(), + reason: "Failed to read back the pushed manifest".to_string(), + })?; + let media_type = manifest_media_type(&manifest_bytes) + .unwrap_or_else(|| OCI_IMAGE_MEDIA_TYPE.to_string()); + entries.push(image_index_entry( + *target, + digest, + manifest_bytes.len() as i64, + media_type, + )); + } + + let index = assemble_image_index(entries); + let index_ref = Reference::try_from(image_uri.as_str()) + .into_alien_error() + .context(ErrorData::InvalidResourceConfig { + resource_id: resource_name.to_string(), + reason: format!("Invalid image reference '{image_uri}'"), + })?; + oci_client + .push_manifest_list(&index_ref, &push_options.auth, index) + .await + .into_alien_error() + .context(ErrorData::ImagePushFailed { + image: image_uri.clone(), + reason: "Failed to push the multi-arch manifest list".to_string(), + })?; + info!( + "Pushed multi-arch image {} ({} arches)", + image_uri, + linux_tarballs.len() + ); + + info!( + "Pushed resource '{}' in {:.2}s", + display_resource_name, + push_resource_started.elapsed().as_secs_f64() + ); + + Ok(image_uri) +} + +/// Pair each linux OCI tarball with its target, sorted for deterministic ordering. +/// darwin/windows tarballs are excluded — they are host binaries, not container images. +pub(crate) fn select_linux_tarballs(oci_files: &[PathBuf]) -> Vec<(BinaryTarget, PathBuf)> { + let mut out: Vec<(BinaryTarget, PathBuf)> = oci_files + .iter() + .filter_map(|path| oci_tarball_target(path).map(|target| (target, path.clone()))) + .filter(|(target, _)| target.oci_os() == "linux") + .collect(); + out.sort_by_key(|(target, _)| target.runtime_platform_id()); + out +} + +/// `.oci.tar` → its `BinaryTarget`. +pub(crate) fn oci_tarball_target(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + BinaryTarget::from_runtime_platform_id(name.strip_suffix(".oci.tar")?) +} + +/// One OCI image index entry for a built target. +pub(crate) fn image_index_entry( + target: BinaryTarget, + digest: String, + size: i64, + media_type: String, +) -> ImageIndexEntry { + ImageIndexEntry { + media_type, + digest, + size, + platform: Some(OciPlatform { + architecture: target.oci_arch().to_string(), + os: target.oci_os().to_string(), + os_version: None, + os_features: None, + variant: None, + features: None, + }), + annotations: None, + } +} + +/// Wrap per-arch manifest entries in an OCI image index (manifest list). +pub(crate) fn assemble_image_index(manifests: Vec) -> OciImageIndex { + OciImageIndex { + schema_version: 2, + media_type: Some(OCI_IMAGE_INDEX_MEDIA_TYPE.to_string()), + manifests, + artifact_type: None, + annotations: None, + } +} + +/// Read the `mediaType` field from a raw manifest, if present. +pub(crate) fn manifest_media_type(bytes: &[u8]) -> Option { + serde_json::from_slice::(bytes) + .ok()? + .get("mediaType")? + .as_str() + .map(|s| s.to_string()) +} + +pub(crate) fn generate_unique_tag() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect::() + .to_lowercase() +} diff --git a/crates/alien-build/src/tests.rs b/crates/alien-build/src/tests.rs new file mode 100644 index 000000000..4e30a64c2 --- /dev/null +++ b/crates/alien-build/src/tests.rs @@ -0,0 +1,1651 @@ +use super::*; +use crate::base_images::*; +use crate::cache::*; +use crate::push::*; +use crate::settings::PushSettings; +use alien_core::Worker; +use dockdash::Image; +use oci_client::client::{Client as OciClient, ClientConfig as OciClientConfig}; +use oci_client::manifest::{ + OciImageIndex, IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_INDEX_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE, +}; +use oci_client::Reference; +use std::path::PathBuf; +use std::process::Command; +use tempfile::tempdir; + +#[tokio::test] +async fn materializing_same_artifact_preserves_its_contents() { + let directory = tempfile::tempdir_in(".").unwrap(); + let absolute = std::fs::canonicalize(directory.path()) + .unwrap() + .join("image.oci.tar"); + std::fs::write(&absolute, b"oci archive").unwrap(); + let relative = absolute + .strip_prefix(std::env::current_dir().unwrap()) + .unwrap(); + + assert_ne!(relative, absolute); + assert!(!materialize_complete_oci_tarball(&absolute, relative) + .await + .unwrap()); + assert_eq!(std::fs::read(absolute).unwrap(), b"oci archive"); +} + +fn toolchain_output( + entrypoint: Option>, + runtime_command: Vec, +) -> toolchain::ToolchainOutput { + toolchain::ToolchainOutput { + build_strategy: toolchain::ImageBuildStrategy::FromScratch { layers: vec![] }, + entrypoint, + runtime_command, + } +} + +/// Pins the ENTRYPOINT/CMD contract shared by the base-image and +/// from-scratch build paths (see also tests/image_shape_tests.rs). +#[test] +fn image_entrypoint_and_cmd_contract() { + // Worker: base entrypoint kept, CMD is the separator + binary. + let worker = toolchain_output(None, vec!["--".to_string(), "./bin".to_string()]); + assert_eq!( + image_entrypoint_and_cmd(&worker), + (None, Some(vec!["--".to_string(), "./bin".to_string()])) + ); + + // Direct entrypoint (Container/Daemon): binary is the entrypoint, no CMD. + let direct = toolchain_output(Some(vec!["/app/bin".to_string()]), vec![]); + assert_eq!( + image_entrypoint_and_cmd(&direct), + (Some(vec!["/app/bin".to_string()]), None) + ); + + // Local from-scratch (host process): no entrypoint, CMD is the binary. + let local = toolchain_output(None, vec!["./bin".to_string()]); + assert_eq!( + image_entrypoint_and_cmd(&local), + (None, Some(vec!["./bin".to_string()])) + ); + + // Explicit entrypoint with a nonempty command keeps both. + let both = toolchain_output( + Some(vec!["/app/bin".to_string()]), + vec!["serve".to_string()], + ); + assert_eq!( + image_entrypoint_and_cmd(&both), + ( + Some(vec!["/app/bin".to_string()]), + Some(vec!["serve".to_string()]) + ) + ); +} + +#[test] +fn runtime_base_override_only_applies_to_workers() { + let direct_bases = vec!["cgr.dev/chainguard/wolfi-base:latest".to_string()]; + let runtime_base = "registry.example.com/alien-base:feature"; + + assert_eq!( + base_images_for_workload(&direct_bases, None, toolchain::WorkloadKind::Worker), + direct_bases, + "without an override the declared default bases must be preserved" + ); + assert_eq!( + base_images_for_workload( + &direct_bases, + Some(runtime_base), + toolchain::WorkloadKind::Worker, + ), + vec![runtime_base.to_string()] + ); + for workload in [ + toolchain::WorkloadKind::Container, + toolchain::WorkloadKind::Daemon, + ] { + assert_eq!( + base_images_for_workload(&direct_bases, Some(runtime_base), workload), + direct_bases, + "{} must not inherit the Worker runtime base", + workload.as_str() + ); + } +} + +#[test] +fn requested_host_binary_only_gates_container_skip() { + use BinaryTarget::*; + // None (defaults to host OS) and empty → containers still build. + assert!(!requested_host_binary_only(None)); + assert!(!requested_host_binary_only(Some(&[]))); + // Explicit non-Linux-only → nothing for a container to build, skip it. + assert!(requested_host_binary_only(Some(&[DarwinArm64]))); + assert!(requested_host_binary_only(Some(&[WindowsX64]))); + assert!(requested_host_binary_only(Some(&[DarwinArm64, WindowsX64]))); + // Any Linux target present → containers build for it. + assert!(!requested_host_binary_only(Some(&[LinuxArm64]))); + assert!(!requested_host_binary_only(Some(&[LinuxX64]))); + assert!(!requested_host_binary_only(Some(&[ + DarwinArm64, + LinuxArm64 + ]))); +} + +#[test] +fn local_build_strips_daemon_only_compute_cluster() { + let cluster = alien_core::ComputeCluster::new("host-runtime".to_string()) + .capacity_group(alien_core::CapacityGroup { + group_id: "general".to_string(), + instance_type: Some("m8i.xlarge".to_string()), + profile: None, + min_size: 1, + max_size: 1, + scale_policy: None, + nested_virtualization: Some(true), + }) + .build(); + let daemon = Daemon::new("host-loader".to_string()) + .cluster("host-runtime".to_string()) + .permissions("loader".to_string()) + .code(DaemonCode::Image { + image: "registry.example.com/host-loader:latest".to_string(), + }) + .build(); + let mut stack = Stack::new("host-loader-stack".to_string()) + .add(cluster, alien_core::ResourceLifecycle::Frozen) + .add(daemon, alien_core::ResourceLifecycle::Live) + .build(); + + strip_local_daemon_only_compute_clusters(&mut stack, Platform::Local); + + assert!(!stack.resources.contains_key("host-runtime")); + let daemon = stack + .resources() + .find(|(id, _)| *id == "host-loader") + .and_then(|(_, entry)| entry.config.downcast_ref::()) + .expect("daemon should remain"); + assert_eq!(daemon.cluster, None); +} + +#[tokio::test] +async fn machines_build_rejects_workers_before_writing_artifacts() { + let output = tempdir().unwrap(); + let worker = Worker::new("job".to_string()) + .permissions("execution".to_string()) + .code(WorkerCode::Image { + image: "registry.example.com/job:latest".to_string(), + }) + .build(); + let stack = Stack::new("machines-worker".to_string()) + .add(worker, alien_core::ResourceLifecycle::Live) + .build(); + let settings = BuildSettings { + output_directory: output.path().display().to_string(), + platform: PlatformBuildSettings::Machines {}, + targets: Some(BinaryTarget::LINUX.to_vec()), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + + let error = build_stack(stack, &settings) + .await + .expect_err("machines worker should fail build-time preflight"); + + assert_eq!(error.code, "STACK_PROCESSOR_FAILED"); + let serialized = serde_json::to_string(&error).expect("error should serialize"); + assert!(serialized.contains("MACHINES_UNSUPPORTED_RESOURCE")); + assert!(!output.path().join("build").join("machines").exists()); +} + +#[test] +fn source_cache_hash_ignores_build_artifacts() { + let src = tempdir().unwrap(); + std::fs::create_dir_all(src.path().join(".alien-build")).unwrap(); + std::fs::create_dir_all(src.path().join("node_modules")).unwrap(); + std::fs::write(src.path().join("package.json"), "{}").unwrap(); + std::fs::write( + src.path().join(".alien-build/__alien_bootstrap.ts"), + "generated", + ) + .unwrap(); + std::fs::write( + src.path().join(".18ba89dff9ff58bf-00000000.bun-build"), + "generated", + ) + .unwrap(); + std::fs::write(src.path().join("node_modules/module.js"), "dependency").unwrap(); + + let mut files = Vec::new(); + collect_source_files(src.path(), src.path(), &mut files).unwrap(); + files.sort(); + + assert_eq!(files, vec![PathBuf::from("package.json")]); +} + +fn docker_available() -> bool { + Command::new("docker") + .arg("info") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// True if a real OCI registry answers at `base/v2/` (200 or 401). Used to gate the +/// multi-arch push test. Run one with: `docker run -d -p 5050:5000 registry:2`. +async fn registry_available(base: &str) -> bool { + match reqwest::get(format!("{base}/v2/")).await { + Ok(resp) => resp.status().is_success() || resp.status().as_u16() == 401, + Err(_) => false, + } +} + +fn test_container(name: &str, image: String) -> Container { + Container::new(name.to_string()) + .code(ContainerCode::Image { image }) + .cpu(alien_core::ResourceSpec { + min: "0.5".to_string(), + desired: "1".to_string(), + }) + .memory(alien_core::ResourceSpec { + min: "512Mi".to_string(), + desired: "1Gi".to_string(), + }) + .permissions("container-execution".to_string()) + .build() +} + +#[test] +fn retryable_image_pull_detects_oci_server_errors() { + let error = dockdash::Error::ImagePull { + image_ref: "ghcr.io/example/base:tag".to_string(), + message: "Failed to pull layer blob sha256:abc".to_string(), + source: Some(Box::new( + oci_client::errors::OciDistributionError::ServerError { + code: 502, + url: "https://ghcr.io/v2/example/base/blobs/sha256:abc".to_string(), + message: "Bad Gateway".to_string(), + }, + )), + }; + + assert!(is_retryable_dockdash_image_pull_error(&error)); +} + +#[test] +fn retryable_image_pull_detects_opaque_transport_errors() { + let error = dockdash::Error::ImagePull { + image_ref: "ghcr.io/example/base:tag".to_string(), + message: "Failed to pull layer blob sha256:abc".to_string(), + source: Some(Box::new(std::io::Error::other( + "error sending request for url (https://ghcr.io/v2/example/base/blobs/sha256:abc): client error (SendRequest): connection error", + ))), + }; + + assert!(is_retryable_dockdash_image_pull_error(&error)); +} + +#[test] +fn retryable_image_pull_rejects_auth_and_not_found_errors() { + let auth_error = dockdash::Error::ImagePull { + image_ref: "ghcr.io/example/base:tag".to_string(), + message: "Failed to pull layer blob sha256:abc".to_string(), + source: Some(Box::new( + oci_client::errors::OciDistributionError::UnauthorizedError { + url: "https://ghcr.io/v2/example/base/blobs/sha256:abc".to_string(), + }, + )), + }; + let missing_error = dockdash::Error::ImagePull { + image_ref: "ghcr.io/example/base:tag".to_string(), + message: "Failed to pull manifest".to_string(), + source: Some(Box::new( + oci_client::errors::OciDistributionError::ImageManifestNotFoundError( + "ghcr.io/example/base:tag".to_string(), + ), + )), + }; + + assert!(!is_retryable_dockdash_image_pull_error(&auth_error)); + assert!(!is_retryable_dockdash_image_pull_error(&missing_error)); +} + +#[test] +fn oci_tarball_target_maps_runtime_platform_ids() { + assert_eq!( + oci_tarball_target(Path::new("/x/linux-aarch64.oci.tar")), + Some(BinaryTarget::LinuxArm64) + ); + assert_eq!( + oci_tarball_target(Path::new("linux-x64.oci.tar")), + Some(BinaryTarget::LinuxX64) + ); + assert_eq!(oci_tarball_target(Path::new("stack.json")), None); + assert_eq!(oci_tarball_target(Path::new("linux-arm64.oci.tar")), None); // CLI spelling, not a tarball name +} + +#[test] +fn select_linux_tarballs_keeps_only_linux_sorted() { + let files = vec![ + PathBuf::from("/b/windows-x64.oci.tar"), + PathBuf::from("/b/linux-x64.oci.tar"), + PathBuf::from("/b/darwin-aarch64.oci.tar"), + PathBuf::from("/b/linux-aarch64.oci.tar"), + ]; + let selected = select_linux_tarballs(&files); + assert_eq!( + selected.iter().map(|(t, _)| *t).collect::>(), + vec![BinaryTarget::LinuxArm64, BinaryTarget::LinuxX64], // sorted by runtime id: linux-aarch64 < linux-x64 + ); +} + +#[test] +fn assemble_image_index_sets_oci_index_shape() { + let entry = image_index_entry( + BinaryTarget::LinuxArm64, + "sha256:abc".to_string(), + 123, + OCI_IMAGE_MEDIA_TYPE.to_string(), + ); + let platform = entry.platform.as_ref().unwrap(); + assert_eq!(platform.architecture, "arm64"); + assert_eq!(platform.os, "linux"); + + let index = assemble_image_index(vec![entry]); + assert_eq!(index.schema_version, 2); + assert_eq!( + index.media_type.as_deref(), + Some(OCI_IMAGE_INDEX_MEDIA_TYPE) + ); + assert_eq!(index.manifests.len(), 1); + assert_eq!(index.manifests[0].digest, "sha256:abc"); + assert_eq!(index.manifests[0].size, 123); +} + +#[test] +fn manifest_media_type_reads_field_or_none() { + assert_eq!( + manifest_media_type(br#"{"mediaType":"application/vnd.oci.image.manifest.v1+json"}"#), + Some("application/vnd.oci.image.manifest.v1+json".to_string()) + ); + assert_eq!(manifest_media_type(br#"{"schemaVersion":2}"#), None); + assert_eq!(manifest_media_type(b"not json"), None); +} + +#[test] +fn collect_push_targets_groups_resources_that_share_local_image_directory() { + let temp_root = tempdir().unwrap(); + let shared_dir = temp_root.path().join("shared-image"); + let unique_dir = temp_root.path().join("unique-image"); + std::fs::create_dir_all(&shared_dir).unwrap(); + std::fs::create_dir_all(&unique_dir).unwrap(); + + let shared_image = shared_dir.to_string_lossy().into_owned(); + let unique_image = unique_dir.to_string_lossy().into_owned(); + + let messaging_gateway = test_container("messaging-gateway", shared_image.clone()); + let billing_worker = test_container("billing-worker", shared_image); + let postgres = test_container("postgres", unique_image); + let remote = test_container("remote", "registry.example.com/remote:latest".to_string()); + + let mut stack = Stack::new("push-dedupe".to_string()) + .add(messaging_gateway, alien_core::ResourceLifecycle::Frozen) + .add(billing_worker, alien_core::ResourceLifecycle::Frozen) + .add(postgres, alien_core::ResourceLifecycle::Frozen) + .add(remote, alien_core::ResourceLifecycle::Frozen) + .build(); + + let targets = collect_push_targets(&stack).unwrap(); + + assert_eq!(targets.len(), 2); + assert_eq!( + targets[0].resource_names, + vec![ + "messaging-gateway".to_string(), + "billing-worker".to_string() + ] + ); + assert_eq!( + targets[0].resource_ids, + vec![ + "messaging-gateway".to_string(), + "billing-worker".to_string() + ] + ); + assert_eq!(targets[0].resource_type, "container"); + assert_eq!(targets[0].local_image_dir, shared_dir); + assert_eq!(targets[1].resource_names, vec!["postgres".to_string()]); + + let mut updates = targets[0].push_result_updates("registry.example.com/shared:tag".into()); + updates.extend(targets[1].push_result_updates("registry.example.com/postgres:tag".into())); + apply_pushed_images(&mut stack, updates); + + let images = stack + .resources() + .filter_map(|(id, entry)| { + entry + .config + .downcast_ref::() + .and_then(|container| match &container.code { + ContainerCode::Image { image } => Some((id.clone(), image.clone())), + ContainerCode::Source { .. } => None, + }) + }) + .collect::>(); + + assert_eq!( + images.get("messaging-gateway").unwrap(), + "registry.example.com/shared:tag" + ); + assert_eq!( + images.get("billing-worker").unwrap(), + "registry.example.com/shared:tag" + ); + assert_eq!( + images.get("postgres").unwrap(), + "registry.example.com/postgres:tag" + ); + assert_eq!( + images.get("remote").unwrap(), + "registry.example.com/remote:latest" + ); +} + +#[test] +fn collect_push_targets_handles_daemons_like_other_compute() { + let temp_root = tempdir().unwrap(); + let daemon_dir = temp_root.path().join("daemon-image"); + std::fs::create_dir_all(&daemon_dir).unwrap(); + + let local_daemon = Daemon::new("agent".to_string()) + .permissions("execution".to_string()) + .code(DaemonCode::Image { + image: daemon_dir.to_string_lossy().into_owned(), + }) + .build(); + let remote_daemon = Daemon::new("collector".to_string()) + .permissions("execution".to_string()) + .code(DaemonCode::Image { + image: "registry.example.com/collector:latest".to_string(), + }) + .build(); + + let mut stack = Stack::new("daemon-push".to_string()) + .add(local_daemon, alien_core::ResourceLifecycle::Live) + .add(remote_daemon, alien_core::ResourceLifecycle::Live) + .build(); + + let targets = collect_push_targets(&stack).unwrap(); + assert_eq!( + targets.len(), + 1, + "only the local-dir daemon is queued for push" + ); + assert_eq!(targets[0].resource_names, vec!["agent".to_string()]); + assert_eq!(targets[0].resource_type, "daemon"); + assert_eq!(targets[0].local_image_dir, daemon_dir); + + let updates = targets[0].push_result_updates("registry.example.com/agent:tag".into()); + apply_pushed_images(&mut stack, updates); + let agent = stack + .resources() + .find(|(id, _)| *id == "agent") + .and_then(|(_, e)| e.config.downcast_ref::().cloned()) + .expect("agent daemon should exist"); + assert_eq!( + agent.code, + DaemonCode::Image { + image: "registry.example.com/agent:tag".to_string() + } + ); + + // An unbuilt source daemon fails fast, same as workers and containers. + let source_daemon = Daemon::new("raw".to_string()) + .permissions("execution".to_string()) + .code(DaemonCode::Source { + src: ".".to_string(), + toolchain: ToolchainConfig::Rust { + binary_name: "raw".to_string(), + }, + }) + .build(); + let source_stack = Stack::new("daemon-source".to_string()) + .add(source_daemon, alien_core::ResourceLifecycle::Live) + .build(); + let error = match collect_push_targets(&source_stack) { + Err(error) => error, + Ok(_) => panic!("source daemon must be rejected"), + }; + assert!(error.to_string().contains("Run 'alien build' first")); +} + +#[tokio::test] +async fn test_pull_and_export_alpine() { + if !docker_available() { + eprintln!("Skipping test_pull_and_export_alpine: docker not available"); + return; + } + + tracing_subscriber::fmt::try_init().ok(); + + let build_dir = tempdir().unwrap(); + let settings = BuildSettings { + output_directory: build_dir.path().to_str().unwrap().to_string(), + platform: PlatformBuildSettings::Test {}, + targets: Some(vec![BinaryTarget::LinuxX64]), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + + // Pull alpine:latest (small, always available) + let result = pull_and_export_image( + "alpine:latest", + "test-alpine", + "test-stack", + &settings, + build_dir.path(), + ) + .await; + + assert!( + result.is_ok(), + "Should successfully pull and export alpine:latest" + ); + + let image_dir = result.unwrap(); + let image_path = PathBuf::from(&image_dir); + + // Verify directory exists and has content hash + assert!(image_path.exists(), "Image directory should exist"); + assert!( + image_path + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("test-alpine-"), + "Directory should have content hash suffix" + ); + + // Verify OCI tarball was created + let tarball_path = image_path.join("linux-x64.oci.tar"); + assert!(tarball_path.exists(), "OCI tarball should exist"); + + // Verify tarball is valid OCI format + let image = Image::from_tarball(&tarball_path).expect("OCI tarball should be valid"); + + let metadata = image + .get_metadata() + .expect("Should be able to read image metadata"); + + // Alpine has a CMD + assert!( + metadata.cmd.is_some() || metadata.entrypoint.is_some(), + "Alpine image should have entrypoint or cmd" + ); +} + +#[tokio::test] +async fn test_pull_nonexistent_image_fails() { + if !docker_available() { + eprintln!("Skipping test_pull_nonexistent_image_fails: docker not available"); + return; + } + + tracing_subscriber::fmt::try_init().ok(); + + let build_dir = tempdir().unwrap(); + let settings = BuildSettings { + output_directory: build_dir.path().to_str().unwrap().to_string(), + platform: PlatformBuildSettings::Test {}, + targets: Some(vec![BinaryTarget::LinuxX64]), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + + // Try to pull non-existent image + let result = pull_and_export_image( + "this-image-definitely-does-not-exist-xyz123:nonexistent", + "test-nonexistent", + "test-stack", + &settings, + build_dir.path(), + ) + .await; + + // Should fail with docker pull error + assert!(result.is_err(), "Should fail for non-existent image"); + let err = result.unwrap_err(); + let err_str = err.to_string(); + assert!( + err_str.contains("docker pull failed") || err_str.contains("not found"), + "Error should mention docker pull failure: {}", + err_str + ); +} + +#[tokio::test] +async fn test_pull_and_export_produces_hash() { + if !docker_available() { + eprintln!("Skipping test_pull_and_export_produces_hash: docker not available"); + return; + } + + tracing_subscriber::fmt::try_init().ok(); + + let build_dir = tempdir().unwrap(); + let settings = BuildSettings { + output_directory: build_dir.path().to_str().unwrap().to_string(), + platform: PlatformBuildSettings::Test {}, + targets: Some(vec![BinaryTarget::LinuxX64]), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + + // Pull alpine image + let result = pull_and_export_image( + "alpine:latest", + "test-alpine", + "test-stack", + &settings, + build_dir.path(), + ) + .await + .expect("Pull should succeed"); + + // Verify directory name has hash suffix + let path = PathBuf::from(&result); + let dir_name = path.file_name().unwrap().to_str().unwrap(); + + // Should be in format: test-alpine-XXXXXXXX (8 char hash) + assert!( + dir_name.starts_with("test-alpine-"), + "Should have container name prefix" + ); + + let hash_part = dir_name.strip_prefix("test-alpine-").unwrap(); + assert_eq!(hash_part.len(), 8, "Hash should be 8 characters"); + assert!( + hash_part.chars().all(|c| c.is_ascii_hexdigit()), + "Hash should be hexadecimal" + ); + + // Verify hash is based on tarball content + // (Pulling same tag multiple times might get different content if image updated, + // which is exactly why we hash - to detect changes!) + let tarball_path = path.join("linux-x64.oci.tar"); + assert!(tarball_path.exists(), "Tarball should exist"); +} + +#[tokio::test] +async fn source_artifact_cache_key_is_shared_for_equivalent_cloud_builds() { + let src_dir = tempdir().unwrap(); + std::fs::create_dir_all(src_dir.path().join("src")).unwrap(); + std::fs::write( + src_dir.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write(src_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); + + let toolchain = ToolchainConfig::Rust { + binary_name: "app".to_string(), + }; + let targets = vec![BinaryTarget::LinuxX64]; + let gcp = BuildSettings { + output_directory: src_dir.path().join("out").to_string_lossy().into_owned(), + platform: PlatformBuildSettings::Gcp {}, + targets: Some(targets.clone()), + cache_url: None, + override_base_image: Some("registry.example.com/base:tag".to_string()), + debug_mode: false, + }; + let azure = BuildSettings { + platform: PlatformBuildSettings::Azure {}, + override_base_image: Some("registry.example.com/base:other-tag".to_string()), + ..gcp.clone() + }; + + let gcp_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &gcp, + &targets, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + let azure_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &azure, + &targets, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + + assert_eq!( + gcp_key, azure_key, + "direct workloads must ignore the Worker runtime-base override" + ); + let gcp_daemon_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &gcp, + &targets, + crate::toolchain::WorkloadKind::Daemon, + ) + .await + .unwrap(); + let azure_daemon_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &azure, + &targets, + crate::toolchain::WorkloadKind::Daemon, + ) + .await + .unwrap(); + assert_eq!( + gcp_daemon_key, azure_daemon_key, + "Daemon artifacts must ignore the Worker runtime-base override" + ); + + let gcp_worker_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &gcp, + &targets, + crate::toolchain::WorkloadKind::Worker, + ) + .await + .unwrap(); + let azure_worker_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &azure, + &targets, + crate::toolchain::WorkloadKind::Worker, + ) + .await + .unwrap(); + assert_ne!( + gcp_worker_key, azure_worker_key, + "Worker artifacts must include their runtime base in the cache key" + ); + + let docker_toolchain = ToolchainConfig::Docker { + dockerfile: None, + build_args: None, + target: None, + }; + let gcp_docker_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &docker_toolchain, + &gcp, + &targets, + crate::toolchain::WorkloadKind::Worker, + ) + .await + .unwrap(); + let azure_docker_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &docker_toolchain, + &azure, + &targets, + crate::toolchain::WorkloadKind::Worker, + ) + .await + .unwrap(); + assert_eq!( + gcp_docker_key, azure_docker_key, + "Dockerfile builds own their base and must ignore the source Worker override" + ); + + let local_a = BuildSettings { + platform: PlatformBuildSettings::Local {}, + ..gcp + }; + let local_b = BuildSettings { + platform: PlatformBuildSettings::Local {}, + ..azure + }; + let local_a_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &local_a, + &targets, + crate::toolchain::WorkloadKind::Worker, + ) + .await + .unwrap(); + let local_b_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &local_b, + &targets, + crate::toolchain::WorkloadKind::Worker, + ) + .await + .unwrap(); + assert_eq!( + local_a_key, local_b_key, + "Local Workers run from scratch and must ignore the cloud runtime base" + ); +} + +#[tokio::test] +async fn rust_source_artifact_cache_key_includes_local_path_dependencies() { + let workspace_dir = tempdir().unwrap(); + let app_dir = workspace_dir.path().join("app"); + let dep_dir = workspace_dir.path().join("dep"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::create_dir_all(dep_dir.join("src")).unwrap(); + std::fs::write( + workspace_dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\", \"dep\"]\nresolver = \"2\"\n", + ) + .unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ndep = { path = \"../dep\" }\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() { dep::value(); }\n").unwrap(); + std::fs::write( + dep_dir.join("Cargo.toml"), + "[package]\nname = \"dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write(dep_dir.join("src/lib.rs"), "pub fn value() -> u32 { 1 }\n").unwrap(); + + let toolchain = ToolchainConfig::Rust { + binary_name: "app".to_string(), + }; + let targets = vec![BinaryTarget::LinuxX64]; + let settings = BuildSettings { + output_directory: workspace_dir + .path() + .join("out") + .to_string_lossy() + .into_owned(), + platform: PlatformBuildSettings::Gcp {}, + targets: Some(targets.clone()), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + + let first_key = compute_source_artifact_cache_key( + app_dir.to_str().unwrap(), + &toolchain, + &settings, + &targets, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + + std::fs::write(dep_dir.join("src/lib.rs"), "pub fn value() -> u32 { 2 }\n").unwrap(); + + let second_key = compute_source_artifact_cache_key( + app_dir.to_str().unwrap(), + &toolchain, + &settings, + &targets, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + + assert_ne!(first_key, second_key); +} + +#[tokio::test] +async fn rust_source_artifact_cache_key_includes_workspace_toolchain_files() { + // Toolchain files live at the workspace root, not inside the member's + // package directory, so this must use a real `[workspace]` layout — + // otherwise package_dir == workspace_root and hash_source_directory + // picks the files up as ordinary source, masking a broken/deleted + // workspace-root hashing loop. + let workspace_dir = tempdir().unwrap(); + let app_dir = workspace_dir.path().join("app"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::write( + workspace_dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n", + ) + .unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let toolchain = ToolchainConfig::Rust { + binary_name: "app".to_string(), + }; + let targets = vec![BinaryTarget::LinuxX64]; + let settings = BuildSettings { + output_directory: workspace_dir + .path() + .join("out") + .to_string_lossy() + .into_owned(), + platform: PlatformBuildSettings::Gcp {}, + targets: Some(targets.clone()), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + + let key = |dir: &Path| { + let dir = dir.to_str().unwrap().to_string(); + let toolchain = toolchain.clone(); + let settings = settings.clone(); + let targets = targets.clone(); + async move { + compute_source_artifact_cache_key( + &dir, + &toolchain, + &settings, + &targets, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap() + } + }; + + let without_toolchain_file = key(&app_dir).await; + + std::fs::write( + workspace_dir.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"1.84.0\"\n", + ) + .unwrap(); + let with_pinned_toolchain = key(&app_dir).await; + assert_ne!( + without_toolchain_file, with_pinned_toolchain, + "pinning the compiler via a workspace-root rust-toolchain.toml must invalidate the artifact cache key" + ); + + std::fs::write( + workspace_dir.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"1.85.0\"\n", + ) + .unwrap(); + let with_changed_toolchain = key(&app_dir).await; + assert_ne!( + with_pinned_toolchain, with_changed_toolchain, + "changing the content of the workspace-root rust-toolchain.toml must invalidate the artifact cache key" + ); + + std::fs::create_dir_all(workspace_dir.path().join(".cargo")).unwrap(); + std::fs::write( + workspace_dir.path().join(".cargo/config.toml"), + "[build]\nrustflags = [\"-C\", \"target-cpu=native\"]\n", + ) + .unwrap(); + let with_cargo_config = key(&app_dir).await; + assert_ne!( + with_changed_toolchain, with_cargo_config, + "changing rustflags via workspace-root .cargo/config.toml must invalidate the artifact cache key" + ); +} + +#[tokio::test] +async fn source_artifact_cache_key_differs_across_target_triples() { + let src_dir = tempdir().unwrap(); + std::fs::create_dir_all(src_dir.path().join("src")).unwrap(); + std::fs::write( + src_dir.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write(src_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); + + let toolchain = ToolchainConfig::Rust { + binary_name: "app".to_string(), + }; + let key_for = |targets: Vec| { + let dir = src_dir.path().to_str().unwrap().to_string(); + let out = src_dir.path().join("out").to_string_lossy().into_owned(); + let toolchain = toolchain.clone(); + async move { + let settings = BuildSettings { + output_directory: out, + platform: PlatformBuildSettings::Gcp {}, + targets: Some(targets.clone()), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + compute_source_artifact_cache_key( + &dir, + &toolchain, + &settings, + &targets, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap() + } + }; + + let x64_key = key_for(vec![BinaryTarget::LinuxX64]).await; + let arm64_key = key_for(vec![BinaryTarget::LinuxArm64]).await; + assert_ne!( + x64_key, arm64_key, + "different target triples must not share build artifacts" + ); +} + +/// Reuse invariant, end to end at the cache layer: after one platform's build +/// produces artifacts, an equivalent-target build for another platform finds +/// them (one build total), while a build for a different triple misses even +/// though the tarball file exists (two builds total). +#[tokio::test] +async fn equivalent_platform_build_reuses_artifact_but_differing_triple_rebuilds() { + let src_dir = tempdir().unwrap(); + std::fs::create_dir_all(src_dir.path().join("src")).unwrap(); + std::fs::write( + src_dir.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write(src_dir.path().join("src/main.rs"), "fn main() {}\n").unwrap(); + + let toolchain = ToolchainConfig::Rust { + binary_name: "app".to_string(), + }; + let out_root = tempdir().unwrap(); + let settings_for = |platform: PlatformBuildSettings, targets: &[BinaryTarget]| BuildSettings { + output_directory: out_root.path().to_string_lossy().into_owned(), + platform, + targets: Some(targets.to_vec()), + cache_url: None, + override_base_image: None, + debug_mode: false, + }; + let x64 = vec![BinaryTarget::LinuxX64]; + let arm64 = vec![BinaryTarget::LinuxArm64]; + + // "First build" (gcp, linux-x64): produce the hashed artifact directory + // exactly as build_resource finalizes it. + let gcp_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &settings_for(PlatformBuildSettings::Gcp {}, &x64), + &x64, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + let gcp_dir = out_root.path().join("build").join("gcp"); + let artifact_dir = gcp_dir.join("app-12345678"); + fs::create_dir_all(&artifact_dir).await.unwrap(); + fs::write(artifact_dir.join("linux-x64.oci.tar"), b"oci") + .await + .unwrap(); + // Also stage an arm64 tarball so the differing-triple case below is + // decided by the cache key, not by a missing target file. + fs::write(artifact_dir.join("linux-arm64.oci.tar"), b"oci") + .await + .unwrap(); + write_artifact_cache_metadata(&artifact_dir, &gcp_key) + .await + .unwrap(); + + // "Second build" (azure, same source, same linux-x64 target): the key + // matches and the sibling-platform lookup finds the gcp artifacts, so + // no second compile happens. + let azure_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &settings_for(PlatformBuildSettings::Azure {}, &x64), + &x64, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + assert_eq!(gcp_key, azure_key, "equivalent platforms must share keys"); + + let azure_dir = out_root.path().join("build").join("azure"); + fs::create_dir_all(&azure_dir).await.unwrap(); + let reused = find_cached_artifact_dir(&azure_dir, "app", &x64, &azure_key) + .await + .unwrap(); + assert_eq!( + reused, + Some(artifact_dir.clone()), + "same inputs + equivalent targets must reuse the one built artifact" + ); + + // "Third build" (aws, linux-arm64): the tarball file exists, but the + // key differs, so the lookup misses and a real build would run. + let aws_key = compute_source_artifact_cache_key( + src_dir.path().to_str().unwrap(), + &toolchain, + &settings_for( + PlatformBuildSettings::Aws { + managing_account_id: None, + }, + &arm64, + ), + &arm64, + crate::toolchain::WorkloadKind::Container, + ) + .await + .unwrap(); + assert_ne!(gcp_key, aws_key); + + let aws_dir = out_root.path().join("build").join("aws"); + fs::create_dir_all(&aws_dir).await.unwrap(); + let miss = find_cached_artifact_dir(&aws_dir, "app", &arm64, &aws_key) + .await + .unwrap(); + assert_eq!(miss, None, "a differing triple must trigger its own build"); +} + +#[tokio::test] +async fn artifact_cache_lookup_reuses_sibling_platform_directory() { + let temp_root = tempdir().unwrap(); + let build_root = temp_root.path().join("build"); + let gcp_dir = build_root.join("gcp"); + let azure_dir = build_root.join("azure"); + let cached_dir = gcp_dir.join("alien-manager-abcdef12"); + + fs::create_dir_all(&cached_dir).await.unwrap(); + fs::create_dir_all(&azure_dir).await.unwrap(); + fs::write(cached_dir.join("linux-x64.oci.tar"), b"oci") + .await + .unwrap(); + write_artifact_cache_metadata(&cached_dir, "cache-key") + .await + .unwrap(); + + let found = find_cached_artifact_dir( + &azure_dir, + "alien-manager", + &[BinaryTarget::LinuxX64], + "cache-key", + ) + .await + .unwrap(); + + assert_eq!(found, Some(cached_dir)); +} + +#[tokio::test] +async fn finalize_artifact_dir_reuses_existing_final_directory() { + let temp_root = tempdir().unwrap(); + let temp_dir = temp_root.path().join(".agent-tmp-1234"); + let final_dir = temp_root.path().join("agent-abcdef12"); + + fs::create_dir_all(&temp_dir).await.unwrap(); + fs::write(temp_dir.join("linux-x64.oci.tar"), b"new-build") + .await + .unwrap(); + + fs::create_dir_all(&final_dir).await.unwrap(); + fs::write(final_dir.join("linux-x64.oci.tar"), b"existing-build") + .await + .unwrap(); + + let resolved = finalize_artifact_dir(&temp_dir, &final_dir, "build") + .await + .unwrap(); + + assert_eq!(resolved, final_dir.to_string_lossy()); + assert!(final_dir.exists()); + assert!(!temp_dir.exists()); + assert_eq!( + fs::read(final_dir.join("linux-x64.oci.tar")).await.unwrap(), + b"existing-build" + ); +} + +#[test] +fn temp_artifact_dir_is_hidden_and_unique() { + let build_output_dir = PathBuf::from("/tmp/build-output"); + + let first = temp_artifact_dir(&build_output_dir, "agent"); + let second = temp_artifact_dir(&build_output_dir, "agent"); + + assert_ne!(first, second); + assert_eq!(first.parent().unwrap(), build_output_dir.as_path()); + assert!(first + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".agent-tmp-")); + assert!(second + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".agent-tmp-")); +} + +/// End-to-end: build two arches into one resource dir, push, and assert the pushed tag +/// resolves to a real multi-arch manifest list (not a single overwritten arch). +/// Gated on docker + a local registry (`docker run -d -p 5050:5000 registry:2`). +#[tokio::test] +async fn multiarch_push_produces_manifest_list() { + use crate::toolchain::{docker::DockerToolchain, Toolchain, ToolchainContext}; + + const REGISTRY: &str = "localhost:5050"; + if !docker_available() { + eprintln!("Skipping multiarch_push_produces_manifest_list: docker not available"); + return; + } + if !registry_available(&format!("http://{REGISTRY}")).await { + eprintln!( + "Skipping multiarch_push_produces_manifest_list: no registry at {REGISTRY} (run: docker run -d -p 5050:5000 registry:2)" + ); + return; + } + + let src = tempfile::tempdir().unwrap(); + let build_dir = tempfile::tempdir().unwrap(); + std::fs::write( + src.path().join("Dockerfile"), + "FROM alpine:latest\nCMD [\"echo\", \"hi\"]\n", + ) + .unwrap(); + + // Build both linux arches into the same resource dir. + for target in [BinaryTarget::LinuxArm64, BinaryTarget::LinuxX64] { + let toolchain = DockerToolchain { + dockerfile: None, + build_args: None, + target: None, + }; + let context = ToolchainContext { + src_dir: src.path().to_path_buf(), + build_dir: build_dir.path().to_path_buf(), + cache_store: None, + cache_prefix: "test".to_string(), + build_target: target, + runtime_platform_name: "aws".to_string(), + debug_mode: false, + workload: crate::toolchain::WorkloadKind::Container, + }; + toolchain + .build(&context) + .await + .expect("docker build should succeed"); + } + assert!(build_dir.path().join("linux-aarch64.oci.tar").exists()); + assert!(build_dir.path().join("linux-x64.oci.tar").exists()); + + let container = Container::new("web".to_string()) + .code(ContainerCode::Image { + image: build_dir.path().to_string_lossy().into_owned(), + }) + .cpu(alien_core::ResourceSpec { + min: "0.5".to_string(), + desired: "1".to_string(), + }) + .memory(alien_core::ResourceSpec { + min: "512Mi".to_string(), + desired: "1Gi".to_string(), + }) + .permissions("web".to_string()) + .build(); + let stack = Stack::new("multiarch-test".to_string()) + .add(container, alien_core::ResourceLifecycle::Live) + .build(); + + let push_settings = PushSettings { + repository: format!("{REGISTRY}/alien-multiarch-test"), + destination_label: None, + options: dockdash::PushOptions { + auth: dockdash::RegistryAuth::Anonymous, + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }, + }; + + let pushed = push_stack(stack, Platform::Aws, &push_settings) + .await + .expect("push should succeed"); + + let image_uri = pushed + .resources() + .filter_map(|(_, entry)| entry.config.downcast_ref::()) + .find_map(|c| match &c.code { + ContainerCode::Image { image } => Some(image.clone()), + _ => None, + }) + .expect("container should carry a pushed image URI"); + assert!( + image_uri.contains(REGISTRY), + "expected a registry URI, got {image_uri}" + ); + + // The pushed tag must resolve to an image index with both linux arches. + let client = OciClient::new(OciClientConfig { + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }); + let reference = Reference::try_from(image_uri.as_str()).unwrap(); + let (bytes, _digest) = client + .pull_manifest_raw( + &reference, + &dockdash::RegistryAuth::Anonymous, + &[ + OCI_IMAGE_INDEX_MEDIA_TYPE, + "application/vnd.docker.distribution.manifest.list.v2+json", + ], + ) + .await + .expect("should pull a manifest list"); + let index: OciImageIndex = + serde_json::from_slice(&bytes).expect("pushed tag should be an image index"); + let mut platforms: Vec<(String, String)> = index + .manifests + .iter() + .filter_map(|m| { + m.platform + .as_ref() + .map(|p| (p.os.clone(), p.architecture.clone())) + }) + .collect(); + platforms.sort(); + assert_eq!( + platforms, + vec![ + ("linux".to_string(), "amd64".to_string()), + ("linux".to_string(), "arm64".to_string()), + ], + "pushed tag must be a real multi-arch index" + ); +} + +/// End-to-end: build a single arch into a resource dir, push, and assert the pushed tag +/// resolves to a plain image manifest (not an index). This is the path every current +/// single-platform release (aws/gcp/azure) takes, so the direct branch must stay intact. +/// Gated on docker + a local registry (`docker run -d -p 5050:5000 registry:2`). +#[tokio::test] +async fn singlearch_push_produces_single_manifest() { + use crate::toolchain::{docker::DockerToolchain, Toolchain, ToolchainContext}; + + const REGISTRY: &str = "localhost:5050"; + if !docker_available() { + eprintln!("Skipping singlearch_push_produces_single_manifest: docker not available"); + return; + } + if !registry_available(&format!("http://{REGISTRY}")).await { + eprintln!( + "Skipping singlearch_push_produces_single_manifest: no registry at {REGISTRY} (run: docker run -d -p 5050:5000 registry:2)" + ); + return; + } + + let src = tempfile::tempdir().unwrap(); + let build_dir = tempfile::tempdir().unwrap(); + std::fs::write( + src.path().join("Dockerfile"), + "FROM alpine:latest\nCMD [\"echo\", \"hi\"]\n", + ) + .unwrap(); + + // Build a single linux arch into the resource dir. + let toolchain = DockerToolchain { + dockerfile: None, + build_args: None, + target: None, + }; + let context = ToolchainContext { + src_dir: src.path().to_path_buf(), + build_dir: build_dir.path().to_path_buf(), + cache_store: None, + cache_prefix: "test".to_string(), + build_target: BinaryTarget::LinuxArm64, + runtime_platform_name: "aws".to_string(), + debug_mode: false, + workload: crate::toolchain::WorkloadKind::Container, + }; + toolchain + .build(&context) + .await + .expect("docker build should succeed"); + assert!(build_dir.path().join("linux-aarch64.oci.tar").exists()); + + let container = Container::new("web".to_string()) + .code(ContainerCode::Image { + image: build_dir.path().to_string_lossy().into_owned(), + }) + .cpu(alien_core::ResourceSpec { + min: "0.5".to_string(), + desired: "1".to_string(), + }) + .memory(alien_core::ResourceSpec { + min: "512Mi".to_string(), + desired: "1Gi".to_string(), + }) + .permissions("web".to_string()) + .build(); + let stack = Stack::new("singlearch-test".to_string()) + .add(container, alien_core::ResourceLifecycle::Live) + .build(); + + let push_settings = PushSettings { + repository: format!("{REGISTRY}/alien-singlearch-test"), + destination_label: None, + options: dockdash::PushOptions { + auth: dockdash::RegistryAuth::Anonymous, + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }, + }; + + let pushed = push_stack(stack, Platform::Aws, &push_settings) + .await + .expect("push should succeed"); + + let image_uri = pushed + .resources() + .filter_map(|(_, entry)| entry.config.downcast_ref::()) + .find_map(|c| match &c.code { + ContainerCode::Image { image } => Some(image.clone()), + _ => None, + }) + .expect("container should carry a pushed image URI"); + assert!( + image_uri.contains(REGISTRY), + "expected a registry URI, got {image_uri}" + ); + + // The pushed tag must resolve to a plain image manifest, NOT an index: it has a + // `config` descriptor and no `manifests` array. + let client = OciClient::new(OciClientConfig { + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }); + let reference = Reference::try_from(image_uri.as_str()).unwrap(); + let (bytes, _digest) = client + .pull_manifest_raw( + &reference, + &dockdash::RegistryAuth::Anonymous, + &[OCI_IMAGE_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE], + ) + .await + .expect("should pull a manifest"); + let value: serde_json::Value = + serde_json::from_slice(&bytes).expect("pushed tag should be valid JSON"); + assert!( + value.get("config").is_some(), + "single-arch push must produce an image manifest with a config descriptor, got: {value}" + ); + assert!( + value.get("manifests").is_none(), + "single-arch push must not produce a manifest index, got: {value}" + ); +} + +/// End-to-end seam: build two arches into two separate partial outputs (one per native +/// runner), run `merge_build_outputs` to combine them, load the merged stack exactly as +/// the release path does (deserialize stack.json), then push — asserting the merged dir +/// resolves to a real multi-arch index. This exercises the merge→load→push chain as one +/// flow, not as independent halves. Gated on docker + a local registry. +#[tokio::test] +async fn merge_then_push_produces_manifest_list() { + use crate::toolchain::{docker::DockerToolchain, Toolchain, ToolchainContext}; + + const REGISTRY: &str = "localhost:5050"; + if !docker_available() { + eprintln!("Skipping merge_then_push_produces_manifest_list: docker not available"); + return; + } + if !registry_available(&format!("http://{REGISTRY}")).await { + eprintln!( + "Skipping merge_then_push_produces_manifest_list: no registry at {REGISTRY} (run: docker run -d -p 5050:5000 registry:2)" + ); + return; + } + + let src = tempfile::tempdir().unwrap(); + let input_root = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + std::fs::write( + src.path().join("Dockerfile"), + "FROM alpine:latest\nCMD [\"echo\", \"hi\"]\n", + ) + .unwrap(); + + // Build each arch into its own partial: //build/aws//, + // with a stack.json whose code.image is that partial's absolute artifact dir — the + // exact shape a native-runner `alien build --output-dir` upload produces. + for (partial, target, dir_name) in [ + ("arm", BinaryTarget::LinuxArm64, "web-aaaa1111"), + ("x64", BinaryTarget::LinuxX64, "web-bbbb2222"), + ] { + let platform_dir = input_root.path().join(partial).join("build").join("aws"); + let artifact_dir = platform_dir.join(dir_name); + std::fs::create_dir_all(&artifact_dir).unwrap(); + + let toolchain = DockerToolchain { + dockerfile: None, + build_args: None, + target: None, + }; + let context = ToolchainContext { + src_dir: src.path().to_path_buf(), + build_dir: artifact_dir.clone(), + cache_store: None, + cache_prefix: "test".to_string(), + build_target: target, + runtime_platform_name: "aws".to_string(), + debug_mode: false, + workload: crate::toolchain::WorkloadKind::Container, + }; + toolchain + .build(&context) + .await + .expect("docker build should succeed"); + + let image = artifact_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + let container = Container::new("web".to_string()) + .code(ContainerCode::Image { image }) + .cpu(alien_core::ResourceSpec { + min: "0.5".to_string(), + desired: "1".to_string(), + }) + .memory(alien_core::ResourceSpec { + min: "512Mi".to_string(), + desired: "1Gi".to_string(), + }) + .permissions("web".to_string()) + .build(); + let stack = Stack::new("merge-push-test".to_string()) + .add(container, alien_core::ResourceLifecycle::Live) + .build(); + std::fs::write( + platform_dir.join("stack.json"), + serde_json::to_string_pretty(&stack).unwrap(), + ) + .unwrap(); + } + + // Merge the two partials into one .alien. + let platforms = crate::merge::merge_build_outputs(input_root.path(), out.path()) + .expect("merge should succeed"); + assert_eq!(platforms, vec!["aws".to_string()]); + + // Load the merged stack the way the release path does, then push it. + let merged_json = std::fs::read_to_string(out.path().join("build/aws/stack.json")).unwrap(); + let merged_stack: Stack = + serde_json::from_str(&merged_json).expect("merged stack.json should deserialize"); + + let push_settings = PushSettings { + repository: format!("{REGISTRY}/alien-merge-push-test"), + destination_label: None, + options: dockdash::PushOptions { + auth: dockdash::RegistryAuth::Anonymous, + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }, + }; + + let pushed = push_stack(merged_stack, Platform::Aws, &push_settings) + .await + .expect("push of the merged stack should succeed"); + + let image_uri = pushed + .resources() + .filter_map(|(_, entry)| entry.config.downcast_ref::()) + .find_map(|c| match &c.code { + ContainerCode::Image { image } => Some(image.clone()), + _ => None, + }) + .expect("container should carry a pushed image URI"); + + let client = OciClient::new(OciClientConfig { + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }); + let reference = Reference::try_from(image_uri.as_str()).unwrap(); + let (bytes, _digest) = client + .pull_manifest_raw( + &reference, + &dockdash::RegistryAuth::Anonymous, + &[ + OCI_IMAGE_INDEX_MEDIA_TYPE, + "application/vnd.docker.distribution.manifest.list.v2+json", + ], + ) + .await + .expect("should pull a manifest list"); + let index: OciImageIndex = + serde_json::from_slice(&bytes).expect("merged-then-pushed tag should be an image index"); + let mut platforms: Vec<(String, String)> = index + .manifests + .iter() + .filter_map(|m| { + m.platform + .as_ref() + .map(|p| (p.os.clone(), p.architecture.clone())) + }) + .collect(); + platforms.sort(); + assert_eq!( + platforms, + vec![ + ("linux".to_string(), "amd64".to_string()), + ("linux".to_string(), "arm64".to_string()), + ], + "merged stack must push as a real multi-arch index" + ); +} diff --git a/crates/alien-cloudformation/src/generator/expressions.rs b/crates/alien-cloudformation/src/generator/expressions.rs new file mode 100644 index 000000000..3f2d1e4ef --- /dev/null +++ b/crates/alien-cloudformation/src/generator/expressions.rs @@ -0,0 +1,379 @@ +//! CloudFormation expression builders and parameter/output constructors. +//! +//! Owns the deployment-settings expressions (kubernetes / network / domains / +//! management), the JSON <-> `CfExpression` bridge used to overlay serialized +//! settings, and the small `CfParameter` / `CfOutput` constructors shared by +//! the parameter and output builders. + +use super::{ + empty_object, CloudFormationTarget, CONDITION_HAS_DOMAIN_NAME, CONDITION_HAS_VPC_CIDR, + CONDITION_NETWORK_MODE_CREATE, CONDITION_NETWORK_MODE_USE_EXISTING, PARAM_AVAILABILITY_ZONES, + PARAM_CERTIFICATE_ARN, PARAM_DOMAIN_NAME, PARAM_MANAGING_ROLE_ARN, PARAM_PRIVATE_SUBNET_IDS, + PARAM_PUBLIC_SUBNET_IDS, PARAM_SECURITY_GROUP_IDS, PARAM_VPC_CIDR, PARAM_VPC_ID, +}; +use crate::template::{CfExpression, CfOutput, CfParameter}; +use alien_core::{HeartbeatsMode, KubernetesSettings, NetworkSettings, TelemetryMode, UpdatesMode}; +use serde_json::Value; + +pub(super) fn kubernetes_settings_expression( + settings: Option<&KubernetesSettings>, + namespace: Option, +) -> CfExpression { + let mut expression = default_kubernetes_settings_expression(); + + if let Some(settings) = settings { + let value = serde_json::to_value(settings) + .expect("serializing Kubernetes stack settings should not fail"); + merge_cf_expression(&mut expression, cf_expression_from_json(value)); + } + + if let Some(namespace) = namespace { + set_kubernetes_namespace_expression(&mut expression, namespace); + } + + expression +} + +fn default_kubernetes_settings_expression() -> CfExpression { + CfExpression::object([ + ( + "cluster", + CfExpression::object([ + ("ownership", CfExpression::from("managed")), + ("namespace", CfExpression::from("default")), + ]), + ), + ( + "exposure", + CfExpression::if_( + CONDITION_HAS_DOMAIN_NAME, + custom_domain_kubernetes_exposure_expression(), + generated_load_balancer_kubernetes_exposure_expression(), + ), + ), + ]) +} + +fn generated_load_balancer_kubernetes_exposure_expression() -> CfExpression { + CfExpression::object([ + ("mode", CfExpression::from("generated")), + ("route", aws_alb_kubernetes_route_expression()), + ( + "certificate", + CfExpression::object([("mode", CfExpression::from("none"))]), + ), + ]) +} + +fn custom_domain_kubernetes_exposure_expression() -> CfExpression { + CfExpression::object([ + ("mode", CfExpression::from("custom")), + ("domain", CfExpression::ref_(PARAM_DOMAIN_NAME)), + ("route", aws_alb_kubernetes_route_expression()), + ( + "certificate", + CfExpression::object([ + ("mode", CfExpression::from("awsAcmArn")), + ("certificateArn", CfExpression::ref_(PARAM_CERTIFICATE_ARN)), + ]), + ), + ]) +} + +fn aws_alb_kubernetes_route_expression() -> CfExpression { + CfExpression::object([ + ("routeApi", CfExpression::from("ingress")), + ("controller", CfExpression::from("eks.amazonaws.com/alb")), + ("ingressClassName", CfExpression::from("alb")), + ("labels", empty_object()), + ("annotations", empty_object()), + ( + "provider", + CfExpression::object([ + ("provider", CfExpression::from("awsAlb")), + ("scheme", CfExpression::from("internet-facing")), + ("targetType", CfExpression::from("ip")), + ("subnetIds", CfExpression::list([])), + ]), + ), + ]) +} + +fn set_kubernetes_namespace_expression(expression: &mut CfExpression, namespace: CfExpression) { + let CfExpression::Object(root) = expression else { + return; + }; + let cluster = root + .entry("cluster".to_string()) + .or_insert_with(|| CfExpression::object([("ownership", CfExpression::from("managed"))])); + let CfExpression::Object(cluster) = cluster else { + *cluster = CfExpression::object([ + ("ownership", CfExpression::from("managed")), + ("namespace", namespace), + ]); + return; + }; + cluster.insert("namespace".to_string(), namespace); +} + +pub(super) fn merge_cf_expression(base: &mut CfExpression, overlay: CfExpression) { + if is_cloudformation_intrinsic(base) || is_cloudformation_intrinsic(&overlay) { + *base = overlay; + return; + } + + match (base, overlay) { + (CfExpression::Object(base), CfExpression::Object(overlay)) => { + for (key, value) in overlay { + match base.get_mut(&key) { + Some(existing) => merge_cf_expression(existing, value), + None => { + base.insert(key, value); + } + } + } + } + (base, overlay) => *base = overlay, + } +} + +pub(super) fn is_cloudformation_intrinsic(expression: &CfExpression) -> bool { + let CfExpression::Object(values) = expression else { + return false; + }; + values + .keys() + .any(|key| key == "Ref" || key.starts_with("Fn::")) +} + +fn cf_expression_from_json(value: Value) -> CfExpression { + match value { + Value::Null => CfExpression::Null, + Value::Bool(value) => CfExpression::Bool(value), + Value::Number(number) => { + if let Some(value) = number.as_i64() { + CfExpression::Integer(value) + } else { + CfExpression::Number( + number + .as_f64() + .expect("serde_json numbers should be representable as f64"), + ) + } + } + Value::String(value) => CfExpression::String(value), + Value::Array(values) => { + CfExpression::List(values.into_iter().map(cf_expression_from_json).collect()) + } + Value::Object(values) => CfExpression::Object( + values + .into_iter() + .map(|(key, value)| (key, cf_expression_from_json(value))) + .collect(), + ), + } +} + +pub(super) fn network_expression(network: Option<&NetworkSettings>) -> CfExpression { + match network { + None => CfExpression::no_value(), + Some( + NetworkSettings::UseDefault + | NetworkSettings::Create { .. } + | NetworkSettings::ByoVpcAws { .. }, + ) => CfExpression::if_( + CONDITION_NETWORK_MODE_CREATE, + CfExpression::object([ + ("type", CfExpression::from("create")), + ( + "cidr", + CfExpression::if_( + CONDITION_HAS_VPC_CIDR, + CfExpression::ref_(PARAM_VPC_CIDR), + CfExpression::no_value(), + ), + ), + ( + "availability_zones", + CfExpression::ref_(PARAM_AVAILABILITY_ZONES), + ), + ]), + CfExpression::if_( + CONDITION_NETWORK_MODE_USE_EXISTING, + CfExpression::object([ + ("type", CfExpression::from("byo-vpc-aws")), + ("vpc_id", CfExpression::ref_(PARAM_VPC_ID)), + ( + "public_subnet_ids", + CfExpression::ref_(PARAM_PUBLIC_SUBNET_IDS), + ), + ( + "private_subnet_ids", + CfExpression::ref_(PARAM_PRIVATE_SUBNET_IDS), + ), + ( + "security_group_ids", + CfExpression::ref_(PARAM_SECURITY_GROUP_IDS), + ), + ]), + CfExpression::object([("type", CfExpression::from("use-default"))]), + ), + ), + Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => { + CfExpression::no_value() + } + } +} + +pub(super) fn domains_expression() -> CfExpression { + CfExpression::if_( + CONDITION_HAS_DOMAIN_NAME, + CfExpression::object([( + "customDomains", + CfExpression::object([( + "default", + CfExpression::object([ + ("domain", CfExpression::ref_(PARAM_DOMAIN_NAME)), + ( + "certificate", + CfExpression::object([( + "aws", + CfExpression::object([( + "certificateArn", + CfExpression::ref_(PARAM_CERTIFICATE_ARN), + )]), + )]), + ), + ]), + )]), + )]), + CfExpression::no_value(), + ) +} + +pub(super) fn management_config_expression(target: CloudFormationTarget) -> CfExpression { + if target.is_kubernetes() { + return CfExpression::Null; + } + + CfExpression::object([ + ("platform", CfExpression::from("aws")), + ( + "managingRoleArn", + CfExpression::ref_(PARAM_MANAGING_ROLE_ARN), + ), + ]) +} + +pub(super) fn string_parameter( + description: &str, + default: Option, + allowed_values: Option>, + no_echo: bool, +) -> CfParameter { + string_parameter_with_allowed_pattern(description, default, allowed_values, None, no_echo) +} + +pub(super) fn string_parameter_with_allowed_pattern( + description: &str, + default: Option, + allowed_values: Option>, + allowed_pattern: Option, + no_echo: bool, +) -> CfParameter { + CfParameter { + parameter_type: "String".to_string(), + description: Some(description.to_string()), + default: default.map(CfExpression::from), + allowed_values, + allowed_pattern, + min_length: None, + max_length: None, + min_value: None, + max_value: None, + no_echo: no_echo.then_some(true), + } +} + +pub(super) fn number_parameter( + description: &str, + default: u32, + allowed_values: Option>, +) -> CfParameter { + CfParameter { + parameter_type: "Number".to_string(), + description: Some(description.to_string()), + default: Some(CfExpression::from(default)), + allowed_values, + allowed_pattern: None, + min_length: None, + max_length: None, + min_value: None, + max_value: None, + no_echo: None, + } +} + +pub(super) fn comma_list_parameter(description: &str, default: Vec) -> CfParameter { + CfParameter { + parameter_type: "CommaDelimitedList".to_string(), + description: Some(description.to_string()), + default: Some(CfExpression::from(default.join(","))), + allowed_values: None, + allowed_pattern: None, + min_length: None, + max_length: None, + min_value: None, + max_value: None, + no_echo: None, + } +} + +pub(super) fn equals_ref(parameter: &str, value: &str) -> CfExpression { + CfExpression::equals(CfExpression::ref_(parameter), CfExpression::from(value)) +} + +pub(super) fn condition_ref(condition: &str) -> CfExpression { + CfExpression::object([("Condition", CfExpression::from(condition))]) +} + +pub(super) fn output(description: &str, value: CfExpression) -> CfOutput { + CfOutput { + description: Some(description.to_string()), + value, + export: None, + } +} + +pub(super) fn updates_mode(mode: UpdatesMode) -> &'static str { + match mode { + UpdatesMode::Auto => "auto", + UpdatesMode::ApprovalRequired => "approval-required", + } +} + +pub(super) fn telemetry_mode(mode: TelemetryMode) -> &'static str { + match mode { + TelemetryMode::Off => "off", + TelemetryMode::Auto => "auto", + TelemetryMode::ApprovalRequired => "approval-required", + } +} + +pub(super) fn heartbeats_mode(mode: HeartbeatsMode) -> &'static str { + match mode { + HeartbeatsMode::Off => "off", + HeartbeatsMode::On => "on", + } +} + +pub(super) fn network_mode_default(network: Option<&NetworkSettings>) -> &'static str { + match network { + Some(NetworkSettings::ByoVpcAws { .. }) => "use-existing", + Some(NetworkSettings::UseDefault) => "use-default", + None | Some(NetworkSettings::Create { .. }) => "create-new", + Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => { + "create-new" + } + } +} diff --git a/crates/alien-cloudformation/src/generator.rs b/crates/alien-cloudformation/src/generator/mod.rs similarity index 63% rename from crates/alien-cloudformation/src/generator.rs rename to crates/alien-cloudformation/src/generator/mod.rs index e9ea77d90..ddf246999 100644 --- a/crates/alien-cloudformation/src/generator.rs +++ b/crates/alien-cloudformation/src/generator/mod.rs @@ -1,21 +1,31 @@ +mod expressions; +mod parameters; + +#[cfg(test)] +mod tests; + use crate::{ emitters::enabled, registry::CfRegistry, - template::{ - CfExpression, CfMapping, CfOutput, CfParameter, CfResource, CfRule, CfRuleAssertion, - CfTemplate, - }, + template::{CfExpression, CfMapping, CfParameter, CfResource, CfTemplate}, }; use alien_core::{ import::{EmitContext, CURRENT_SETUP_IMPORT_FORMAT_VERSION}, - ownership_policy_for_resource_type, CapacityGroup, CapacityGroupScalePolicy, ComputeCluster, - ComputePoolSelection, DeploymentModel, DomainSettings, ErrorData, HeartbeatsMode, - KubernetesCluster, KubernetesSettings, Network, NetworkSettings, Platform, Result, Stack, + ownership_policy_for_resource_type, CapacityGroup, ComputeCluster, ComputePoolSelection, + DeploymentModel, ErrorData, KubernetesCluster, NetworkSettings, Platform, Result, Stack, StackInputDefaultValue, StackInputDefinition, StackInputKind, StackInputProvider, - StackSettings, TelemetryMode, UpdatesMode, Worker, WorkerCode, + StackSettings, Worker, WorkerCode, }; use alien_error::AlienError; +use expressions::{ + domains_expression, equals_ref, kubernetes_settings_expression, management_config_expression, + network_expression, output, +}; use indexmap::{indexmap, IndexMap}; +use parameters::{ + add_custom_domain_certificate_rule, add_standard_conditions, add_standard_parameters, + add_supported_region_rule, +}; use serde_json::{json, Value}; use std::collections::HashSet; @@ -866,405 +876,6 @@ fn apply_resource_dependencies( } } -fn add_standard_parameters( - template: &mut CfTemplate, - stack: &Stack, - settings: &StackSettings, - supports_custom_domain: bool, -) -> Result<()> { - template.parameters.insert( - PARAM_TOKEN.to_string(), - string_parameter( - "Install token from the application setup page.", - None, - None, - true, - ), - ); - template.parameters.insert( - PARAM_MANAGING_ROLE_ARN.to_string(), - string_parameter( - "ARN of the management identity allowed to assume setup-created roles.", - Some(String::new()), - None, - false, - ), - ); - template.parameters.insert( - PARAM_MANAGING_ACCOUNT_ID.to_string(), - string_parameter( - "AWS account ID for the management account that hosts application container images.", - Some(String::new()), - None, - false, - ), - ); - - add_network_parameters(template, settings.network.as_ref()); - add_compute_parameters(template, stack, settings.compute.as_ref())?; - - if supports_custom_domain { - let domain_defaults = DomainParameterDefaults::from_settings(settings.domains.as_ref()); - template.parameters.insert( - PARAM_DOMAIN_NAME.to_string(), - string_parameter( - "Optional custom domain for public endpoints. Leave unset to expose through the generated load balancer DNS name over HTTP.", - Some(domain_defaults.domain_name.unwrap_or_default()), - None, - false, - ), - ); - template.parameters.insert( - PARAM_HOSTED_ZONE_ID.to_string(), - string_parameter( - "Route 53 hosted zone ID for the custom domain. Not needed for the auto-generated domain.", - Some(String::new()), - None, - false, - ), - ); - template.parameters.insert( - PARAM_CERTIFICATE_ARN.to_string(), - string_parameter_with_allowed_pattern( - "ACM certificate ARN for the custom domain. Required when DomainName is set.", - Some(domain_defaults.certificate_arn.unwrap_or_default()), - None, - Some("^$|^arn:aws(-[a-z]+)?:acm:[a-z0-9-]+:[0-9]{12}:certificate/.+$".to_string()), - false, - ), - ); - } - - template.parameters.insert( - PARAM_UPDATES_MODE.to_string(), - string_parameter( - "How updates are applied after setup registration.", - Some(updates_mode(settings.updates).to_string()), - Some(vec![ - CfExpression::from("auto"), - CfExpression::from("approval-required"), - ]), - false, - ), - ); - template.parameters.insert( - PARAM_TELEMETRY_MODE.to_string(), - string_parameter( - "Telemetry collection behavior.", - Some(telemetry_mode(settings.telemetry).to_string()), - Some(vec![CfExpression::from(telemetry_mode(settings.telemetry))]), - false, - ), - ); - template.parameters.insert( - PARAM_HEARTBEATS_MODE.to_string(), - string_parameter( - "Heartbeat health-check behavior.", - Some(heartbeats_mode(settings.heartbeats).to_string()), - Some(vec![CfExpression::from("off"), CfExpression::from("on")]), - false, - ), - ); - Ok(()) -} - -fn add_network_parameters(template: &mut CfTemplate, network: Option<&NetworkSettings>) { - let defaults = NetworkParameterDefaults::from_settings(network); - template.parameters.insert( - PARAM_NETWORK_MODE.to_string(), - string_parameter( - "Choose create-new for a managed VPC, use-existing for your VPC, or use-default for the account default VPC.", - Some(network_mode_default(network).to_string()), - Some(vec![ - CfExpression::from("create-new"), - CfExpression::from("use-existing"), - CfExpression::from("use-default"), - ]), - false, - ), - ); - match network { - Some( - NetworkSettings::Create { .. } - | NetworkSettings::UseDefault - | NetworkSettings::ByoVpcAws { .. }, - ) => { - template.parameters.insert( - PARAM_VPC_CIDR.to_string(), - string_parameter( - "Only used with create-new. CIDR for the new VPC; leave unset for the generated default.", - Some(defaults.cidr.unwrap_or_default()), - None, - false, - ), - ); - template.parameters.insert( - PARAM_AVAILABILITY_ZONES.to_string(), - number_parameter( - "Only used with create-new. Number of availability zones for the new VPC.", - u32::from(defaults.availability_zones), - Some(vec![ - CfExpression::from(1u8), - CfExpression::from(2u8), - CfExpression::from(3u8), - ]), - ), - ); - template.parameters.insert( - PARAM_VPC_ID.to_string(), - string_parameter( - "Only used with use-existing. Existing VPC ID.", - Some(defaults.vpc_id.unwrap_or_default()), - None, - false, - ), - ); - template.parameters.insert( - PARAM_PUBLIC_SUBNET_IDS.to_string(), - comma_list_parameter( - "Only used with use-existing. Existing public subnet IDs.", - defaults.public_subnet_ids, - ), - ); - template.parameters.insert( - PARAM_PRIVATE_SUBNET_IDS.to_string(), - comma_list_parameter( - "Only used with use-existing. Existing private subnet IDs.", - defaults.private_subnet_ids, - ), - ); - template.parameters.insert( - PARAM_SECURITY_GROUP_IDS.to_string(), - comma_list_parameter( - "Only used with use-existing. Existing security group IDs.", - defaults.security_group_ids, - ), - ); - } - None | Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => {} - } -} - -fn add_compute_parameters( - template: &mut CfTemplate, - stack: &Stack, - compute: Option<&alien_core::ComputeSettings>, -) -> Result<()> { - let plan = alien_core::compute_planner::plan_compute(stack, Platform::Aws, compute)?; - for group in compute_capacity_groups(stack) { - let Some(selection) = compute.and_then(|settings| settings.pools.get(&group.group_id)) - else { - continue; - }; - let machine_parameter = compute_machine_parameter_name(&group.group_id); - let allowed_values = plan - .pools - .iter() - .find(|pool| pool.pool_id == group.group_id) - .map(|pool| { - pool.machines - .iter() - .map(|machine| CfExpression::from(machine.machine.as_str())) - .collect() - }); - template.parameters.insert( - machine_parameter, - string_parameter( - &format!( - "Provider machine type for runtime compute pool '{}'.", - group.group_id - ), - selection.machine().map(ToString::to_string), - allowed_values, - false, - ), - ); - - let scale = group.scale_policy.as_ref().cloned().unwrap_or_else(|| { - CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size) - }); - match (selection, scale) { - ( - ComputePoolSelection::Fixed { machines, .. }, - CapacityGroupScalePolicy::Fixed { machines: range }, - ) => { - let mut parameter = number_parameter( - &format!( - "Fixed machine count for runtime compute pool '{}'.", - group.group_id - ), - *machines, - None, - ); - parameter.min_value = Some(CfExpression::from(range.min)); - parameter.max_value = Some(CfExpression::from(range.max)); - template.parameters.insert( - compute_fixed_machines_parameter_name(&group.group_id), - parameter, - ); - } - ( - ComputePoolSelection::Autoscale { min, max, .. }, - CapacityGroupScalePolicy::Autoscale { - min: min_range, - max: max_range, - }, - ) => { - let mut min_parameter = number_parameter( - &format!( - "Minimum machine count for runtime compute pool '{}'.", - group.group_id - ), - *min, - None, - ); - min_parameter.min_value = Some(CfExpression::from(min_range.min)); - min_parameter.max_value = Some(CfExpression::from(min_range.max)); - template.parameters.insert( - compute_autoscale_min_parameter_name(&group.group_id), - min_parameter, - ); - - let mut max_parameter = number_parameter( - &format!( - "Maximum machine count for runtime compute pool '{}'.", - group.group_id - ), - *max, - None, - ); - max_parameter.min_value = Some(CfExpression::from(max_range.min)); - max_parameter.max_value = Some(CfExpression::from(max_range.max)); - template.parameters.insert( - compute_autoscale_max_parameter_name(&group.group_id), - max_parameter, - ); - } - _ => {} - } - } - Ok(()) -} - -fn add_supported_region_rule(template: &mut CfTemplate, registration: &RegistrationMode) { - let supported_regions = registration.supported_regions(); - if supported_regions.is_empty() { - return; - } - - let regions = supported_regions.join(", "); - template.rules.insert( - RULE_SUPPORTED_AWS_REGION.to_string(), - CfRule { - assertions: vec![CfRuleAssertion { - assertion: CfExpression::contains( - CfExpression::list(supported_regions.into_iter().map(CfExpression::from)), - CfExpression::ref_("AWS::Region"), - ), - assert_description: format!( - "This template can only be launched in AWS regions supported by this environment: {regions}." - ), - }], - }, - ); -} - -fn add_custom_domain_certificate_rule(template: &mut CfTemplate) { - template.rules.insert( - RULE_CUSTOM_DOMAIN_CERTIFICATE.to_string(), - CfRule { - assertions: vec![CfRuleAssertion { - assertion: CfExpression::or([ - equals_ref(PARAM_DOMAIN_NAME, ""), - CfExpression::not(equals_ref(PARAM_CERTIFICATE_ARN, "")), - ]), - assert_description: "CertificateArn must be set to an AWS ACM certificate ARN when DomainName is set.".to_string(), - }], - }, - ); -} - -fn add_standard_conditions( - template: &mut CfTemplate, - stack: &Stack, - settings: &StackSettings, - supports_custom_domain: bool, -) { - let has_created_network = stack_has_created_network(stack); - if has_dynamic_aws_network_settings(settings.network.as_ref()) || has_created_network { - template.conditions.insert( - CONDITION_NETWORK_MODE_CREATE.to_string(), - equals_ref(PARAM_NETWORK_MODE, "create-new"), - ); - template.conditions.insert( - CONDITION_NETWORK_MODE_USE_EXISTING.to_string(), - equals_ref(PARAM_NETWORK_MODE, "use-existing"), - ); - } - if has_created_network { - template.conditions.insert( - CONDITION_NETWORK_AZ2.to_string(), - CfExpression::not(CfExpression::equals( - CfExpression::ref_(PARAM_AVAILABILITY_ZONES), - CfExpression::from(1u8), - )), - ); - template.conditions.insert( - CONDITION_NETWORK_AZ3.to_string(), - CfExpression::equals( - CfExpression::ref_(PARAM_AVAILABILITY_ZONES), - CfExpression::from(3u8), - ), - ); - template.conditions.insert( - CONDITION_NETWORK_CREATE_AZ2.to_string(), - CfExpression::and([ - equals_ref(PARAM_NETWORK_MODE, "create-new"), - condition_ref(CONDITION_NETWORK_AZ2), - ]), - ); - template.conditions.insert( - CONDITION_NETWORK_CREATE_AZ3.to_string(), - CfExpression::and([ - equals_ref(PARAM_NETWORK_MODE, "create-new"), - condition_ref(CONDITION_NETWORK_AZ3), - ]), - ); - } - if has_dynamic_aws_network_settings(settings.network.as_ref()) || has_created_network { - template.conditions.insert( - CONDITION_HAS_VPC_CIDR.to_string(), - CfExpression::not(equals_ref(PARAM_VPC_CIDR, "")), - ); - } - if supports_custom_domain { - template.conditions.insert( - CONDITION_HAS_DOMAIN_NAME.to_string(), - CfExpression::not(equals_ref(PARAM_DOMAIN_NAME, "")), - ); - } -} - -fn stack_has_created_network(stack: &Stack) -> bool { - stack.resources().any(|(_resource_id, resource)| { - resource - .config - .downcast_ref::() - .is_some_and(|network| matches!(network.settings, NetworkSettings::Create { .. })) - }) -} - -fn has_dynamic_aws_network_settings(network: Option<&NetworkSettings>) -> bool { - matches!( - network, - Some( - NetworkSettings::Create { .. } - | NetworkSettings::UseDefault - | NetworkSettings::ByoVpcAws { .. } - ) - ) -} - fn add_console_interface_metadata( template: &mut CfTemplate, stack: &Stack, @@ -1967,528 +1578,3 @@ fn stack_settings_expression( } CfExpression::object(values) } - -fn kubernetes_settings_expression( - settings: Option<&KubernetesSettings>, - namespace: Option, -) -> CfExpression { - let mut expression = default_kubernetes_settings_expression(); - - if let Some(settings) = settings { - let value = serde_json::to_value(settings) - .expect("serializing Kubernetes stack settings should not fail"); - merge_cf_expression(&mut expression, cf_expression_from_json(value)); - } - - if let Some(namespace) = namespace { - set_kubernetes_namespace_expression(&mut expression, namespace); - } - - expression -} - -fn default_kubernetes_settings_expression() -> CfExpression { - CfExpression::object([ - ( - "cluster", - CfExpression::object([ - ("ownership", CfExpression::from("managed")), - ("namespace", CfExpression::from("default")), - ]), - ), - ( - "exposure", - CfExpression::if_( - CONDITION_HAS_DOMAIN_NAME, - custom_domain_kubernetes_exposure_expression(), - generated_load_balancer_kubernetes_exposure_expression(), - ), - ), - ]) -} - -fn generated_load_balancer_kubernetes_exposure_expression() -> CfExpression { - CfExpression::object([ - ("mode", CfExpression::from("generated")), - ("route", aws_alb_kubernetes_route_expression()), - ( - "certificate", - CfExpression::object([("mode", CfExpression::from("none"))]), - ), - ]) -} - -fn custom_domain_kubernetes_exposure_expression() -> CfExpression { - CfExpression::object([ - ("mode", CfExpression::from("custom")), - ("domain", CfExpression::ref_(PARAM_DOMAIN_NAME)), - ("route", aws_alb_kubernetes_route_expression()), - ( - "certificate", - CfExpression::object([ - ("mode", CfExpression::from("awsAcmArn")), - ("certificateArn", CfExpression::ref_(PARAM_CERTIFICATE_ARN)), - ]), - ), - ]) -} - -fn aws_alb_kubernetes_route_expression() -> CfExpression { - CfExpression::object([ - ("routeApi", CfExpression::from("ingress")), - ("controller", CfExpression::from("eks.amazonaws.com/alb")), - ("ingressClassName", CfExpression::from("alb")), - ("labels", empty_object()), - ("annotations", empty_object()), - ( - "provider", - CfExpression::object([ - ("provider", CfExpression::from("awsAlb")), - ("scheme", CfExpression::from("internet-facing")), - ("targetType", CfExpression::from("ip")), - ("subnetIds", CfExpression::list([])), - ]), - ), - ]) -} - -fn set_kubernetes_namespace_expression(expression: &mut CfExpression, namespace: CfExpression) { - let CfExpression::Object(root) = expression else { - return; - }; - let cluster = root - .entry("cluster".to_string()) - .or_insert_with(|| CfExpression::object([("ownership", CfExpression::from("managed"))])); - let CfExpression::Object(cluster) = cluster else { - *cluster = CfExpression::object([ - ("ownership", CfExpression::from("managed")), - ("namespace", namespace), - ]); - return; - }; - cluster.insert("namespace".to_string(), namespace); -} - -fn merge_cf_expression(base: &mut CfExpression, overlay: CfExpression) { - if is_cloudformation_intrinsic(base) || is_cloudformation_intrinsic(&overlay) { - *base = overlay; - return; - } - - match (base, overlay) { - (CfExpression::Object(base), CfExpression::Object(overlay)) => { - for (key, value) in overlay { - match base.get_mut(&key) { - Some(existing) => merge_cf_expression(existing, value), - None => { - base.insert(key, value); - } - } - } - } - (base, overlay) => *base = overlay, - } -} - -fn is_cloudformation_intrinsic(expression: &CfExpression) -> bool { - let CfExpression::Object(values) = expression else { - return false; - }; - values - .keys() - .any(|key| key == "Ref" || key.starts_with("Fn::")) -} - -fn cf_expression_from_json(value: Value) -> CfExpression { - match value { - Value::Null => CfExpression::Null, - Value::Bool(value) => CfExpression::Bool(value), - Value::Number(number) => { - if let Some(value) = number.as_i64() { - CfExpression::Integer(value) - } else { - CfExpression::Number( - number - .as_f64() - .expect("serde_json numbers should be representable as f64"), - ) - } - } - Value::String(value) => CfExpression::String(value), - Value::Array(values) => { - CfExpression::List(values.into_iter().map(cf_expression_from_json).collect()) - } - Value::Object(values) => CfExpression::Object( - values - .into_iter() - .map(|(key, value)| (key, cf_expression_from_json(value))) - .collect(), - ), - } -} - -fn network_expression(network: Option<&NetworkSettings>) -> CfExpression { - match network { - None => CfExpression::no_value(), - Some( - NetworkSettings::UseDefault - | NetworkSettings::Create { .. } - | NetworkSettings::ByoVpcAws { .. }, - ) => CfExpression::if_( - CONDITION_NETWORK_MODE_CREATE, - CfExpression::object([ - ("type", CfExpression::from("create")), - ( - "cidr", - CfExpression::if_( - CONDITION_HAS_VPC_CIDR, - CfExpression::ref_(PARAM_VPC_CIDR), - CfExpression::no_value(), - ), - ), - ( - "availability_zones", - CfExpression::ref_(PARAM_AVAILABILITY_ZONES), - ), - ]), - CfExpression::if_( - CONDITION_NETWORK_MODE_USE_EXISTING, - CfExpression::object([ - ("type", CfExpression::from("byo-vpc-aws")), - ("vpc_id", CfExpression::ref_(PARAM_VPC_ID)), - ( - "public_subnet_ids", - CfExpression::ref_(PARAM_PUBLIC_SUBNET_IDS), - ), - ( - "private_subnet_ids", - CfExpression::ref_(PARAM_PRIVATE_SUBNET_IDS), - ), - ( - "security_group_ids", - CfExpression::ref_(PARAM_SECURITY_GROUP_IDS), - ), - ]), - CfExpression::object([("type", CfExpression::from("use-default"))]), - ), - ), - Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => { - CfExpression::no_value() - } - } -} - -fn domains_expression() -> CfExpression { - CfExpression::if_( - CONDITION_HAS_DOMAIN_NAME, - CfExpression::object([( - "customDomains", - CfExpression::object([( - "default", - CfExpression::object([ - ("domain", CfExpression::ref_(PARAM_DOMAIN_NAME)), - ( - "certificate", - CfExpression::object([( - "aws", - CfExpression::object([( - "certificateArn", - CfExpression::ref_(PARAM_CERTIFICATE_ARN), - )]), - )]), - ), - ]), - )]), - )]), - CfExpression::no_value(), - ) -} - -fn management_config_expression(target: CloudFormationTarget) -> CfExpression { - if target.is_kubernetes() { - return CfExpression::Null; - } - - CfExpression::object([ - ("platform", CfExpression::from("aws")), - ( - "managingRoleArn", - CfExpression::ref_(PARAM_MANAGING_ROLE_ARN), - ), - ]) -} - -fn string_parameter( - description: &str, - default: Option, - allowed_values: Option>, - no_echo: bool, -) -> CfParameter { - string_parameter_with_allowed_pattern(description, default, allowed_values, None, no_echo) -} - -fn string_parameter_with_allowed_pattern( - description: &str, - default: Option, - allowed_values: Option>, - allowed_pattern: Option, - no_echo: bool, -) -> CfParameter { - CfParameter { - parameter_type: "String".to_string(), - description: Some(description.to_string()), - default: default.map(CfExpression::from), - allowed_values, - allowed_pattern, - min_length: None, - max_length: None, - min_value: None, - max_value: None, - no_echo: no_echo.then_some(true), - } -} - -fn number_parameter( - description: &str, - default: u32, - allowed_values: Option>, -) -> CfParameter { - CfParameter { - parameter_type: "Number".to_string(), - description: Some(description.to_string()), - default: Some(CfExpression::from(default)), - allowed_values, - allowed_pattern: None, - min_length: None, - max_length: None, - min_value: None, - max_value: None, - no_echo: None, - } -} - -fn comma_list_parameter(description: &str, default: Vec) -> CfParameter { - CfParameter { - parameter_type: "CommaDelimitedList".to_string(), - description: Some(description.to_string()), - default: Some(CfExpression::from(default.join(","))), - allowed_values: None, - allowed_pattern: None, - min_length: None, - max_length: None, - min_value: None, - max_value: None, - no_echo: None, - } -} - -fn equals_ref(parameter: &str, value: &str) -> CfExpression { - CfExpression::equals(CfExpression::ref_(parameter), CfExpression::from(value)) -} - -fn condition_ref(condition: &str) -> CfExpression { - CfExpression::object([("Condition", CfExpression::from(condition))]) -} - -fn output(description: &str, value: CfExpression) -> CfOutput { - CfOutput { - description: Some(description.to_string()), - value, - export: None, - } -} - -fn updates_mode(mode: UpdatesMode) -> &'static str { - match mode { - UpdatesMode::Auto => "auto", - UpdatesMode::ApprovalRequired => "approval-required", - } -} - -fn telemetry_mode(mode: TelemetryMode) -> &'static str { - match mode { - TelemetryMode::Off => "off", - TelemetryMode::Auto => "auto", - TelemetryMode::ApprovalRequired => "approval-required", - } -} - -fn heartbeats_mode(mode: HeartbeatsMode) -> &'static str { - match mode { - HeartbeatsMode::Off => "off", - HeartbeatsMode::On => "on", - } -} - -fn network_mode_default(network: Option<&NetworkSettings>) -> &'static str { - match network { - Some(NetworkSettings::ByoVpcAws { .. }) => "use-existing", - Some(NetworkSettings::UseDefault) => "use-default", - None | Some(NetworkSettings::Create { .. }) => "create-new", - Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => { - "create-new" - } - } -} - -#[derive(Debug)] -struct NetworkParameterDefaults { - cidr: Option, - availability_zones: u8, - vpc_id: Option, - public_subnet_ids: Vec, - private_subnet_ids: Vec, - security_group_ids: Vec, -} - -impl NetworkParameterDefaults { - fn from_settings(network: Option<&NetworkSettings>) -> Self { - match network { - None => Self::auto(), - Some(NetworkSettings::UseDefault) => Self { ..Self::auto() }, - Some(NetworkSettings::Create { - cidr, - availability_zones, - }) => Self { - cidr: cidr.clone(), - availability_zones: *availability_zones, - ..Self::auto() - }, - Some(NetworkSettings::ByoVpcAws { - vpc_id, - public_subnet_ids, - private_subnet_ids, - security_group_ids, - }) => Self { - vpc_id: Some(vpc_id.clone()), - public_subnet_ids: public_subnet_ids.clone(), - private_subnet_ids: private_subnet_ids.clone(), - security_group_ids: security_group_ids.clone(), - ..Self::auto() - }, - Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => { - Self::auto() - } - } - } - - fn auto() -> Self { - Self { - cidr: None, - availability_zones: 2, - vpc_id: None, - public_subnet_ids: vec![], - private_subnet_ids: vec![], - security_group_ids: vec![], - } - } -} - -#[derive(Debug)] -struct DomainParameterDefaults { - domain_name: Option, - certificate_arn: Option, -} - -impl DomainParameterDefaults { - fn from_settings(domains: Option<&DomainSettings>) -> Self { - let Some(domains) = domains else { - return Self::empty(); - }; - let Some(custom_domains) = &domains.custom_domains else { - return Self::empty(); - }; - let Some((_resource_id, domain)) = custom_domains.iter().next() else { - return Self::empty(); - }; - - Self { - domain_name: Some(domain.domain.clone()), - certificate_arn: domain - .certificate - .aws - .as_ref() - .map(|certificate| certificate.certificate_arn.clone()), - } - } - - fn empty() -> Self { - Self { - domain_name: None, - certificate_arn: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn merge_replaces_intrinsic_expression_with_structured_overlay() { - let mut base = CfExpression::object([( - "exposure", - CfExpression::if_( - CONDITION_HAS_DOMAIN_NAME, - CfExpression::object([("mode", CfExpression::from("custom"))]), - CfExpression::object([("mode", CfExpression::from("generated"))]), - ), - )]); - let overlay = CfExpression::object([( - "exposure", - CfExpression::object([ - ("mode", CfExpression::from("generated")), - ( - "certificate", - CfExpression::object([("mode", CfExpression::from("none"))]), - ), - ]), - )]); - - merge_cf_expression(&mut base, overlay); - - let CfExpression::Object(root) = base else { - panic!("merged expression should remain an object"); - }; - let exposure = root - .get("exposure") - .expect("merged settings should keep exposure"); - let CfExpression::Object(exposure) = exposure else { - panic!("exposure should be the structured overlay"); - }; - assert_eq!(exposure.get("mode"), Some(&CfExpression::from("generated"))); - assert!( - !exposure.contains_key("Fn::If"), - "intrinsic and structured object keys must not be merged" - ); - } - - #[test] - fn merge_replaces_structured_expression_with_intrinsic_overlay() { - let mut base = CfExpression::object([( - "network", - CfExpression::object([("type", CfExpression::from("use-default"))]), - )]); - let overlay = CfExpression::object([( - "network", - CfExpression::if_( - CONDITION_NETWORK_MODE_CREATE, - CfExpression::object([("type", CfExpression::from("create"))]), - CfExpression::no_value(), - ), - )]); - - merge_cf_expression(&mut base, overlay); - - let CfExpression::Object(root) = base else { - panic!("merged expression should remain an object"); - }; - let network = root - .get("network") - .expect("merged settings should keep network"); - assert!( - is_cloudformation_intrinsic(network), - "intrinsic overlay should replace the structured base" - ); - } -} diff --git a/crates/alien-cloudformation/src/generator/parameters.rs b/crates/alien-cloudformation/src/generator/parameters.rs new file mode 100644 index 000000000..c88212980 --- /dev/null +++ b/crates/alien-cloudformation/src/generator/parameters.rs @@ -0,0 +1,519 @@ +//! CloudFormation parameter, rule, and condition builders. +//! +//! Owns the standard/network/compute parameter blocks, the supported-region +//! and custom-domain rules, the standard conditions, and the parameter-default +//! helpers that read those values from stack settings. + +use super::expressions::{ + comma_list_parameter, condition_ref, equals_ref, heartbeats_mode, network_mode_default, + number_parameter, string_parameter, string_parameter_with_allowed_pattern, telemetry_mode, + updates_mode, +}; +use super::{ + compute_autoscale_max_parameter_name, compute_autoscale_min_parameter_name, + compute_capacity_groups, compute_fixed_machines_parameter_name, compute_machine_parameter_name, + RegistrationMode, CONDITION_HAS_DOMAIN_NAME, CONDITION_HAS_VPC_CIDR, CONDITION_NETWORK_AZ2, + CONDITION_NETWORK_AZ3, CONDITION_NETWORK_CREATE_AZ2, CONDITION_NETWORK_CREATE_AZ3, + CONDITION_NETWORK_MODE_CREATE, CONDITION_NETWORK_MODE_USE_EXISTING, PARAM_AVAILABILITY_ZONES, + PARAM_CERTIFICATE_ARN, PARAM_DOMAIN_NAME, PARAM_HEARTBEATS_MODE, PARAM_HOSTED_ZONE_ID, + PARAM_MANAGING_ACCOUNT_ID, PARAM_MANAGING_ROLE_ARN, PARAM_NETWORK_MODE, + PARAM_PRIVATE_SUBNET_IDS, PARAM_PUBLIC_SUBNET_IDS, PARAM_SECURITY_GROUP_IDS, + PARAM_TELEMETRY_MODE, PARAM_TOKEN, PARAM_UPDATES_MODE, PARAM_VPC_CIDR, PARAM_VPC_ID, + RULE_CUSTOM_DOMAIN_CERTIFICATE, RULE_SUPPORTED_AWS_REGION, +}; +use crate::template::{CfExpression, CfRule, CfRuleAssertion, CfTemplate}; +use alien_core::{ + CapacityGroupScalePolicy, ComputePoolSelection, DomainSettings, Network, NetworkSettings, + Platform, Result, Stack, StackSettings, +}; + +pub(super) fn add_standard_parameters( + template: &mut CfTemplate, + stack: &Stack, + settings: &StackSettings, + supports_custom_domain: bool, +) -> Result<()> { + template.parameters.insert( + PARAM_TOKEN.to_string(), + string_parameter( + "Install token from the application setup page.", + None, + None, + true, + ), + ); + template.parameters.insert( + PARAM_MANAGING_ROLE_ARN.to_string(), + string_parameter( + "ARN of the management identity allowed to assume setup-created roles.", + Some(String::new()), + None, + false, + ), + ); + template.parameters.insert( + PARAM_MANAGING_ACCOUNT_ID.to_string(), + string_parameter( + "AWS account ID for the management account that hosts application container images.", + Some(String::new()), + None, + false, + ), + ); + + add_network_parameters(template, settings.network.as_ref()); + add_compute_parameters(template, stack, settings.compute.as_ref())?; + + if supports_custom_domain { + let domain_defaults = DomainParameterDefaults::from_settings(settings.domains.as_ref()); + template.parameters.insert( + PARAM_DOMAIN_NAME.to_string(), + string_parameter( + "Optional custom domain for public endpoints. Leave unset to expose through the generated load balancer DNS name over HTTP.", + Some(domain_defaults.domain_name.unwrap_or_default()), + None, + false, + ), + ); + template.parameters.insert( + PARAM_HOSTED_ZONE_ID.to_string(), + string_parameter( + "Route 53 hosted zone ID for the custom domain. Not needed for the auto-generated domain.", + Some(String::new()), + None, + false, + ), + ); + template.parameters.insert( + PARAM_CERTIFICATE_ARN.to_string(), + string_parameter_with_allowed_pattern( + "ACM certificate ARN for the custom domain. Required when DomainName is set.", + Some(domain_defaults.certificate_arn.unwrap_or_default()), + None, + Some("^$|^arn:aws(-[a-z]+)?:acm:[a-z0-9-]+:[0-9]{12}:certificate/.+$".to_string()), + false, + ), + ); + } + + template.parameters.insert( + PARAM_UPDATES_MODE.to_string(), + string_parameter( + "How updates are applied after setup registration.", + Some(updates_mode(settings.updates).to_string()), + Some(vec![ + CfExpression::from("auto"), + CfExpression::from("approval-required"), + ]), + false, + ), + ); + template.parameters.insert( + PARAM_TELEMETRY_MODE.to_string(), + string_parameter( + "Telemetry collection behavior.", + Some(telemetry_mode(settings.telemetry).to_string()), + Some(vec![CfExpression::from(telemetry_mode(settings.telemetry))]), + false, + ), + ); + template.parameters.insert( + PARAM_HEARTBEATS_MODE.to_string(), + string_parameter( + "Heartbeat health-check behavior.", + Some(heartbeats_mode(settings.heartbeats).to_string()), + Some(vec![CfExpression::from("off"), CfExpression::from("on")]), + false, + ), + ); + Ok(()) +} + +fn add_network_parameters(template: &mut CfTemplate, network: Option<&NetworkSettings>) { + let defaults = NetworkParameterDefaults::from_settings(network); + template.parameters.insert( + PARAM_NETWORK_MODE.to_string(), + string_parameter( + "Choose create-new for a managed VPC, use-existing for your VPC, or use-default for the account default VPC.", + Some(network_mode_default(network).to_string()), + Some(vec![ + CfExpression::from("create-new"), + CfExpression::from("use-existing"), + CfExpression::from("use-default"), + ]), + false, + ), + ); + match network { + Some( + NetworkSettings::Create { .. } + | NetworkSettings::UseDefault + | NetworkSettings::ByoVpcAws { .. }, + ) => { + template.parameters.insert( + PARAM_VPC_CIDR.to_string(), + string_parameter( + "Only used with create-new. CIDR for the new VPC; leave unset for the generated default.", + Some(defaults.cidr.unwrap_or_default()), + None, + false, + ), + ); + template.parameters.insert( + PARAM_AVAILABILITY_ZONES.to_string(), + number_parameter( + "Only used with create-new. Number of availability zones for the new VPC.", + u32::from(defaults.availability_zones), + Some(vec![ + CfExpression::from(1u8), + CfExpression::from(2u8), + CfExpression::from(3u8), + ]), + ), + ); + template.parameters.insert( + PARAM_VPC_ID.to_string(), + string_parameter( + "Only used with use-existing. Existing VPC ID.", + Some(defaults.vpc_id.unwrap_or_default()), + None, + false, + ), + ); + template.parameters.insert( + PARAM_PUBLIC_SUBNET_IDS.to_string(), + comma_list_parameter( + "Only used with use-existing. Existing public subnet IDs.", + defaults.public_subnet_ids, + ), + ); + template.parameters.insert( + PARAM_PRIVATE_SUBNET_IDS.to_string(), + comma_list_parameter( + "Only used with use-existing. Existing private subnet IDs.", + defaults.private_subnet_ids, + ), + ); + template.parameters.insert( + PARAM_SECURITY_GROUP_IDS.to_string(), + comma_list_parameter( + "Only used with use-existing. Existing security group IDs.", + defaults.security_group_ids, + ), + ); + } + None | Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => {} + } +} + +fn add_compute_parameters( + template: &mut CfTemplate, + stack: &Stack, + compute: Option<&alien_core::ComputeSettings>, +) -> Result<()> { + let plan = alien_core::compute_planner::plan_compute(stack, Platform::Aws, compute)?; + for group in compute_capacity_groups(stack) { + let Some(selection) = compute.and_then(|settings| settings.pools.get(&group.group_id)) + else { + continue; + }; + let machine_parameter = compute_machine_parameter_name(&group.group_id); + let allowed_values = plan + .pools + .iter() + .find(|pool| pool.pool_id == group.group_id) + .map(|pool| { + pool.machines + .iter() + .map(|machine| CfExpression::from(machine.machine.as_str())) + .collect() + }); + template.parameters.insert( + machine_parameter, + string_parameter( + &format!( + "Provider machine type for runtime compute pool '{}'.", + group.group_id + ), + selection.machine().map(ToString::to_string), + allowed_values, + false, + ), + ); + + let scale = group.scale_policy.as_ref().cloned().unwrap_or_else(|| { + CapacityGroupScalePolicy::from_selected_bounds(group.min_size, group.max_size) + }); + match (selection, scale) { + ( + ComputePoolSelection::Fixed { machines, .. }, + CapacityGroupScalePolicy::Fixed { machines: range }, + ) => { + let mut parameter = number_parameter( + &format!( + "Fixed machine count for runtime compute pool '{}'.", + group.group_id + ), + *machines, + None, + ); + parameter.min_value = Some(CfExpression::from(range.min)); + parameter.max_value = Some(CfExpression::from(range.max)); + template.parameters.insert( + compute_fixed_machines_parameter_name(&group.group_id), + parameter, + ); + } + ( + ComputePoolSelection::Autoscale { min, max, .. }, + CapacityGroupScalePolicy::Autoscale { + min: min_range, + max: max_range, + }, + ) => { + let mut min_parameter = number_parameter( + &format!( + "Minimum machine count for runtime compute pool '{}'.", + group.group_id + ), + *min, + None, + ); + min_parameter.min_value = Some(CfExpression::from(min_range.min)); + min_parameter.max_value = Some(CfExpression::from(min_range.max)); + template.parameters.insert( + compute_autoscale_min_parameter_name(&group.group_id), + min_parameter, + ); + + let mut max_parameter = number_parameter( + &format!( + "Maximum machine count for runtime compute pool '{}'.", + group.group_id + ), + *max, + None, + ); + max_parameter.min_value = Some(CfExpression::from(max_range.min)); + max_parameter.max_value = Some(CfExpression::from(max_range.max)); + template.parameters.insert( + compute_autoscale_max_parameter_name(&group.group_id), + max_parameter, + ); + } + _ => {} + } + } + Ok(()) +} + +pub(super) fn add_supported_region_rule( + template: &mut CfTemplate, + registration: &RegistrationMode, +) { + let supported_regions = registration.supported_regions(); + if supported_regions.is_empty() { + return; + } + + let regions = supported_regions.join(", "); + template.rules.insert( + RULE_SUPPORTED_AWS_REGION.to_string(), + CfRule { + assertions: vec![CfRuleAssertion { + assertion: CfExpression::contains( + CfExpression::list(supported_regions.into_iter().map(CfExpression::from)), + CfExpression::ref_("AWS::Region"), + ), + assert_description: format!( + "This template can only be launched in AWS regions supported by this environment: {regions}." + ), + }], + }, + ); +} + +pub(super) fn add_custom_domain_certificate_rule(template: &mut CfTemplate) { + template.rules.insert( + RULE_CUSTOM_DOMAIN_CERTIFICATE.to_string(), + CfRule { + assertions: vec![CfRuleAssertion { + assertion: CfExpression::or([ + equals_ref(PARAM_DOMAIN_NAME, ""), + CfExpression::not(equals_ref(PARAM_CERTIFICATE_ARN, "")), + ]), + assert_description: "CertificateArn must be set to an AWS ACM certificate ARN when DomainName is set.".to_string(), + }], + }, + ); +} + +pub(super) fn add_standard_conditions( + template: &mut CfTemplate, + stack: &Stack, + settings: &StackSettings, + supports_custom_domain: bool, +) { + let has_created_network = stack_has_created_network(stack); + if has_dynamic_aws_network_settings(settings.network.as_ref()) || has_created_network { + template.conditions.insert( + CONDITION_NETWORK_MODE_CREATE.to_string(), + equals_ref(PARAM_NETWORK_MODE, "create-new"), + ); + template.conditions.insert( + CONDITION_NETWORK_MODE_USE_EXISTING.to_string(), + equals_ref(PARAM_NETWORK_MODE, "use-existing"), + ); + } + if has_created_network { + template.conditions.insert( + CONDITION_NETWORK_AZ2.to_string(), + CfExpression::not(CfExpression::equals( + CfExpression::ref_(PARAM_AVAILABILITY_ZONES), + CfExpression::from(1u8), + )), + ); + template.conditions.insert( + CONDITION_NETWORK_AZ3.to_string(), + CfExpression::equals( + CfExpression::ref_(PARAM_AVAILABILITY_ZONES), + CfExpression::from(3u8), + ), + ); + template.conditions.insert( + CONDITION_NETWORK_CREATE_AZ2.to_string(), + CfExpression::and([ + equals_ref(PARAM_NETWORK_MODE, "create-new"), + condition_ref(CONDITION_NETWORK_AZ2), + ]), + ); + template.conditions.insert( + CONDITION_NETWORK_CREATE_AZ3.to_string(), + CfExpression::and([ + equals_ref(PARAM_NETWORK_MODE, "create-new"), + condition_ref(CONDITION_NETWORK_AZ3), + ]), + ); + } + if has_dynamic_aws_network_settings(settings.network.as_ref()) || has_created_network { + template.conditions.insert( + CONDITION_HAS_VPC_CIDR.to_string(), + CfExpression::not(equals_ref(PARAM_VPC_CIDR, "")), + ); + } + if supports_custom_domain { + template.conditions.insert( + CONDITION_HAS_DOMAIN_NAME.to_string(), + CfExpression::not(equals_ref(PARAM_DOMAIN_NAME, "")), + ); + } +} + +fn stack_has_created_network(stack: &Stack) -> bool { + stack.resources().any(|(_resource_id, resource)| { + resource + .config + .downcast_ref::() + .is_some_and(|network| matches!(network.settings, NetworkSettings::Create { .. })) + }) +} + +fn has_dynamic_aws_network_settings(network: Option<&NetworkSettings>) -> bool { + matches!( + network, + Some( + NetworkSettings::Create { .. } + | NetworkSettings::UseDefault + | NetworkSettings::ByoVpcAws { .. } + ) + ) +} + +#[derive(Debug)] +struct NetworkParameterDefaults { + cidr: Option, + availability_zones: u8, + vpc_id: Option, + public_subnet_ids: Vec, + private_subnet_ids: Vec, + security_group_ids: Vec, +} + +impl NetworkParameterDefaults { + fn from_settings(network: Option<&NetworkSettings>) -> Self { + match network { + None => Self::auto(), + Some(NetworkSettings::UseDefault) => Self { ..Self::auto() }, + Some(NetworkSettings::Create { + cidr, + availability_zones, + }) => Self { + cidr: cidr.clone(), + availability_zones: *availability_zones, + ..Self::auto() + }, + Some(NetworkSettings::ByoVpcAws { + vpc_id, + public_subnet_ids, + private_subnet_ids, + security_group_ids, + }) => Self { + vpc_id: Some(vpc_id.clone()), + public_subnet_ids: public_subnet_ids.clone(), + private_subnet_ids: private_subnet_ids.clone(), + security_group_ids: security_group_ids.clone(), + ..Self::auto() + }, + Some(NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. }) => { + Self::auto() + } + } + } + + fn auto() -> Self { + Self { + cidr: None, + availability_zones: 2, + vpc_id: None, + public_subnet_ids: vec![], + private_subnet_ids: vec![], + security_group_ids: vec![], + } + } +} + +#[derive(Debug)] +struct DomainParameterDefaults { + domain_name: Option, + certificate_arn: Option, +} + +impl DomainParameterDefaults { + fn from_settings(domains: Option<&DomainSettings>) -> Self { + let Some(domains) = domains else { + return Self::empty(); + }; + let Some(custom_domains) = &domains.custom_domains else { + return Self::empty(); + }; + let Some((_resource_id, domain)) = custom_domains.iter().next() else { + return Self::empty(); + }; + + Self { + domain_name: Some(domain.domain.clone()), + certificate_arn: domain + .certificate + .aws + .as_ref() + .map(|certificate| certificate.certificate_arn.clone()), + } + } + + fn empty() -> Self { + Self { + domain_name: None, + certificate_arn: None, + } + } +} diff --git a/crates/alien-cloudformation/src/generator/tests.rs b/crates/alien-cloudformation/src/generator/tests.rs new file mode 100644 index 000000000..52e1bade9 --- /dev/null +++ b/crates/alien-cloudformation/src/generator/tests.rs @@ -0,0 +1,71 @@ +use super::expressions::{is_cloudformation_intrinsic, merge_cf_expression}; +use super::{CONDITION_HAS_DOMAIN_NAME, CONDITION_NETWORK_MODE_CREATE}; +use crate::template::CfExpression; + +#[test] +fn merge_replaces_intrinsic_expression_with_structured_overlay() { + let mut base = CfExpression::object([( + "exposure", + CfExpression::if_( + CONDITION_HAS_DOMAIN_NAME, + CfExpression::object([("mode", CfExpression::from("custom"))]), + CfExpression::object([("mode", CfExpression::from("generated"))]), + ), + )]); + let overlay = CfExpression::object([( + "exposure", + CfExpression::object([ + ("mode", CfExpression::from("generated")), + ( + "certificate", + CfExpression::object([("mode", CfExpression::from("none"))]), + ), + ]), + )]); + + merge_cf_expression(&mut base, overlay); + + let CfExpression::Object(root) = base else { + panic!("merged expression should remain an object"); + }; + let exposure = root + .get("exposure") + .expect("merged settings should keep exposure"); + let CfExpression::Object(exposure) = exposure else { + panic!("exposure should be the structured overlay"); + }; + assert_eq!(exposure.get("mode"), Some(&CfExpression::from("generated"))); + assert!( + !exposure.contains_key("Fn::If"), + "intrinsic and structured object keys must not be merged" + ); +} + +#[test] +fn merge_replaces_structured_expression_with_intrinsic_overlay() { + let mut base = CfExpression::object([( + "network", + CfExpression::object([("type", CfExpression::from("use-default"))]), + )]); + let overlay = CfExpression::object([( + "network", + CfExpression::if_( + CONDITION_NETWORK_MODE_CREATE, + CfExpression::object([("type", CfExpression::from("create"))]), + CfExpression::no_value(), + ), + )]); + + merge_cf_expression(&mut base, overlay); + + let CfExpression::Object(root) = base else { + panic!("merged expression should remain an object"); + }; + let network = root + .get("network") + .expect("merged settings should keep network"); + assert!( + is_cloudformation_intrinsic(network), + "intrinsic overlay should replace the structured base" + ); +} diff --git a/crates/alien-core/src/heartbeat.rs b/crates/alien-core/src/heartbeat.rs deleted file mode 100644 index 7f35c24c8..000000000 --- a/crates/alien-core/src/heartbeat.rs +++ /dev/null @@ -1,2729 +0,0 @@ -use std::collections::BTreeMap; - -use crate::{Platform, ResourceType}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ResourceHeartbeat { - pub deployment_id: Option, - /// Alien resource id, such as the `alien.Container` or `alien.Storage` - /// resource id from the stack. - pub resource_id: String, - pub resource_type: ResourceType, - pub controller_platform: Platform, - pub backend: HeartbeatBackend, - pub observed_at: DateTime, - pub data: ResourceHeartbeatData, - pub raw: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ObservedInventoryBatch { - /// Writer/source for this inventory pass, such as `operator` or - /// `manager-observer`. - pub source_kind: String, - /// Stable scope for the provider list operation that produced this batch. - pub inventory_scope: String, - /// Platform whose observer produced this snapshot. - pub controller_platform: Platform, - /// Backend whose observer produced this snapshot. - pub backend: HeartbeatBackend, - /// Time the inventory scope was observed. - pub observed_at: DateTime, - /// Whether this batch is a complete replacement for the scope. Complete - /// batches tombstone previously observed rows in the same scope when they - /// are absent from `resources`. - pub complete: bool, - pub resources: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ObservedResourceSample { - pub deployment_id: Option, - /// Provider-native stable identity: Kubernetes object identity, cloud ARN, - /// GCP full resource name, Azure resource id, etc. - pub raw_identity: String, - /// Provider-native kind, such as `apps/v1/Deployment`, - /// `AWS::S3::Bucket`, `storage.googleapis.com/Bucket`, or an Azure - /// resource type. - pub provider_kind: String, - pub display_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub namespace: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resource_type_hint: Option, - /// Release/version identity observed from the provider resource, when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub alien_resource_id: Option, - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, - pub partial: bool, - pub provider_stale: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub counts: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub collection_issues: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub labels: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub attributes: BTreeMap, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub raw: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub enum HeartbeatBackend { - Aws, - Gcp, - Azure, - Kubernetes, - Local, - Managed, - External, - Test, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum HeartbeatCollectionIssueReason { - Forbidden, - NotInstalled, - ApiUnavailable, - CollectionFailed, - TimedOut, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "resourceType", content = "data")] -pub enum ResourceHeartbeatData { - #[serde(rename = "storage")] - Storage(StorageHeartbeatData), - #[serde(rename = "worker")] - Worker(WorkerHeartbeatData), - #[serde(rename = "container")] - Container(ContainerHeartbeatData), - #[serde(rename = "daemon")] - Daemon(DaemonHeartbeatData), - #[serde(rename = "compute-cluster")] - ComputeCluster(ComputeClusterHeartbeatData), - #[serde(rename = "kubernetes-cluster")] - KubernetesCluster(KubernetesClusterHeartbeatData), - #[serde(rename = "queue")] - Queue(QueueHeartbeatData), - #[serde(rename = "kv")] - Kv(KvHeartbeatData), - #[serde(rename = "postgres")] - Postgres(PostgresHeartbeatData), - #[serde(rename = "vault")] - Vault(VaultHeartbeatData), - #[serde(rename = "service-account")] - ServiceAccount(ServiceAccountHeartbeatData), - #[serde(rename = "network")] - Network(NetworkHeartbeatData), - #[serde(rename = "remote-stack-management")] - RemoteStackManagement(RemoteStackManagementHeartbeatData), - #[serde(rename = "artifact-registry")] - ArtifactRegistry(ArtifactRegistryHeartbeatData), - #[serde(rename = "build")] - Build(BuildHeartbeatData), - #[serde(rename = "service_activation")] - ServiceActivation(ServiceActivationHeartbeatData), - #[serde(rename = "azure_resource_group")] - AzureResourceGroup(AzureResourceGroupHeartbeatData), - #[serde(rename = "azure_storage_account")] - AzureStorageAccount(AzureStorageAccountHeartbeatData), - #[serde(rename = "azure_container_apps_environment")] - AzureContainerAppsEnvironment(AzureContainerAppsEnvironmentHeartbeatData), - #[serde(rename = "azure_service_bus_namespace")] - AzureServiceBusNamespace(AzureServiceBusNamespaceHeartbeatData), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum ObservedHealth { - Unknown, - Healthy, - Degraded, - Unhealthy, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum ProviderLifecycleState { - Unknown, - Creating, - Updating, - Running, - Scaling, - Stopping, - Stopped, - Deleting, - Deleted, - Failed, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct HeartbeatCollectionIssue { - pub source: String, - pub reason: HeartbeatCollectionIssueReason, - pub severity: HeartbeatIssueSeverity, - pub message: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum HeartbeatIssueSeverity { - Info, - Warning, - Error, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesEventSnapshot { - pub reason: String, - #[serde(rename = "type")] - pub type_: Option, - pub message: String, - pub count: Option, - pub first_timestamp: Option>, - pub last_timestamp: Option>, - pub event_time: Option>, - pub source: Option, - pub involved_object: Option, - pub raw: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesEventSource { - pub component: Option, - pub host: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesEventInvolvedObject { - pub kind: Option, - pub namespace: Option, - pub name: Option, - pub uid: Option, - pub api_version: Option, - pub resource_version: Option, - pub field_path: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ManagedRuntimeEventSnapshot { - pub event_id: Option, - pub reason: String, - #[serde(rename = "type")] - pub type_: Option, - pub message: String, - pub count: Option, - pub first_timestamp: Option>, - pub last_timestamp: Option>, - pub event_time: Option>, - pub source: Option, - pub involved_object: Option, - pub details: Option, - pub raw: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ManagedRuntimeEventSource { - pub component: Option, - pub host: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ManagedRuntimeEventInvolvedObject { - pub kind: Option, - pub id: Option, - pub name: Option, - pub replica_id: Option, - pub machine_id: Option, - pub details: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalRuntimeEventSnapshot { - pub timestamp: DateTime, - pub severity: HeartbeatIssueSeverity, - pub kind: String, - pub message: String, - pub subject: Option, - pub raw: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalRuntimeEventSubject { - pub kind: String, - pub id: Option, - pub name: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct RawHeartbeatSnippet { - pub source: String, - pub format: RawHeartbeatSnippetFormat, - pub collected_at: DateTime, - pub body: String, - pub truncated: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum RawHeartbeatSnippetFormat { - Json, - Yaml, - Text, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ObservedCounts { - pub desired: Option, - pub current: Option, - pub ready: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct MetricSample { - pub value: f64, - pub unit: MetricUnit, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum MetricUnit { - Count, - Percent, - Bytes, - Cores, - Milliseconds, - RequestsPerSecond, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum StorageHeartbeatData { - AwsS3(AwsS3StorageHeartbeatData), - GcpCloudStorage(GcpCloudStorageHeartbeatData), - AzureBlob(AzureBlobStorageHeartbeatData), - Local(LocalStorageHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct StorageHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsS3StorageHeartbeatData { - pub status: StorageHeartbeatStatus, - pub name: String, - pub region: Option, - pub bucket_location: Option, - pub versioning_status: Option, - pub versioning_enabled: Option, - pub lifecycle_present: bool, - pub lifecycle_rule_count: Option, - pub encryption_config_present: bool, - pub encryption_enabled: Option, - pub public_access_block_present: bool, - pub block_public_acls: Option, - pub ignore_public_acls: Option, - pub block_public_policy: Option, - pub restrict_public_buckets: Option, - pub bucket_policy_present: Option, - pub bucket_acl_present: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureBlobStorageHeartbeatData { - pub status: StorageHeartbeatStatus, - pub name: String, - pub storage_account_name: Option, - pub resource_group: Option, - pub location: Option, - pub account_kind: Option, - pub sku_name: Option, - pub sku_tier: Option, - pub access_tier: Option, - pub provisioning_state: Option, - pub primary_location: Option, - pub secondary_location: Option, - pub status_of_primary: Option, - pub status_of_secondary: Option, - pub public_network_access: Option, - pub allow_blob_public_access: Option, - pub encryption_key_source: Option, - pub blob_encryption_enabled: Option, - pub file_encryption_enabled: Option, - pub queue_encryption_enabled: Option, - pub table_encryption_enabled: Option, - pub blob_versioning_enabled: Option, - pub blob_delete_retention_enabled: Option, - pub blob_delete_retention_days: Option, - pub container_delete_retention_enabled: Option, - pub container_delete_retention_days: Option, - pub change_feed_enabled: Option, - pub change_feed_retention_days: Option, - pub container_public_access: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpCloudStorageHeartbeatData { - pub status: StorageHeartbeatStatus, - pub name: String, - pub bucket_id: Option, - pub location: Option, - pub location_type: Option, - pub storage_class: Option, - pub versioning_enabled: Option, - pub lifecycle_present: bool, - pub lifecycle_rule_count: Option, - pub retention_policy_effective_time: Option, - pub retention_policy_is_locked: Option, - pub retention_period: Option, - pub soft_delete_retention_duration_seconds: Option, - pub soft_delete_effective_time: Option, - pub uniform_bucket_level_access_enabled: Option, - pub uniform_bucket_level_access_locked_time: Option, - pub public_access_prevention: Option, - pub encryption_config_present: bool, - pub default_kms_key_name: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalStorageHeartbeatData { - pub status: StorageHeartbeatStatus, - pub path: String, - pub path_exists: bool, - pub is_directory: Option, - pub readonly: Option, - pub modified_at: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum WorkerHeartbeatData { - AwsLambda(AwsLambdaWorkerHeartbeatData), - GcpCloudRun(GcpCloudRunWorkerHeartbeatData), - AzureContainerApps(AzureContainerAppsWorkerHeartbeatData), - Kubernetes(KubernetesWorkerHeartbeatData), - Local(LocalWorkerHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum ContainerHeartbeatData { - HorizonPlatform(HorizonContainerHeartbeatData), - Kubernetes(KubernetesContainerHeartbeatData), - Local(LocalContainerHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum DaemonHeartbeatData { - Aws(AwsDaemonHeartbeatData), - Gcp(GcpDaemonHeartbeatData), - Azure(AzureDaemonHeartbeatData), - Machines(MachinesDaemonHeartbeatData), - Kubernetes(KubernetesDaemonHeartbeatData), - Local(LocalDaemonHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct WorkloadHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct WorkloadReplicaStatus { - pub desired: Option, - pub current: Option, - pub ready: Option, - pub available: Option, - pub updated: Option, - pub misscheduled: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsLambdaWorkerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub function_name: String, - pub runtime: Option, - pub package_type: Option, - pub memory_size_mb: Option, - pub timeout_seconds: Option, - pub version: Option, - pub revision_id: Option, - pub last_modified: Option, - pub state: Option, - pub state_reason: Option, - pub state_reason_code: Option, - pub last_update_status: Option, - pub last_update_status_reason: Option, - pub last_update_status_reason_code: Option, - pub code_sha256: Option, - pub layer_count: u32, - pub function_url_auth_type: Option, - pub function_url_cors_present: bool, - pub trigger_count: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpCloudRunWorkerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub service: String, - pub region: Option, - pub uri: Option, - pub urls: Vec, - pub latest_created_revision: Option, - pub latest_ready_revision: Option, - pub generation: Option, - pub observed_generation: Option, - pub traffic_count: u32, - pub min_instance_count: Option, - pub max_instance_count: Option, - pub container_image: Option, - pub cpu_limit: Option, - pub memory_limit: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureContainerAppsWorkerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub app_name: String, - pub revision: Option, - pub environment_name: Option, - pub provisioning_state: Option, - pub running_status: Option, - pub ingress_fqdn: Option, - pub min_replicas: Option, - pub max_replicas: Option, - pub cpu: Option, - pub memory: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesWorkerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub namespace: String, - pub name: String, - pub workload_kind: KubernetesWorkloadKind, - pub replicas: WorkloadReplicaStatus, - pub restarts: Option, - pub cpu: Option, - pub memory: Option, - pub workload: Option, - pub pods: Vec, - pub trigger_count: u32, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalWorkerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub pid: Option, - pub command_supported: bool, - pub image_path_present: bool, - pub readiness_probe_ok: Option, - pub trigger_count: u32, - pub cpu: Option, - pub memory: Option, - pub process: Option, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct HorizonContainerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub container_id: String, - pub image: Option, - #[serde(default)] - pub observed_image: Option, - #[serde(default)] - pub latest_update_timestamp: Option, - pub scheduling_mode: HorizonWorkloadSchedulingMode, - pub replicas: WorkloadReplicaStatus, - pub cpu: Option, - pub memory: Option, - pub attention_count: u32, - pub replica_units: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub enum HorizonWorkloadSchedulingMode { - Replicated, - Stateful, - Daemon, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesContainerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub namespace: String, - pub name: String, - pub workload_kind: KubernetesWorkloadKind, - pub replicas: WorkloadReplicaStatus, - pub restarts: Option, - pub cpu: Option, - pub memory: Option, - pub workload: Option, - pub pods: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalContainerHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub container_id: Option, - pub name: Option, - pub image: Option, - pub runtime_status: Option, - pub restart_count: Option, - pub port_count: u32, - pub bind_mount_count: u32, - pub local_url: Option, - pub runtime_reachable: bool, - pub cpu: Option, - pub memory: Option, - pub container_unit: Option, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsDaemonHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub horizon_cluster_id: String, - #[serde(default)] - pub daemon_name: String, - pub horizon_status: String, - pub horizon_status_reason: Option, - pub horizon_status_message: Option, - pub capacity_group: String, - pub desired_machines: u32, - pub assigned_machines: u32, - pub healthy_instances: u32, - pub unavailable_instances: u32, - pub command_supported: bool, - #[serde(default)] - pub observed_image: Option, - pub latest_update_timestamp: String, - pub daemon_instances: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpDaemonHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub horizon_cluster_id: String, - #[serde(default)] - pub daemon_name: String, - pub horizon_status: String, - pub horizon_status_reason: Option, - pub horizon_status_message: Option, - pub capacity_group: String, - pub desired_machines: u32, - pub assigned_machines: u32, - pub healthy_instances: u32, - pub unavailable_instances: u32, - pub command_supported: bool, - #[serde(default)] - pub observed_image: Option, - pub latest_update_timestamp: String, - pub daemon_instances: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureDaemonHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub horizon_cluster_id: String, - #[serde(default)] - pub daemon_name: String, - pub horizon_status: String, - pub horizon_status_reason: Option, - pub horizon_status_message: Option, - pub capacity_group: String, - pub desired_machines: u32, - pub assigned_machines: u32, - pub healthy_instances: u32, - pub unavailable_instances: u32, - pub command_supported: bool, - #[serde(default)] - pub observed_image: Option, - pub latest_update_timestamp: String, - pub daemon_instances: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct MachinesDaemonHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub horizon_cluster_id: String, - #[serde(default)] - pub daemon_name: String, - pub horizon_status: String, - pub horizon_status_reason: Option, - pub horizon_status_message: Option, - pub capacity_group: String, - pub desired_machines: u32, - pub assigned_machines: u32, - pub healthy_instances: u32, - pub unavailable_instances: u32, - pub command_supported: bool, - #[serde(default)] - pub observed_image: Option, - pub latest_update_timestamp: String, - pub daemon_instances: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ManagedRuntimeUnitStatus { - pub replica_id: String, - pub name: String, - pub machine_id: Option, - pub node_name: Option, - pub ip: Option, - pub ready: bool, - pub phase: Option, - pub status: Option, - pub reason: Option, - pub message: Option, - pub restart_count: Option, - pub waiting_reason: Option, - pub terminated_reason: Option, - pub metrics_healthy: Option, - pub metrics_status: Option, - pub metrics_last_updated: Option, - pub cpu: Option, - pub memory: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesDaemonHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub namespace: String, - pub name: String, - pub replicas: WorkloadReplicaStatus, - pub restarts: Option, - pub command_supported: bool, - pub cpu: Option, - pub memory: Option, - pub workload: Option, - pub pods: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalDaemonHeartbeatData { - pub status: WorkloadHeartbeatStatus, - #[serde(default)] - pub daemon_name: String, - pub runtime_id: String, - pub pid: Option, - pub command_supported: bool, - pub image_path_present: bool, - pub restart_count: Option, - pub exit_reason: Option, - pub daemon_instance: Option, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalRuntimeUnitStatus { - pub unit_id: String, - pub name: String, - pub kind: LocalRuntimeUnitKind, - pub ready: bool, - pub phase: Option, - pub pid: Option, - pub restart_count: Option, - pub cpu: Option, - pub memory: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "kebab-case")] -pub enum LocalRuntimeUnitKind { - Container, - Process, - Daemon, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub enum KubernetesWorkloadKind { - Deployment, - StatefulSet, - DaemonSet, - ReplicaSet, - Pod, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesWorkloadStatus { - pub desired_replicas: Option, - pub ready_replicas: Option, - pub available_replicas: Option, - pub updated_replicas: Option, - pub observed_generation: Option, - pub desired_generation: Option, - pub rollout_reason: Option, - pub conditions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesWorkloadCondition { - #[serde(rename = "type")] - pub condition_type: String, - pub status: String, - pub reason: Option, - pub message: Option, - pub last_transition_time: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesPodRuntimeUnitStatus { - pub name: String, - pub uid: Option, - pub phase: Option, - pub ready: bool, - pub restart_count: u32, - pub waiting_reason: Option, - pub terminated_reason: Option, - pub node_name: Option, - pub pod_ip: Option, - pub owner_references: Vec, - pub cpu: Option, - pub memory: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesOwnerReference { - pub kind: String, - pub name: String, - pub uid: String, - pub controller: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum ComputeClusterHeartbeatData { - Aws(AwsComputeClusterHeartbeatData), - Gcp(GcpComputeClusterHeartbeatData), - Azure(AzureComputeClusterHeartbeatData), - Machines(MachinesComputeClusterHeartbeatData), - Local(LocalComputeClusterHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ComputeClusterHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalComputeClusterHeartbeatData { - pub status: ComputeClusterHeartbeatStatus, - pub nodes: ObservedCounts, - pub name: String, - pub host_identifier: Option, - pub docker_available: bool, - pub docker_version: Option, - pub docker_api_version: Option, - pub docker_os: Option, - pub docker_arch: Option, - pub network_name: Option, - pub network_available: bool, - pub tracked_containers: Option, - pub running_containers: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsComputeClusterHeartbeatData { - pub status: ComputeClusterHeartbeatStatus, - pub nodes: ObservedCounts, - pub cpu: Option, - pub memory: Option, - pub name: String, - pub region: Option, - pub backend_cluster_id: Option, - pub capacity_groups: Vec, - pub provider_fleets: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpComputeClusterHeartbeatData { - pub status: ComputeClusterHeartbeatStatus, - pub nodes: ObservedCounts, - pub cpu: Option, - pub memory: Option, - pub name: String, - pub region: Option, - pub backend_cluster_id: Option, - pub capacity_groups: Vec, - pub provider_fleets: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureComputeClusterHeartbeatData { - pub status: ComputeClusterHeartbeatStatus, - pub nodes: ObservedCounts, - pub cpu: Option, - pub memory: Option, - pub name: String, - pub region: Option, - pub backend_cluster_id: Option, - pub capacity_groups: Vec, - pub provider_fleets: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct MachinesComputeClusterHeartbeatData { - pub status: ComputeClusterHeartbeatStatus, - pub nodes: ObservedCounts, - pub cpu: Option, - pub memory: Option, - pub name: String, - pub backend_cluster_id: Option, - pub capacity_groups: Vec, - pub machines: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct MachinesComputeMachineStatus { - pub machine_id: String, - pub status: String, - pub capacity_group: String, - pub zone: String, - pub public_ip: Option, - pub overlay_ip: Option, - pub last_heartbeat: String, - pub horizond_version: Option, - pub replica_count: i64, - pub cpu_cores: Option, - pub memory_bytes: Option, - pub drain_force: bool, - pub drain_requested_at: Option, - pub drain_deadline_at: Option, - pub drained_at: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub drain_blockers: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ComputeCapacityGroupStatus { - pub group_id: String, - pub current_machines: u32, - pub desired_machines: u32, - pub min_machines: Option, - pub max_machines: Option, - pub instance_type: Option, - pub recommendation: Option, - pub capacity_blocker: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub drain_progress: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub enum ComputeCapacityBlockerCategory { - Quota, - Capacity, - Allocation, - Other, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ComputeCapacityBlocker { - pub category: ComputeCapacityBlockerCategory, - pub provider_code: Option, - pub message: String, - pub provider_reference: Option, - pub observed_at: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ComputeCapacityRecommendation { - pub desired_machines: u32, - pub reason: Option, - pub utilization: Option, - pub unschedulable_replicas: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub enum ComputeDrainProgressStatus { - Draining, - Drained, - Terminating, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ComputeDrainBlocker { - pub workload_name: String, - pub replica_id: String, - pub scheduling_mode: String, - pub state: String, - pub reason: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ComputeDrainProgress { - pub machine_id: String, - pub status: ComputeDrainProgressStatus, - pub replica_count: i64, - pub force: bool, - pub stalled: bool, - pub drain_requested_at: Option, - pub drain_deadline_at: Option, - pub drained_at: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub blockers: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ProviderFleetStatus { - pub group_id: String, - pub provider_id: String, - pub location: Option, - pub current_machines: u32, - pub desired_machines: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesClusterHeartbeatData { - pub status: WorkloadHeartbeatStatus, - pub node_counts: ObservedCounts, - pub pod_counts: ObservedCounts, - pub cpu: Option, - pub memory: Option, - pub name: String, - pub region: Option, - pub namespace: Option, - pub version: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub node_statuses: Vec, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesClusterNodeStatus { - pub name: String, - pub uid: Option, - pub ready: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub conditions: Vec, - pub roles: Vec, - pub labels: BTreeMap, - pub allocatable: KubernetesNodeResources, - pub capacity: KubernetesNodeResources, - pub usage: Option, - pub kubelet_version: Option, - pub container_runtime_version: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesNodeConditionStatus { - pub type_: String, - pub status: String, - pub reason: Option, - pub message: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesNodeResources { - pub cpu: Option, - pub memory: Option, - pub pods: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesNodeUsage { - pub cpu: Option, - pub memory: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum QueueHeartbeatData { - AwsSqs(AwsSqsQueueHeartbeatData), - GcpPubSub(GcpPubSubQueueHeartbeatData), - AzureServiceBus(AzureServiceBusQueueHeartbeatData), - Local(LocalQueueHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct QueueHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -impl Default for QueueHeartbeatStatus { - fn default() -> Self { - Self { - health: ObservedHealth::Unknown, - lifecycle: ProviderLifecycleState::Unknown, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalQueueHeartbeatData { - pub status: QueueHeartbeatStatus, - pub name: String, - pub path: Option, - pub service_status: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpPubSubQueueHeartbeatData { - pub status: QueueHeartbeatStatus, - pub topic_name: String, - pub subscription_name: Option, - pub project_id: Option, - pub topic_full_name: Option, - pub subscription_full_name: Option, - pub endpoint: Option, - pub topic_labels: BTreeMap, - pub subscription_labels: BTreeMap, - pub message_storage_allowed_persistence_regions: Vec, - pub message_storage_enforce_in_transit: Option, - pub kms_key_name: Option, - pub schema_name: Option, - pub schema_encoding: Option, - pub schema_first_revision_id: Option, - pub schema_last_revision_id: Option, - pub topic_message_retention_duration: Option, - pub topic_state: Option, - pub subscription_ack_deadline_seconds: Option, - pub subscription_message_retention_duration: Option, - pub subscription_retain_acked_messages: Option, - pub subscription_enable_message_ordering: Option, - pub subscription_filter: Option, - pub subscription_detached: Option, - pub subscription_state: Option, - pub subscription_push_config_present: Option, - pub subscription_push_endpoint: Option, - pub subscription_push_attributes: BTreeMap, - pub subscription_push_oidc_service_account_email: Option, - pub subscription_push_oidc_audience: Option, - pub subscription_push_pubsub_wrapper_write_metadata: Option, - pub subscription_push_no_wrapper_write_metadata: Option, - pub subscription_dead_letter_topic: Option, - pub subscription_dead_letter_max_delivery_attempts: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureServiceBusQueueHeartbeatData { - pub status: QueueHeartbeatStatus, - pub name: String, - pub namespace_name: String, - pub resource_group: Option, - pub resource_id: Option, - pub endpoint: Option, - pub queue_status: Option, - pub lock_duration: Option, - pub max_delivery_count: Option, - pub requires_duplicate_detection: Option, - pub duplicate_detection_history_time_window: Option, - pub requires_session: Option, - pub dead_lettering_on_message_expiration: Option, - pub forward_dead_lettered_messages_to: Option, - pub forward_to: Option, - pub default_message_time_to_live: Option, - pub auto_delete_on_idle: Option, - pub enable_batched_operations: Option, - pub enable_express: Option, - pub enable_partitioning: Option, - pub max_message_size_in_kilobytes: Option, - pub max_size_in_megabytes: Option, - pub message_count: Option, - pub active_message_count: Option, - pub dead_letter_message_count: Option, - pub scheduled_message_count: Option, - pub transfer_message_count: Option, - pub transfer_dead_letter_message_count: Option, - pub size_in_bytes: Option, - pub accessed_at: Option, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsSqsQueueHeartbeatData { - pub status: QueueHeartbeatStatus, - pub name: String, - pub region: Option, - pub queue_url: Option, - pub queue_arn: Option, - pub visibility_timeout_seconds: Option, - pub message_retention_period_seconds: Option, - pub delay_seconds: Option, - pub receive_message_wait_time_seconds: Option, - pub maximum_message_size: Option, - pub redrive_policy: Option, - pub redrive_allow_policy: Option, - pub fifo_queue: Option, - pub content_based_deduplication: Option, - pub deduplication_scope: Option, - pub fifo_throughput_limit: Option, - pub sse_enabled: Option, - pub kms_master_key_id: Option, - pub kms_data_key_reuse_period_seconds: Option, - pub sqs_managed_sse_enabled: Option, - pub approximate_visible_messages: Option, - pub approximate_in_flight_messages: Option, - pub approximate_delayed_messages: Option, - pub approximate_counts: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum KvHeartbeatData { - AwsDynamoDb(AwsDynamoDbKvHeartbeatData), - GcpFirestore(GcpFirestoreKvHeartbeatData), - AzureTable(AzureTableKvHeartbeatData), - Local(LocalKvHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KvHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -impl Default for KvHeartbeatStatus { - fn default() -> Self { - Self { - health: ObservedHealth::Unknown, - lifecycle: ProviderLifecycleState::Unknown, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsDynamoDbKvHeartbeatData { - pub status: KvHeartbeatStatus, - pub name: String, - pub region: Option, - pub table_arn: Option, - pub table_status: Option, - pub billing_mode: Option, - pub key_schema: Vec, - pub global_secondary_index_count: Option, - pub local_secondary_index_count: Option, - pub item_count: Option, - pub table_size_bytes: Option, - pub stream_enabled: Option, - pub stream_view_type: Option, - pub ttl_status: Option, - pub ttl_attribute_name: Option, - pub deletion_protection_enabled: Option, - pub sse_status: Option, - pub sse_type: Option, - pub table_class: Option, - pub replica_count: Option, - pub restore_in_progress: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsDynamoDbKeySchemaElement { - pub attribute_name: String, - pub key_type: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpFirestoreKvHeartbeatData { - pub status: KvHeartbeatStatus, - pub database_name: String, - pub project_id: Option, - pub endpoint: Option, - pub location_id: Option, - pub database_type: Option, - pub concurrency_mode: Option, - pub app_engine_integration_mode: Option, - pub delete_protection_state: Option, - pub point_in_time_recovery_enablement: Option, - pub version_retention_period: Option, - pub earliest_version_time: Option, - pub create_time: Option, - pub update_time: Option, - pub delete_time: Option, - pub database_edition: Option, - pub cmek_enabled: bool, - pub source_info_present: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureTableKvHeartbeatData { - pub status: KvHeartbeatStatus, - pub table_name: String, - pub storage_account_name: String, - pub resource_group: Option, - pub endpoint: Option, - pub storage_account_resource_id: Option, - pub storage_account_location: Option, - pub storage_account_kind: Option, - pub storage_account_provisioning_state: Option, - pub storage_account_primary_status: Option, - pub table_exists: bool, - pub signed_identifier_count: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalKvHeartbeatData { - pub status: KvHeartbeatStatus, - pub name: String, - pub path: String, - pub path_exists: bool, - pub is_directory: Option, - pub cloud_metadata_supported: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum PostgresHeartbeatData { - /// AWS Aurora Serverless v2 backend. - Aurora(AuroraPostgresHeartbeatData), - /// GCP Cloud SQL backend. - CloudSql(GcpCloudSqlPostgresHeartbeatData), - /// Azure Flexible Server backend. - FlexibleServer(AzureFlexibleServerPostgresHeartbeatData), - /// Local embedded Postgres backend. - Local(LocalPostgresHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct PostgresHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -impl Default for PostgresHeartbeatStatus { - fn default() -> Self { - Self { - health: ObservedHealth::Unknown, - lifecycle: ProviderLifecycleState::Unknown, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalPostgresHeartbeatData { - pub status: PostgresHeartbeatStatus, - pub name: String, - pub port: Option, - pub version: String, - pub process_running: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AuroraPostgresHeartbeatData { - pub status: PostgresHeartbeatStatus, - pub cluster_identifier: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub engine_version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub endpoint: Option, - /// Latest sampled `ServerlessDatabaseCapacity` (ACU). - #[serde(skip_serializing_if = "Option::is_none")] - pub serverless_capacity: Option, - /// True when a `minCapacity: 0` instance has not reached 0 ACU over the observation - /// window — it is silently paying always-on prices (auto-pause verification). - pub never_pauses: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpCloudSqlPostgresHeartbeatData { - pub status: PostgresHeartbeatStatus, - pub instance_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub state: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub database_version: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureFlexibleServerPostgresHeartbeatData { - pub status: PostgresHeartbeatStatus, - pub server_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub state: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum VaultHeartbeatData { - AwsParameterStore(AwsParameterStoreVaultHeartbeatData), - GcpSecretManager(GcpSecretManagerVaultHeartbeatData), - AzureKeyVault(AzureKeyVaultHeartbeatData), - KubernetesSecret(KubernetesSecretVaultHeartbeatData), - Local(LocalVaultHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct VaultHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -impl Default for VaultHeartbeatStatus { - fn default() -> Self { - Self { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsParameterStoreVaultHeartbeatData { - pub status: VaultHeartbeatStatus, - pub account_id: String, - pub region: String, - pub prefix: String, - pub parameter_metadata_sampled: bool, - pub sampled_parameter_count: Option, - pub sampled_secure_string_count: Option, - pub sampled_string_count: Option, - pub sampled_string_list_count: Option, - pub sampled_advanced_tier_count: Option, - pub sampled_kms_key_metadata_present_count: Option, - pub latest_modified_at: Option>, - pub has_more_parameters: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpSecretManagerVaultHeartbeatData { - pub status: VaultHeartbeatStatus, - pub project_id: String, - pub location: String, - pub prefix: String, - pub secret_metadata_listed: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureKeyVaultHeartbeatData { - pub status: VaultHeartbeatStatus, - pub name: String, - pub resource_group: Option, - pub resource_id: Option, - pub location: Option, - pub vault_uri: Option, - pub provisioning_state: Option, - pub sku_family: Option, - pub sku_name: Option, - pub soft_delete_enabled: bool, - pub soft_delete_retention_days: i32, - pub purge_protection_enabled: Option, - pub rbac_authorization_enabled: bool, - pub public_network_access: String, - pub access_policy_count: u32, - pub private_endpoint_connection_count: u32, - pub secret_metadata_listed: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesSecretVaultHeartbeatData { - pub status: VaultHeartbeatStatus, - pub namespace: String, - pub prefix: String, - pub secret_metadata_listed: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalVaultHeartbeatData { - pub status: VaultHeartbeatStatus, - pub path: String, - pub path_exists: bool, - pub is_directory: Option, - pub readonly: Option, - pub modified_at: Option>, - pub secret_metadata_listed: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum ServiceAccountHeartbeatData { - AwsIamRole(AwsIamRoleServiceAccountHeartbeatData), - GcpServiceAccount(GcpServiceAccountHeartbeatData), - AzureManagedIdentity(AzureManagedIdentityServiceAccountHeartbeatData), - Local(LocalServiceAccountHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ServiceAccountHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsIamRoleServiceAccountHeartbeatData { - pub status: ServiceAccountHeartbeatStatus, - pub role_name: String, - pub role_arn: String, - pub role_id: String, - pub path: String, - pub create_date: String, - pub description: Option, - pub max_session_duration: Option, - pub assume_role_policy_present: bool, - pub permissions_boundary_type: Option, - pub permissions_boundary_arn: Option, - pub tag_count: u32, - pub managed_tag_count: u32, - pub attached_policy_count: u32, - pub attached_policy_names: Vec, - pub inline_policy_count: u32, - pub inline_policy_names: Vec, - pub stack_permissions_applied: bool, - pub last_used_date: Option, - pub last_used_region: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpServiceAccountHeartbeatData { - pub status: ServiceAccountHeartbeatStatus, - pub name: Option, - pub project_id: Option, - pub unique_id: Option, - pub email: String, - pub display_name: Option, - pub description: Option, - pub oauth2_client_id: Option, - pub disabled: Option, - pub etag: Option, - pub project_binding_count: u32, - pub project_roles: Vec, - pub service_account_binding_count: u32, - pub service_account_roles: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureManagedIdentityServiceAccountHeartbeatData { - pub status: ServiceAccountHeartbeatStatus, - pub name: String, - pub resource_id: String, - pub resource_group: String, - pub location: String, - pub type_: Option, - pub client_id: Option, - pub principal_id: Option, - pub tenant_id: Option, - pub isolation_scope: Option, - pub managed_tag_count: u32, - pub role_assignment_count: u32, - pub role_assignment_ids: Vec, - pub custom_role_definition_count: u32, - pub custom_role_definition_ids: Vec, - pub stack_permissions_applied: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalServiceAccountHeartbeatData { - pub status: ServiceAccountHeartbeatStatus, - pub identity: String, - pub configured: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum NetworkHeartbeatData { - AwsVpc(AwsVpcNetworkHeartbeatData), - GcpVpc(GcpVpcNetworkHeartbeatData), - AzureVnet(AzureVnetNetworkHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct NetworkHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsVpcNetworkHeartbeatData { - pub status: NetworkHeartbeatStatus, - pub vpc_id: Option, - pub vpc_state: Option, - pub cidr_block: Option, - pub public_subnet_ids: Vec, - pub private_subnet_ids: Vec, - pub availability_zones: Vec, - pub internet_gateway_id: Option, - pub nat_gateway_id: Option, - pub route_table_count: u32, - pub security_group_id: Option, - pub is_byo_vpc: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpVpcNetworkHeartbeatData { - pub status: NetworkHeartbeatStatus, - pub network_name: Option, - pub network_self_link: Option, - pub subnetwork_name: Option, - pub subnetwork_self_link: Option, - pub region: Option, - pub cidr_block: Option, - pub router_name: Option, - pub cloud_nat_name: Option, - pub firewall_name: Option, - pub is_byo_vpc: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureVnetNetworkHeartbeatData { - pub status: NetworkHeartbeatStatus, - pub vnet_name: Option, - pub vnet_resource_id: Option, - pub resource_group: Option, - pub location: Option, - pub cidr_block: Option, - pub public_subnet_name: Option, - pub private_subnet_name: Option, - pub application_gateway_subnet_name: Option, - pub private_endpoint_subnet_name: Option, - pub nat_gateway_id: Option, - pub public_ip_id: Option, - pub nsg_id: Option, - pub is_byo_vnet: bool, - pub last_byo_vnet_verification_error_code: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum RemoteStackManagementHeartbeatData { - AwsIamRole(AwsRemoteStackManagementHeartbeatData), - GcpServiceAccount(GcpRemoteStackManagementHeartbeatData), - AzureManagedIdentity(AzureRemoteStackManagementHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct RemoteStackManagementHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsRemoteStackManagementHeartbeatData { - pub status: RemoteStackManagementHeartbeatStatus, - pub role_name: Option, - pub role_arn: Option, - pub management_permissions_applied: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpRemoteStackManagementHeartbeatData { - pub status: RemoteStackManagementHeartbeatStatus, - pub service_account_email: Option, - pub service_account_unique_id: Option, - pub role_bound: bool, - pub impersonation_granted: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureRemoteStackManagementHeartbeatData { - pub status: RemoteStackManagementHeartbeatStatus, - pub uami_resource_id: Option, - pub uami_client_id: Option, - pub uami_principal_id: Option, - pub tenant_id: Option, - pub fic_name: Option, - pub role_definition_id: Option, - pub role_assignment_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum ArtifactRegistryHeartbeatData { - AwsEcr(AwsEcrArtifactRegistryHeartbeatData), - GcpArtifactRegistry(GcpArtifactRegistryHeartbeatData), - AzureContainerRegistry(AzureContainerRegistryHeartbeatData), - Local(LocalArtifactRegistryHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ArtifactRegistryHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsEcrArtifactRegistryHeartbeatData { - pub status: ArtifactRegistryHeartbeatStatus, - pub registry_id: String, - pub region: String, - pub registry_uri: String, - pub repository_prefix: String, - pub pull_role_arn: Option, - pub push_role_arn: Option, - pub repository_count: u32, - pub repositories_truncated: bool, - pub repositories: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsEcrRepositoryHeartbeatData { - pub repository_arn: String, - pub registry_id: String, - pub repository_name: String, - pub repository_uri: String, - pub created_at: f64, - pub image_tag_mutability: Option, - pub scan_on_push: Option, - pub encryption_type: Option, - pub kms_key_present: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpArtifactRegistryHeartbeatData { - pub status: ArtifactRegistryHeartbeatStatus, - pub project_id: String, - pub location: String, - pub repository_id: String, - pub name: Option, - pub format: Option, - pub mode: Option, - pub description: Option, - pub label_count: u32, - pub cleanup_policy_count: u32, - pub cleanup_policy_dry_run: Option, - pub kms_key_name_present: bool, - pub size_bytes: Option, - pub satisfies_pzs: Option, - pub create_time: Option, - pub update_time: Option, - pub iam_policy_etag_present: bool, - pub iam_binding_count: u32, - pub iam_roles: Vec, - pub pull_service_account_email: Option, - pub push_service_account_email: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureContainerRegistryHeartbeatData { - pub status: ArtifactRegistryHeartbeatStatus, - pub name: String, - pub resource_id: Option, - pub resource_group: String, - pub location: String, - pub type_: Option, - pub login_server: Option, - pub sku_name: String, - pub sku_tier: Option, - pub provisioning_state: Option, - pub admin_user_enabled: bool, - pub anonymous_pull_enabled: bool, - pub public_network_access: String, - pub network_rule_bypass_options: String, - pub network_rule_default_action: Option, - pub ip_rule_count: u32, - pub encryption_status: Option, - pub encryption_key_vault_uri_present: bool, - pub encryption_key_identifier_present: bool, - pub policies_present: bool, - pub policy_count: u32, - pub private_endpoint_connection_count: u32, - pub data_endpoint_enabled: Option, - pub data_endpoint_host_names: Vec, - pub zone_redundancy: String, - pub creation_date: Option, - pub managed_tag_count: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct LocalArtifactRegistryHeartbeatData { - pub status: ArtifactRegistryHeartbeatStatus, - pub registry_url: String, - pub reachable: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum BuildHeartbeatData { - AwsCodeBuild(AwsCodeBuildHeartbeatData), - GcpCloudBuild(GcpCloudBuildHeartbeatData), - AzureContainerApps(AzureContainerAppsBuildHeartbeatData), - KubernetesJob(KubernetesBuildHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct BuildHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AwsCodeBuildHeartbeatData { - pub status: BuildHeartbeatStatus, - pub project_name: String, - pub project_arn: Option, - pub description: Option, - pub source_type: Option, - pub artifacts_type: Option, - pub artifacts_encryption_disabled: Option, - pub environment_type: Option, - pub environment_image: Option, - pub compute_type: Option, - pub image_pull_credentials_type: Option, - pub privileged_mode: Option, - pub environment_variable_count: u32, - pub service_role_present: bool, - pub encryption_key_present: bool, - pub cloud_watch_logs_status: Option, - pub s3_logs_status: Option, - pub timeout_in_minutes: Option, - pub queued_timeout_in_minutes: Option, - pub created: Option, - pub last_modified: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpCloudBuildHeartbeatData { - pub status: BuildHeartbeatStatus, - pub project_id: String, - pub location: String, - pub build_config_id: String, - pub service_account: Option, - pub environment_variable_count: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureContainerAppsBuildHeartbeatData { - pub status: BuildHeartbeatStatus, - pub managed_environment_id: String, - pub resource_group_name: String, - pub managed_identity_id: Option, - pub resource_prefix: Option, - pub environment_variable_count: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct KubernetesBuildHeartbeatData { - pub status: BuildHeartbeatStatus, - pub job_name: String, - pub namespace: String, - pub active: Option, - pub succeeded: Option, - pub failed: Option, - pub start_time: Option>, - pub completion_time: Option>, - pub condition_count: u32, - pub image_digest: Option, - pub events: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(tag = "backend", rename_all = "camelCase")] -pub enum ServiceActivationHeartbeatData { - GcpServiceUsage(GcpServiceUsageActivationHeartbeatData), - AzureResourceProvider(AzureResourceProviderActivationHeartbeatData), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct ServiceActivationHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -impl Default for ServiceActivationHeartbeatStatus { - fn default() -> Self { - Self { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct GcpServiceUsageActivationHeartbeatData { - pub status: ServiceActivationHeartbeatStatus, - pub project_id: String, - pub service_name: String, - pub service_resource_name: Option, - pub title: Option, - pub state: Option, - pub enabled: bool, - pub last_operation_name: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureResourceProviderActivationHeartbeatData { - pub status: ServiceActivationHeartbeatStatus, - pub namespace: String, - pub provider_id: Option, - pub registration_state: Option, - pub registration_policy: Option, - pub resource_type_count: u32, - pub registered: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureResourceGroupHeartbeatData { - pub status: AzureResourceGroupHeartbeatStatus, - pub name: String, - pub resource_id: Option, - pub location: Option, - pub provisioning_state: Option, - pub managed_tags: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureResourceGroupHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -impl Default for AzureResourceGroupHeartbeatStatus { - fn default() -> Self { - Self { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureStorageAccountHeartbeatData { - pub status: StorageHeartbeatStatus, - pub name: String, - pub resource_id: Option, - pub resource_group: Option, - pub location: Option, - pub kind: Option, - pub sku_name: Option, - pub sku_tier: Option, - pub provisioning_state: Option, - pub primary_endpoints: AzureStorageAccountEndpoints, - pub secondary_endpoints: AzureStorageAccountEndpoints, - pub public_network_access: Option, - pub allow_blob_public_access: Option, - pub allow_shared_key_access: Option, - pub minimum_tls_version: Option, - pub supports_https_traffic_only: Option, - pub encryption_key_source: Option, - pub require_infrastructure_encryption: Option, - pub network_default_action: Option, - pub network_bypass: Option, - pub network_ip_rule_count: Option, - pub network_virtual_network_rule_count: Option, - pub network_resource_access_rule_count: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureStorageAccountEndpoints { - pub blob: Option, - pub dfs: Option, - pub file: Option, - pub queue: Option, - pub table: Option, - pub web: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureContainerAppsEnvironmentHeartbeatData { - pub status: AzureContainerAppsEnvironmentHeartbeatStatus, - pub name: String, - pub resource_id: Option, - pub resource_group: Option, - pub location: Option, - pub kind: Option, - pub provisioning_state: Option, - pub default_domain: Option, - pub static_ip: Option, - pub custom_domain_verification_id: Option, - pub infrastructure_resource_group: Option, - pub event_stream_endpoint: Option, - pub zone_redundant: Option, - pub workload_profile_count: u32, - pub workload_profiles: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureContainerAppsEnvironmentHeartbeatStatus { - pub health: ObservedHealth, - pub lifecycle: ProviderLifecycleState, - pub message: Option, - pub stale: bool, - pub partial: bool, - pub collection_issues: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureContainerAppsEnvironmentWorkloadProfile { - pub name: String, - pub workload_profile_type: String, - pub minimum_count: Option, - pub maximum_count: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct AzureServiceBusNamespaceHeartbeatData { - pub status: QueueHeartbeatStatus, - pub name: String, - pub resource_id: Option, - pub resource_group: Option, - pub location: Option, - pub sku_name: Option, - pub sku_tier: Option, - pub sku_capacity: Option, - pub namespace_status: Option, - pub provisioning_state: Option, - pub service_bus_endpoint: Option, - pub metric_id: Option, - pub public_network_access: Option, - pub disable_local_auth: Option, - pub minimum_tls_version: Option, - pub premium_messaging_partitions: Option, - pub private_endpoint_connection_count: u32, - pub zone_redundant: Option, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "camelCase")] -pub struct NamedResourceDetail { - pub name: String, - pub region: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone as _; - use serde_json::json; - - fn observed_at() -> DateTime { - Utc.with_ymd_and_hms(2026, 5, 28, 10, 30, 0).unwrap() - } - - fn workload_status() -> WorkloadHeartbeatStatus { - WorkloadHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - } - } - - fn workload_replicas() -> WorkloadReplicaStatus { - WorkloadReplicaStatus { - desired: Some(2), - current: Some(2), - ready: Some(1), - available: Some(1), - updated: Some(2), - misscheduled: None, - } - } - - fn heartbeat(data: ResourceHeartbeatData, resource_type: &str) -> ResourceHeartbeat { - ResourceHeartbeat { - deployment_id: Some("dep_123".to_string()), - resource_id: "api".to_string(), - resource_type: ResourceType::from(resource_type), - controller_platform: Platform::Kubernetes, - backend: HeartbeatBackend::Kubernetes, - observed_at: observed_at(), - data, - raw: vec![RawHeartbeatSnippet { - source: "kubernetes/apps/v1/deployments/api".to_string(), - format: RawHeartbeatSnippetFormat::Json, - collected_at: observed_at(), - body: r#"{"readyReplicas":1}"#.to_string(), - truncated: false, - }], - } - } - - #[test] - fn resource_heartbeat_roundtrips_managed_resource_data() { - let heartbeat_json = serde_json::to_value(heartbeat( - ResourceHeartbeatData::Container(ContainerHeartbeatData::Kubernetes( - KubernetesContainerHeartbeatData { - status: workload_status(), - namespace: "default".to_string(), - name: "api".to_string(), - workload_kind: KubernetesWorkloadKind::Deployment, - replicas: workload_replicas(), - restarts: None, - cpu: None, - memory: None, - workload: None, - pods: vec![], - events: vec![], - }, - )), - "container", - )) - .unwrap(); - - let parsed: ResourceHeartbeat = serde_json::from_value(heartbeat_json).unwrap(); - assert_eq!(parsed.resource_id, "api"); - assert!(matches!( - parsed.data, - ResourceHeartbeatData::Container(ContainerHeartbeatData::Kubernetes(_)) - )); - } - - #[test] - fn local_daemon_heartbeat_defaults_missing_daemon_name() { - let mut value = - serde_json::to_value(DaemonHeartbeatData::Local(LocalDaemonHeartbeatData { - status: workload_status(), - daemon_name: "agent".to_string(), - runtime_id: "local-agent".to_string(), - pid: None, - command_supported: false, - image_path_present: true, - restart_count: None, - exit_reason: None, - daemon_instance: None, - events: vec![], - })) - .unwrap(); - - value.as_object_mut().unwrap().remove("daemonName"); - - let parsed: DaemonHeartbeatData = serde_json::from_value(value).unwrap(); - match parsed { - DaemonHeartbeatData::Local(data) => assert_eq!(data.daemon_name, ""), - other => panic!("expected local daemon heartbeat, got {other:?}"), - } - } - - #[test] - fn container_heartbeat_serializes_resource_first_data() { - let heartbeat = heartbeat( - ResourceHeartbeatData::Container(ContainerHeartbeatData::Kubernetes( - KubernetesContainerHeartbeatData { - status: workload_status(), - namespace: "default".to_string(), - name: "api".to_string(), - workload_kind: KubernetesWorkloadKind::Deployment, - replicas: workload_replicas(), - restarts: Some(1), - cpu: Some(MetricSample { - value: 0.5, - unit: MetricUnit::Cores, - }), - memory: None, - workload: None, - pods: vec![], - events: vec![], - }, - )), - "container", - ); - - let value = serde_json::to_value(&heartbeat).unwrap(); - - assert_eq!(value["resourceType"], "container"); - assert_eq!(value["data"]["resourceType"], "container"); - assert_eq!(value["data"]["data"]["backend"], "kubernetes"); - assert_eq!(value["raw"][0]["body"], r#"{"readyReplicas":1}"#); - assert!(value.get("collection").is_none()); - assert!(value["data"]["data"].get("summary").is_none()); - assert!(value["data"]["data"].get("detail").is_none()); - } - - #[test] - fn representative_workload_data_has_stable_tags() { - let daemon = serde_json::to_value(ResourceHeartbeatData::Daemon( - DaemonHeartbeatData::Kubernetes(KubernetesDaemonHeartbeatData { - status: workload_status(), - namespace: "default".to_string(), - name: "agent".to_string(), - replicas: workload_replicas(), - restarts: Some(0), - command_supported: true, - cpu: None, - memory: None, - workload: None, - pods: vec![], - events: vec![], - }), - )) - .unwrap(); - let worker = serde_json::to_value(ResourceHeartbeatData::Worker( - WorkerHeartbeatData::AwsLambda(AwsLambdaWorkerHeartbeatData { - status: workload_status(), - function_name: "handler".to_string(), - runtime: Some("nodejs22.x".to_string()), - package_type: None, - memory_size_mb: None, - timeout_seconds: None, - version: None, - revision_id: None, - last_modified: None, - state: None, - state_reason: None, - state_reason_code: None, - last_update_status: None, - last_update_status_reason: None, - last_update_status_reason_code: None, - code_sha256: None, - layer_count: 0, - function_url_auth_type: None, - function_url_cors_present: false, - trigger_count: 0, - }), - )) - .unwrap(); - - assert_eq!(daemon["resourceType"], "daemon"); - assert_eq!(daemon["data"]["backend"], "kubernetes"); - assert_eq!(worker["resourceType"], "worker"); - assert_eq!(worker["data"]["backend"], "awsLambda"); - } - - #[test] - fn representative_cluster_and_data_variants_have_optional_counts() { - let cluster = serde_json::to_value(ResourceHeartbeatData::KubernetesCluster( - KubernetesClusterHeartbeatData { - status: WorkloadHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - }, - node_counts: ObservedCounts { - desired: Some(3), - current: Some(3), - ready: None, - }, - pod_counts: ObservedCounts { - desired: None, - current: Some(12), - ready: Some(11), - }, - cpu: None, - memory: None, - name: "prod".to_string(), - region: Some("us-east-1".to_string()), - namespace: None, - version: Some("1.33".to_string()), - node_statuses: vec![], - events: vec![], - }, - )) - .unwrap(); - let queue = serde_json::to_value(ResourceHeartbeatData::Queue(QueueHeartbeatData::AwsSqs( - AwsSqsQueueHeartbeatData { - status: QueueHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - }, - name: "jobs".to_string(), - region: Some("us-east-1".to_string()), - queue_url: Some("https://sqs.us-east-1.amazonaws.com/123/jobs".to_string()), - queue_arn: Some("arn:aws:sqs:us-east-1:123:jobs".to_string()), - visibility_timeout_seconds: Some(30), - message_retention_period_seconds: Some(345600), - delay_seconds: Some(0), - receive_message_wait_time_seconds: Some(0), - maximum_message_size: Some(262144), - redrive_policy: None, - redrive_allow_policy: None, - fifo_queue: Some(false), - content_based_deduplication: None, - deduplication_scope: None, - fifo_throughput_limit: None, - sse_enabled: Some(false), - kms_master_key_id: None, - kms_data_key_reuse_period_seconds: None, - sqs_managed_sse_enabled: Some(false), - approximate_visible_messages: Some(42), - approximate_in_flight_messages: Some(1), - approximate_delayed_messages: Some(0), - approximate_counts: true, - }, - ))) - .unwrap(); - let storage = serde_json::to_value(ResourceHeartbeatData::Storage( - StorageHeartbeatData::AwsS3(AwsS3StorageHeartbeatData { - status: StorageHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - }, - name: "assets".to_string(), - region: Some("us-east-1".to_string()), - bucket_location: Some("us-east-1".to_string()), - versioning_status: Some("Enabled".to_string()), - versioning_enabled: Some(true), - lifecycle_present: false, - lifecycle_rule_count: Some(0), - encryption_config_present: true, - encryption_enabled: Some(true), - public_access_block_present: true, - block_public_acls: Some(true), - ignore_public_acls: Some(true), - block_public_policy: Some(true), - restrict_public_buckets: Some(true), - bucket_policy_present: Some(false), - bucket_acl_present: Some(true), - }), - )) - .unwrap(); - let gcp_storage = serde_json::to_value(ResourceHeartbeatData::Storage( - StorageHeartbeatData::GcpCloudStorage(GcpCloudStorageHeartbeatData { - status: StorageHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - }, - name: "assets".to_string(), - bucket_id: Some("project/assets".to_string()), - location: Some("US".to_string()), - location_type: Some("multi-region".to_string()), - storage_class: Some("STANDARD".to_string()), - versioning_enabled: Some(true), - lifecycle_present: false, - lifecycle_rule_count: Some(0), - retention_policy_effective_time: None, - retention_policy_is_locked: None, - retention_period: None, - soft_delete_retention_duration_seconds: None, - soft_delete_effective_time: None, - uniform_bucket_level_access_enabled: Some(true), - uniform_bucket_level_access_locked_time: None, - public_access_prevention: Some("enforced".to_string()), - encryption_config_present: true, - default_kms_key_name: Some( - "projects/p/locations/l/keyRings/r/cryptoKeys/k".to_string(), - ), - }), - )) - .unwrap(); - let kv = serde_json::to_value(ResourceHeartbeatData::Kv(KvHeartbeatData::AwsDynamoDb( - AwsDynamoDbKvHeartbeatData { - status: KvHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: None, - stale: false, - partial: false, - collection_issues: vec![], - }, - name: "state".to_string(), - region: Some("us-east-1".to_string()), - table_arn: None, - table_status: Some("ACTIVE".to_string()), - billing_mode: Some("PAY_PER_REQUEST".to_string()), - key_schema: vec![AwsDynamoDbKeySchemaElement { - attribute_name: "pk".to_string(), - key_type: "HASH".to_string(), - }], - global_secondary_index_count: Some(0), - local_secondary_index_count: Some(0), - item_count: None, - table_size_bytes: None, - stream_enabled: Some(false), - stream_view_type: None, - ttl_status: Some("ENABLED".to_string()), - ttl_attribute_name: Some("ttl".to_string()), - deletion_protection_enabled: Some(false), - sse_status: Some("ENABLED".to_string()), - sse_type: Some("KMS".to_string()), - table_class: None, - replica_count: Some(0), - restore_in_progress: None, - }, - ))) - .unwrap(); - - assert_eq!(cluster["resourceType"], "kubernetes-cluster"); - assert!(cluster["data"].get("summary").is_none()); - assert!(cluster["data"].get("detail").is_none()); - assert_eq!(cluster["data"]["name"], "prod"); - assert_eq!(queue["data"]["backend"], "awsSqs"); - assert_eq!(queue["data"]["approximateVisibleMessages"], 42); - assert!(queue["data"].get("summary").is_none()); - assert_eq!(storage["data"]["backend"], "awsS3"); - assert!(storage["data"].get("summary").is_none()); - assert_eq!(gcp_storage["data"]["backend"], "gcpCloudStorage"); - assert_eq!(gcp_storage["data"]["publicAccessPrevention"], "enforced"); - assert_eq!(kv["data"]["backend"], "awsDynamoDb"); - assert!(kv["data"].get("summary").is_none()); - assert_eq!(kv["data"]["itemCount"], json!(null)); - } -} diff --git a/crates/alien-core/src/heartbeat/azure_platform.rs b/crates/alien-core/src/heartbeat/azure_platform.rs new file mode 100644 index 000000000..417c794be --- /dev/null +++ b/crates/alien-core/src/heartbeat/azure_platform.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; + +use super::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureResourceGroupHeartbeatData { + pub status: AzureResourceGroupHeartbeatStatus, + pub name: String, + pub resource_id: Option, + pub location: Option, + pub provisioning_state: Option, + pub managed_tags: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureResourceGroupHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for AzureResourceGroupHeartbeatStatus { + fn default() -> Self { + Self { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureStorageAccountHeartbeatData { + pub status: StorageHeartbeatStatus, + pub name: String, + pub resource_id: Option, + pub resource_group: Option, + pub location: Option, + pub kind: Option, + pub sku_name: Option, + pub sku_tier: Option, + pub provisioning_state: Option, + pub primary_endpoints: AzureStorageAccountEndpoints, + pub secondary_endpoints: AzureStorageAccountEndpoints, + pub public_network_access: Option, + pub allow_blob_public_access: Option, + pub allow_shared_key_access: Option, + pub minimum_tls_version: Option, + pub supports_https_traffic_only: Option, + pub encryption_key_source: Option, + pub require_infrastructure_encryption: Option, + pub network_default_action: Option, + pub network_bypass: Option, + pub network_ip_rule_count: Option, + pub network_virtual_network_rule_count: Option, + pub network_resource_access_rule_count: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureStorageAccountEndpoints { + pub blob: Option, + pub dfs: Option, + pub file: Option, + pub queue: Option, + pub table: Option, + pub web: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureContainerAppsEnvironmentHeartbeatData { + pub status: AzureContainerAppsEnvironmentHeartbeatStatus, + pub name: String, + pub resource_id: Option, + pub resource_group: Option, + pub location: Option, + pub kind: Option, + pub provisioning_state: Option, + pub default_domain: Option, + pub static_ip: Option, + pub custom_domain_verification_id: Option, + pub infrastructure_resource_group: Option, + pub event_stream_endpoint: Option, + pub zone_redundant: Option, + pub workload_profile_count: u32, + pub workload_profiles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureContainerAppsEnvironmentHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureContainerAppsEnvironmentWorkloadProfile { + pub name: String, + pub workload_profile_type: String, + pub minimum_count: Option, + pub maximum_count: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureServiceBusNamespaceHeartbeatData { + pub status: QueueHeartbeatStatus, + pub name: String, + pub resource_id: Option, + pub resource_group: Option, + pub location: Option, + pub sku_name: Option, + pub sku_tier: Option, + pub sku_capacity: Option, + pub namespace_status: Option, + pub provisioning_state: Option, + pub service_bus_endpoint: Option, + pub metric_id: Option, + pub public_network_access: Option, + pub disable_local_auth: Option, + pub minimum_tls_version: Option, + pub premium_messaging_partitions: Option, + pub private_endpoint_connection_count: u32, + pub zone_redundant: Option, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/crates/alien-core/src/heartbeat/cluster.rs b/crates/alien-core/src/heartbeat/cluster.rs new file mode 100644 index 000000000..e70fee5b0 --- /dev/null +++ b/crates/alien-core/src/heartbeat/cluster.rs @@ -0,0 +1,286 @@ +use std::collections::BTreeMap; + +use super::*; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum ComputeClusterHeartbeatData { + Aws(AwsComputeClusterHeartbeatData), + Gcp(GcpComputeClusterHeartbeatData), + Azure(AzureComputeClusterHeartbeatData), + Machines(MachinesComputeClusterHeartbeatData), + Local(LocalComputeClusterHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ComputeClusterHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalComputeClusterHeartbeatData { + pub status: ComputeClusterHeartbeatStatus, + pub nodes: ObservedCounts, + pub name: String, + pub host_identifier: Option, + pub docker_available: bool, + pub docker_version: Option, + pub docker_api_version: Option, + pub docker_os: Option, + pub docker_arch: Option, + pub network_name: Option, + pub network_available: bool, + pub tracked_containers: Option, + pub running_containers: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsComputeClusterHeartbeatData { + pub status: ComputeClusterHeartbeatStatus, + pub nodes: ObservedCounts, + pub cpu: Option, + pub memory: Option, + pub name: String, + pub region: Option, + pub backend_cluster_id: Option, + pub capacity_groups: Vec, + pub provider_fleets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpComputeClusterHeartbeatData { + pub status: ComputeClusterHeartbeatStatus, + pub nodes: ObservedCounts, + pub cpu: Option, + pub memory: Option, + pub name: String, + pub region: Option, + pub backend_cluster_id: Option, + pub capacity_groups: Vec, + pub provider_fleets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureComputeClusterHeartbeatData { + pub status: ComputeClusterHeartbeatStatus, + pub nodes: ObservedCounts, + pub cpu: Option, + pub memory: Option, + pub name: String, + pub region: Option, + pub backend_cluster_id: Option, + pub capacity_groups: Vec, + pub provider_fleets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct MachinesComputeClusterHeartbeatData { + pub status: ComputeClusterHeartbeatStatus, + pub nodes: ObservedCounts, + pub cpu: Option, + pub memory: Option, + pub name: String, + pub backend_cluster_id: Option, + pub capacity_groups: Vec, + pub machines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct MachinesComputeMachineStatus { + pub machine_id: String, + pub status: String, + pub capacity_group: String, + pub zone: String, + pub public_ip: Option, + pub overlay_ip: Option, + pub last_heartbeat: String, + pub horizond_version: Option, + pub replica_count: i64, + pub cpu_cores: Option, + pub memory_bytes: Option, + pub drain_force: bool, + pub drain_requested_at: Option, + pub drain_deadline_at: Option, + pub drained_at: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub drain_blockers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ComputeCapacityGroupStatus { + pub group_id: String, + pub current_machines: u32, + pub desired_machines: u32, + pub min_machines: Option, + pub max_machines: Option, + pub instance_type: Option, + pub recommendation: Option, + pub capacity_blocker: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub drain_progress: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub enum ComputeCapacityBlockerCategory { + Quota, + Capacity, + Allocation, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ComputeCapacityBlocker { + pub category: ComputeCapacityBlockerCategory, + pub provider_code: Option, + pub message: String, + pub provider_reference: Option, + pub observed_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ComputeCapacityRecommendation { + pub desired_machines: u32, + pub reason: Option, + pub utilization: Option, + pub unschedulable_replicas: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub enum ComputeDrainProgressStatus { + Draining, + Drained, + Terminating, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ComputeDrainBlocker { + pub workload_name: String, + pub replica_id: String, + pub scheduling_mode: String, + pub state: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ComputeDrainProgress { + pub machine_id: String, + pub status: ComputeDrainProgressStatus, + pub replica_count: i64, + pub force: bool, + pub stalled: bool, + pub drain_requested_at: Option, + pub drain_deadline_at: Option, + pub drained_at: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blockers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ProviderFleetStatus { + pub group_id: String, + pub provider_id: String, + pub location: Option, + pub current_machines: u32, + pub desired_machines: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesClusterHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub node_counts: ObservedCounts, + pub pod_counts: ObservedCounts, + pub cpu: Option, + pub memory: Option, + pub name: String, + pub region: Option, + pub namespace: Option, + pub version: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub node_statuses: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesClusterNodeStatus { + pub name: String, + pub uid: Option, + pub ready: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditions: Vec, + pub roles: Vec, + pub labels: BTreeMap, + pub allocatable: KubernetesNodeResources, + pub capacity: KubernetesNodeResources, + pub usage: Option, + pub kubelet_version: Option, + pub container_runtime_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesNodeConditionStatus { + pub type_: String, + pub status: String, + pub reason: Option, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesNodeResources { + pub cpu: Option, + pub memory: Option, + pub pods: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesNodeUsage { + pub cpu: Option, + pub memory: Option, +} diff --git a/crates/alien-core/src/heartbeat/data_services.rs b/crates/alien-core/src/heartbeat/data_services.rs new file mode 100644 index 000000000..ceab1e662 --- /dev/null +++ b/crates/alien-core/src/heartbeat/data_services.rs @@ -0,0 +1,486 @@ +use std::collections::BTreeMap; + +use super::*; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum QueueHeartbeatData { + AwsSqs(AwsSqsQueueHeartbeatData), + GcpPubSub(GcpPubSubQueueHeartbeatData), + AzureServiceBus(AzureServiceBusQueueHeartbeatData), + Local(LocalQueueHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct QueueHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for QueueHeartbeatStatus { + fn default() -> Self { + Self { + health: ObservedHealth::Unknown, + lifecycle: ProviderLifecycleState::Unknown, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalQueueHeartbeatData { + pub status: QueueHeartbeatStatus, + pub name: String, + pub path: Option, + pub service_status: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpPubSubQueueHeartbeatData { + pub status: QueueHeartbeatStatus, + pub topic_name: String, + pub subscription_name: Option, + pub project_id: Option, + pub topic_full_name: Option, + pub subscription_full_name: Option, + pub endpoint: Option, + pub topic_labels: BTreeMap, + pub subscription_labels: BTreeMap, + pub message_storage_allowed_persistence_regions: Vec, + pub message_storage_enforce_in_transit: Option, + pub kms_key_name: Option, + pub schema_name: Option, + pub schema_encoding: Option, + pub schema_first_revision_id: Option, + pub schema_last_revision_id: Option, + pub topic_message_retention_duration: Option, + pub topic_state: Option, + pub subscription_ack_deadline_seconds: Option, + pub subscription_message_retention_duration: Option, + pub subscription_retain_acked_messages: Option, + pub subscription_enable_message_ordering: Option, + pub subscription_filter: Option, + pub subscription_detached: Option, + pub subscription_state: Option, + pub subscription_push_config_present: Option, + pub subscription_push_endpoint: Option, + pub subscription_push_attributes: BTreeMap, + pub subscription_push_oidc_service_account_email: Option, + pub subscription_push_oidc_audience: Option, + pub subscription_push_pubsub_wrapper_write_metadata: Option, + pub subscription_push_no_wrapper_write_metadata: Option, + pub subscription_dead_letter_topic: Option, + pub subscription_dead_letter_max_delivery_attempts: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureServiceBusQueueHeartbeatData { + pub status: QueueHeartbeatStatus, + pub name: String, + pub namespace_name: String, + pub resource_group: Option, + pub resource_id: Option, + pub endpoint: Option, + pub queue_status: Option, + pub lock_duration: Option, + pub max_delivery_count: Option, + pub requires_duplicate_detection: Option, + pub duplicate_detection_history_time_window: Option, + pub requires_session: Option, + pub dead_lettering_on_message_expiration: Option, + pub forward_dead_lettered_messages_to: Option, + pub forward_to: Option, + pub default_message_time_to_live: Option, + pub auto_delete_on_idle: Option, + pub enable_batched_operations: Option, + pub enable_express: Option, + pub enable_partitioning: Option, + pub max_message_size_in_kilobytes: Option, + pub max_size_in_megabytes: Option, + pub message_count: Option, + pub active_message_count: Option, + pub dead_letter_message_count: Option, + pub scheduled_message_count: Option, + pub transfer_message_count: Option, + pub transfer_dead_letter_message_count: Option, + pub size_in_bytes: Option, + pub accessed_at: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsSqsQueueHeartbeatData { + pub status: QueueHeartbeatStatus, + pub name: String, + pub region: Option, + pub queue_url: Option, + pub queue_arn: Option, + pub visibility_timeout_seconds: Option, + pub message_retention_period_seconds: Option, + pub delay_seconds: Option, + pub receive_message_wait_time_seconds: Option, + pub maximum_message_size: Option, + pub redrive_policy: Option, + pub redrive_allow_policy: Option, + pub fifo_queue: Option, + pub content_based_deduplication: Option, + pub deduplication_scope: Option, + pub fifo_throughput_limit: Option, + pub sse_enabled: Option, + pub kms_master_key_id: Option, + pub kms_data_key_reuse_period_seconds: Option, + pub sqs_managed_sse_enabled: Option, + pub approximate_visible_messages: Option, + pub approximate_in_flight_messages: Option, + pub approximate_delayed_messages: Option, + pub approximate_counts: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum KvHeartbeatData { + AwsDynamoDb(AwsDynamoDbKvHeartbeatData), + GcpFirestore(GcpFirestoreKvHeartbeatData), + AzureTable(AzureTableKvHeartbeatData), + Local(LocalKvHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KvHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for KvHeartbeatStatus { + fn default() -> Self { + Self { + health: ObservedHealth::Unknown, + lifecycle: ProviderLifecycleState::Unknown, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsDynamoDbKvHeartbeatData { + pub status: KvHeartbeatStatus, + pub name: String, + pub region: Option, + pub table_arn: Option, + pub table_status: Option, + pub billing_mode: Option, + pub key_schema: Vec, + pub global_secondary_index_count: Option, + pub local_secondary_index_count: Option, + pub item_count: Option, + pub table_size_bytes: Option, + pub stream_enabled: Option, + pub stream_view_type: Option, + pub ttl_status: Option, + pub ttl_attribute_name: Option, + pub deletion_protection_enabled: Option, + pub sse_status: Option, + pub sse_type: Option, + pub table_class: Option, + pub replica_count: Option, + pub restore_in_progress: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsDynamoDbKeySchemaElement { + pub attribute_name: String, + pub key_type: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpFirestoreKvHeartbeatData { + pub status: KvHeartbeatStatus, + pub database_name: String, + pub project_id: Option, + pub endpoint: Option, + pub location_id: Option, + pub database_type: Option, + pub concurrency_mode: Option, + pub app_engine_integration_mode: Option, + pub delete_protection_state: Option, + pub point_in_time_recovery_enablement: Option, + pub version_retention_period: Option, + pub earliest_version_time: Option, + pub create_time: Option, + pub update_time: Option, + pub delete_time: Option, + pub database_edition: Option, + pub cmek_enabled: bool, + pub source_info_present: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureTableKvHeartbeatData { + pub status: KvHeartbeatStatus, + pub table_name: String, + pub storage_account_name: String, + pub resource_group: Option, + pub endpoint: Option, + pub storage_account_resource_id: Option, + pub storage_account_location: Option, + pub storage_account_kind: Option, + pub storage_account_provisioning_state: Option, + pub storage_account_primary_status: Option, + pub table_exists: bool, + pub signed_identifier_count: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalKvHeartbeatData { + pub status: KvHeartbeatStatus, + pub name: String, + pub path: String, + pub path_exists: bool, + pub is_directory: Option, + pub cloud_metadata_supported: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum PostgresHeartbeatData { + /// AWS Aurora Serverless v2 backend. + Aurora(AuroraPostgresHeartbeatData), + /// GCP Cloud SQL backend. + CloudSql(GcpCloudSqlPostgresHeartbeatData), + /// Azure Flexible Server backend. + FlexibleServer(AzureFlexibleServerPostgresHeartbeatData), + /// Local embedded Postgres backend. + Local(LocalPostgresHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PostgresHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for PostgresHeartbeatStatus { + fn default() -> Self { + Self { + health: ObservedHealth::Unknown, + lifecycle: ProviderLifecycleState::Unknown, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalPostgresHeartbeatData { + pub status: PostgresHeartbeatStatus, + pub name: String, + pub port: Option, + pub version: String, + pub process_running: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AuroraPostgresHeartbeatData { + pub status: PostgresHeartbeatStatus, + pub cluster_identifier: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub engine_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + /// Latest sampled `ServerlessDatabaseCapacity` (ACU). + #[serde(skip_serializing_if = "Option::is_none")] + pub serverless_capacity: Option, + /// True when a `minCapacity: 0` instance has not reached 0 ACU over the observation + /// window — it is silently paying always-on prices (auto-pause verification). + pub never_pauses: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpCloudSqlPostgresHeartbeatData { + pub status: PostgresHeartbeatStatus, + pub instance_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub database_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureFlexibleServerPostgresHeartbeatData { + pub status: PostgresHeartbeatStatus, + pub server_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum VaultHeartbeatData { + AwsParameterStore(AwsParameterStoreVaultHeartbeatData), + GcpSecretManager(GcpSecretManagerVaultHeartbeatData), + AzureKeyVault(AzureKeyVaultHeartbeatData), + KubernetesSecret(KubernetesSecretVaultHeartbeatData), + Local(LocalVaultHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct VaultHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for VaultHeartbeatStatus { + fn default() -> Self { + Self { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsParameterStoreVaultHeartbeatData { + pub status: VaultHeartbeatStatus, + pub account_id: String, + pub region: String, + pub prefix: String, + pub parameter_metadata_sampled: bool, + pub sampled_parameter_count: Option, + pub sampled_secure_string_count: Option, + pub sampled_string_count: Option, + pub sampled_string_list_count: Option, + pub sampled_advanced_tier_count: Option, + pub sampled_kms_key_metadata_present_count: Option, + pub latest_modified_at: Option>, + pub has_more_parameters: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpSecretManagerVaultHeartbeatData { + pub status: VaultHeartbeatStatus, + pub project_id: String, + pub location: String, + pub prefix: String, + pub secret_metadata_listed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureKeyVaultHeartbeatData { + pub status: VaultHeartbeatStatus, + pub name: String, + pub resource_group: Option, + pub resource_id: Option, + pub location: Option, + pub vault_uri: Option, + pub provisioning_state: Option, + pub sku_family: Option, + pub sku_name: Option, + pub soft_delete_enabled: bool, + pub soft_delete_retention_days: i32, + pub purge_protection_enabled: Option, + pub rbac_authorization_enabled: bool, + pub public_network_access: String, + pub access_policy_count: u32, + pub private_endpoint_connection_count: u32, + pub secret_metadata_listed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesSecretVaultHeartbeatData { + pub status: VaultHeartbeatStatus, + pub namespace: String, + pub prefix: String, + pub secret_metadata_listed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalVaultHeartbeatData { + pub status: VaultHeartbeatStatus, + pub path: String, + pub path_exists: bool, + pub is_directory: Option, + pub readonly: Option, + pub modified_at: Option>, + pub secret_metadata_listed: bool, +} diff --git a/crates/alien-core/src/heartbeat/mod.rs b/crates/alien-core/src/heartbeat/mod.rs new file mode 100644 index 000000000..d3426885d --- /dev/null +++ b/crates/alien-core/src/heartbeat/mod.rs @@ -0,0 +1,378 @@ +use std::collections::BTreeMap; + +use crate::{Platform, ResourceType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +mod azure_platform; +mod cluster; +mod data_services; +mod platform; +mod registry_build; +mod storage; +mod workload; + +#[cfg(test)] +mod tests; + +pub use azure_platform::*; +pub use cluster::*; +pub use data_services::*; +pub use platform::*; +pub use registry_build::*; +pub use storage::*; +pub use workload::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ResourceHeartbeat { + pub deployment_id: Option, + /// Alien resource id, such as the `alien.Container` or `alien.Storage` + /// resource id from the stack. + pub resource_id: String, + pub resource_type: ResourceType, + pub controller_platform: Platform, + pub backend: HeartbeatBackend, + pub observed_at: DateTime, + pub data: ResourceHeartbeatData, + pub raw: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ObservedInventoryBatch { + /// Writer/source for this inventory pass, such as `operator` or + /// `manager-observer`. + pub source_kind: String, + /// Stable scope for the provider list operation that produced this batch. + pub inventory_scope: String, + /// Platform whose observer produced this snapshot. + pub controller_platform: Platform, + /// Backend whose observer produced this snapshot. + pub backend: HeartbeatBackend, + /// Time the inventory scope was observed. + pub observed_at: DateTime, + /// Whether this batch is a complete replacement for the scope. Complete + /// batches tombstone previously observed rows in the same scope when they + /// are absent from `resources`. + pub complete: bool, + pub resources: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ObservedResourceSample { + pub deployment_id: Option, + /// Provider-native stable identity: Kubernetes object identity, cloud ARN, + /// GCP full resource name, Azure resource id, etc. + pub raw_identity: String, + /// Provider-native kind, such as `apps/v1/Deployment`, + /// `AWS::S3::Bucket`, `storage.googleapis.com/Bucket`, or an Azure + /// resource type. + pub provider_kind: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_type_hint: Option, + /// Release/version identity observed from the provider resource, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alien_resource_id: Option, + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + pub partial: bool, + pub provider_stale: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub counts: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub collection_issues: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub labels: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub attributes: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub raw: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub enum HeartbeatBackend { + Aws, + Gcp, + Azure, + Kubernetes, + Local, + Managed, + External, + Test, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum HeartbeatCollectionIssueReason { + Forbidden, + NotInstalled, + ApiUnavailable, + CollectionFailed, + TimedOut, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "resourceType", content = "data")] +pub enum ResourceHeartbeatData { + #[serde(rename = "storage")] + Storage(StorageHeartbeatData), + #[serde(rename = "worker")] + Worker(WorkerHeartbeatData), + #[serde(rename = "container")] + Container(ContainerHeartbeatData), + #[serde(rename = "daemon")] + Daemon(DaemonHeartbeatData), + #[serde(rename = "compute-cluster")] + ComputeCluster(ComputeClusterHeartbeatData), + #[serde(rename = "kubernetes-cluster")] + KubernetesCluster(KubernetesClusterHeartbeatData), + #[serde(rename = "queue")] + Queue(QueueHeartbeatData), + #[serde(rename = "kv")] + Kv(KvHeartbeatData), + #[serde(rename = "postgres")] + Postgres(PostgresHeartbeatData), + #[serde(rename = "vault")] + Vault(VaultHeartbeatData), + #[serde(rename = "service-account")] + ServiceAccount(ServiceAccountHeartbeatData), + #[serde(rename = "network")] + Network(NetworkHeartbeatData), + #[serde(rename = "remote-stack-management")] + RemoteStackManagement(RemoteStackManagementHeartbeatData), + #[serde(rename = "artifact-registry")] + ArtifactRegistry(ArtifactRegistryHeartbeatData), + #[serde(rename = "build")] + Build(BuildHeartbeatData), + #[serde(rename = "service_activation")] + ServiceActivation(ServiceActivationHeartbeatData), + #[serde(rename = "azure_resource_group")] + AzureResourceGroup(AzureResourceGroupHeartbeatData), + #[serde(rename = "azure_storage_account")] + AzureStorageAccount(AzureStorageAccountHeartbeatData), + #[serde(rename = "azure_container_apps_environment")] + AzureContainerAppsEnvironment(AzureContainerAppsEnvironmentHeartbeatData), + #[serde(rename = "azure_service_bus_namespace")] + AzureServiceBusNamespace(AzureServiceBusNamespaceHeartbeatData), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum ObservedHealth { + Unknown, + Healthy, + Degraded, + Unhealthy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum ProviderLifecycleState { + Unknown, + Creating, + Updating, + Running, + Scaling, + Stopping, + Stopped, + Deleting, + Deleted, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct HeartbeatCollectionIssue { + pub source: String, + pub reason: HeartbeatCollectionIssueReason, + pub severity: HeartbeatIssueSeverity, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum HeartbeatIssueSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesEventSnapshot { + pub reason: String, + #[serde(rename = "type")] + pub type_: Option, + pub message: String, + pub count: Option, + pub first_timestamp: Option>, + pub last_timestamp: Option>, + pub event_time: Option>, + pub source: Option, + pub involved_object: Option, + pub raw: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesEventSource { + pub component: Option, + pub host: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesEventInvolvedObject { + pub kind: Option, + pub namespace: Option, + pub name: Option, + pub uid: Option, + pub api_version: Option, + pub resource_version: Option, + pub field_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ManagedRuntimeEventSnapshot { + pub event_id: Option, + pub reason: String, + #[serde(rename = "type")] + pub type_: Option, + pub message: String, + pub count: Option, + pub first_timestamp: Option>, + pub last_timestamp: Option>, + pub event_time: Option>, + pub source: Option, + pub involved_object: Option, + pub details: Option, + pub raw: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ManagedRuntimeEventSource { + pub component: Option, + pub host: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ManagedRuntimeEventInvolvedObject { + pub kind: Option, + pub id: Option, + pub name: Option, + pub replica_id: Option, + pub machine_id: Option, + pub details: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalRuntimeEventSnapshot { + pub timestamp: DateTime, + pub severity: HeartbeatIssueSeverity, + pub kind: String, + pub message: String, + pub subject: Option, + pub raw: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalRuntimeEventSubject { + pub kind: String, + pub id: Option, + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct RawHeartbeatSnippet { + pub source: String, + pub format: RawHeartbeatSnippetFormat, + pub collected_at: DateTime, + pub body: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum RawHeartbeatSnippetFormat { + Json, + Yaml, + Text, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ObservedCounts { + pub desired: Option, + pub current: Option, + pub ready: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct MetricSample { + pub value: f64, + pub unit: MetricUnit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum MetricUnit { + Count, + Percent, + Bytes, + Cores, + Milliseconds, + RequestsPerSecond, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct NamedResourceDetail { + pub name: String, + pub region: Option, +} diff --git a/crates/alien-core/src/heartbeat/platform.rs b/crates/alien-core/src/heartbeat/platform.rs new file mode 100644 index 000000000..8cc0efba1 --- /dev/null +++ b/crates/alien-core/src/heartbeat/platform.rs @@ -0,0 +1,234 @@ +use super::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum ServiceAccountHeartbeatData { + AwsIamRole(AwsIamRoleServiceAccountHeartbeatData), + GcpServiceAccount(GcpServiceAccountHeartbeatData), + AzureManagedIdentity(AzureManagedIdentityServiceAccountHeartbeatData), + Local(LocalServiceAccountHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ServiceAccountHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsIamRoleServiceAccountHeartbeatData { + pub status: ServiceAccountHeartbeatStatus, + pub role_name: String, + pub role_arn: String, + pub role_id: String, + pub path: String, + pub create_date: String, + pub description: Option, + pub max_session_duration: Option, + pub assume_role_policy_present: bool, + pub permissions_boundary_type: Option, + pub permissions_boundary_arn: Option, + pub tag_count: u32, + pub managed_tag_count: u32, + pub attached_policy_count: u32, + pub attached_policy_names: Vec, + pub inline_policy_count: u32, + pub inline_policy_names: Vec, + pub stack_permissions_applied: bool, + pub last_used_date: Option, + pub last_used_region: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpServiceAccountHeartbeatData { + pub status: ServiceAccountHeartbeatStatus, + pub name: Option, + pub project_id: Option, + pub unique_id: Option, + pub email: String, + pub display_name: Option, + pub description: Option, + pub oauth2_client_id: Option, + pub disabled: Option, + pub etag: Option, + pub project_binding_count: u32, + pub project_roles: Vec, + pub service_account_binding_count: u32, + pub service_account_roles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureManagedIdentityServiceAccountHeartbeatData { + pub status: ServiceAccountHeartbeatStatus, + pub name: String, + pub resource_id: String, + pub resource_group: String, + pub location: String, + pub type_: Option, + pub client_id: Option, + pub principal_id: Option, + pub tenant_id: Option, + pub isolation_scope: Option, + pub managed_tag_count: u32, + pub role_assignment_count: u32, + pub role_assignment_ids: Vec, + pub custom_role_definition_count: u32, + pub custom_role_definition_ids: Vec, + pub stack_permissions_applied: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalServiceAccountHeartbeatData { + pub status: ServiceAccountHeartbeatStatus, + pub identity: String, + pub configured: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum NetworkHeartbeatData { + AwsVpc(AwsVpcNetworkHeartbeatData), + GcpVpc(GcpVpcNetworkHeartbeatData), + AzureVnet(AzureVnetNetworkHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct NetworkHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsVpcNetworkHeartbeatData { + pub status: NetworkHeartbeatStatus, + pub vpc_id: Option, + pub vpc_state: Option, + pub cidr_block: Option, + pub public_subnet_ids: Vec, + pub private_subnet_ids: Vec, + pub availability_zones: Vec, + pub internet_gateway_id: Option, + pub nat_gateway_id: Option, + pub route_table_count: u32, + pub security_group_id: Option, + pub is_byo_vpc: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpVpcNetworkHeartbeatData { + pub status: NetworkHeartbeatStatus, + pub network_name: Option, + pub network_self_link: Option, + pub subnetwork_name: Option, + pub subnetwork_self_link: Option, + pub region: Option, + pub cidr_block: Option, + pub router_name: Option, + pub cloud_nat_name: Option, + pub firewall_name: Option, + pub is_byo_vpc: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureVnetNetworkHeartbeatData { + pub status: NetworkHeartbeatStatus, + pub vnet_name: Option, + pub vnet_resource_id: Option, + pub resource_group: Option, + pub location: Option, + pub cidr_block: Option, + pub public_subnet_name: Option, + pub private_subnet_name: Option, + pub application_gateway_subnet_name: Option, + pub private_endpoint_subnet_name: Option, + pub nat_gateway_id: Option, + pub public_ip_id: Option, + pub nsg_id: Option, + pub is_byo_vnet: bool, + pub last_byo_vnet_verification_error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum RemoteStackManagementHeartbeatData { + AwsIamRole(AwsRemoteStackManagementHeartbeatData), + GcpServiceAccount(GcpRemoteStackManagementHeartbeatData), + AzureManagedIdentity(AzureRemoteStackManagementHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct RemoteStackManagementHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsRemoteStackManagementHeartbeatData { + pub status: RemoteStackManagementHeartbeatStatus, + pub role_name: Option, + pub role_arn: Option, + pub management_permissions_applied: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpRemoteStackManagementHeartbeatData { + pub status: RemoteStackManagementHeartbeatStatus, + pub service_account_email: Option, + pub service_account_unique_id: Option, + pub role_bound: bool, + pub impersonation_granted: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureRemoteStackManagementHeartbeatData { + pub status: RemoteStackManagementHeartbeatStatus, + pub uami_resource_id: Option, + pub uami_client_id: Option, + pub uami_principal_id: Option, + pub tenant_id: Option, + pub fic_name: Option, + pub role_definition_id: Option, + pub role_assignment_ids: Vec, +} diff --git a/crates/alien-core/src/heartbeat/registry_build.rs b/crates/alien-core/src/heartbeat/registry_build.rs new file mode 100644 index 000000000..0f1d2f04d --- /dev/null +++ b/crates/alien-core/src/heartbeat/registry_build.rs @@ -0,0 +1,275 @@ +use super::*; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum ArtifactRegistryHeartbeatData { + AwsEcr(AwsEcrArtifactRegistryHeartbeatData), + GcpArtifactRegistry(GcpArtifactRegistryHeartbeatData), + AzureContainerRegistry(AzureContainerRegistryHeartbeatData), + Local(LocalArtifactRegistryHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ArtifactRegistryHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsEcrArtifactRegistryHeartbeatData { + pub status: ArtifactRegistryHeartbeatStatus, + pub registry_id: String, + pub region: String, + pub registry_uri: String, + pub repository_prefix: String, + pub pull_role_arn: Option, + pub push_role_arn: Option, + pub repository_count: u32, + pub repositories_truncated: bool, + pub repositories: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsEcrRepositoryHeartbeatData { + pub repository_arn: String, + pub registry_id: String, + pub repository_name: String, + pub repository_uri: String, + pub created_at: f64, + pub image_tag_mutability: Option, + pub scan_on_push: Option, + pub encryption_type: Option, + pub kms_key_present: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpArtifactRegistryHeartbeatData { + pub status: ArtifactRegistryHeartbeatStatus, + pub project_id: String, + pub location: String, + pub repository_id: String, + pub name: Option, + pub format: Option, + pub mode: Option, + pub description: Option, + pub label_count: u32, + pub cleanup_policy_count: u32, + pub cleanup_policy_dry_run: Option, + pub kms_key_name_present: bool, + pub size_bytes: Option, + pub satisfies_pzs: Option, + pub create_time: Option, + pub update_time: Option, + pub iam_policy_etag_present: bool, + pub iam_binding_count: u32, + pub iam_roles: Vec, + pub pull_service_account_email: Option, + pub push_service_account_email: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureContainerRegistryHeartbeatData { + pub status: ArtifactRegistryHeartbeatStatus, + pub name: String, + pub resource_id: Option, + pub resource_group: String, + pub location: String, + pub type_: Option, + pub login_server: Option, + pub sku_name: String, + pub sku_tier: Option, + pub provisioning_state: Option, + pub admin_user_enabled: bool, + pub anonymous_pull_enabled: bool, + pub public_network_access: String, + pub network_rule_bypass_options: String, + pub network_rule_default_action: Option, + pub ip_rule_count: u32, + pub encryption_status: Option, + pub encryption_key_vault_uri_present: bool, + pub encryption_key_identifier_present: bool, + pub policies_present: bool, + pub policy_count: u32, + pub private_endpoint_connection_count: u32, + pub data_endpoint_enabled: Option, + pub data_endpoint_host_names: Vec, + pub zone_redundancy: String, + pub creation_date: Option, + pub managed_tag_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalArtifactRegistryHeartbeatData { + pub status: ArtifactRegistryHeartbeatStatus, + pub registry_url: String, + pub reachable: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum BuildHeartbeatData { + AwsCodeBuild(AwsCodeBuildHeartbeatData), + GcpCloudBuild(GcpCloudBuildHeartbeatData), + AzureContainerApps(AzureContainerAppsBuildHeartbeatData), + KubernetesJob(KubernetesBuildHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct BuildHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsCodeBuildHeartbeatData { + pub status: BuildHeartbeatStatus, + pub project_name: String, + pub project_arn: Option, + pub description: Option, + pub source_type: Option, + pub artifacts_type: Option, + pub artifacts_encryption_disabled: Option, + pub environment_type: Option, + pub environment_image: Option, + pub compute_type: Option, + pub image_pull_credentials_type: Option, + pub privileged_mode: Option, + pub environment_variable_count: u32, + pub service_role_present: bool, + pub encryption_key_present: bool, + pub cloud_watch_logs_status: Option, + pub s3_logs_status: Option, + pub timeout_in_minutes: Option, + pub queued_timeout_in_minutes: Option, + pub created: Option, + pub last_modified: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpCloudBuildHeartbeatData { + pub status: BuildHeartbeatStatus, + pub project_id: String, + pub location: String, + pub build_config_id: String, + pub service_account: Option, + pub environment_variable_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureContainerAppsBuildHeartbeatData { + pub status: BuildHeartbeatStatus, + pub managed_environment_id: String, + pub resource_group_name: String, + pub managed_identity_id: Option, + pub resource_prefix: Option, + pub environment_variable_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesBuildHeartbeatData { + pub status: BuildHeartbeatStatus, + pub job_name: String, + pub namespace: String, + pub active: Option, + pub succeeded: Option, + pub failed: Option, + pub start_time: Option>, + pub completion_time: Option>, + pub condition_count: u32, + pub image_digest: Option, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum ServiceActivationHeartbeatData { + GcpServiceUsage(GcpServiceUsageActivationHeartbeatData), + AzureResourceProvider(AzureResourceProviderActivationHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ServiceActivationHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for ServiceActivationHeartbeatStatus { + fn default() -> Self { + Self { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpServiceUsageActivationHeartbeatData { + pub status: ServiceActivationHeartbeatStatus, + pub project_id: String, + pub service_name: String, + pub service_resource_name: Option, + pub title: Option, + pub state: Option, + pub enabled: bool, + pub last_operation_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureResourceProviderActivationHeartbeatData { + pub status: ServiceActivationHeartbeatStatus, + pub namespace: String, + pub provider_id: Option, + pub registration_state: Option, + pub registration_policy: Option, + pub resource_type_count: u32, + pub registered: bool, +} diff --git a/crates/alien-core/src/heartbeat/storage.rs b/crates/alien-core/src/heartbeat/storage.rs new file mode 100644 index 000000000..48fc9f4d3 --- /dev/null +++ b/crates/alien-core/src/heartbeat/storage.rs @@ -0,0 +1,120 @@ +use super::*; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum StorageHeartbeatData { + AwsS3(AwsS3StorageHeartbeatData), + GcpCloudStorage(GcpCloudStorageHeartbeatData), + AzureBlob(AzureBlobStorageHeartbeatData), + Local(LocalStorageHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct StorageHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsS3StorageHeartbeatData { + pub status: StorageHeartbeatStatus, + pub name: String, + pub region: Option, + pub bucket_location: Option, + pub versioning_status: Option, + pub versioning_enabled: Option, + pub lifecycle_present: bool, + pub lifecycle_rule_count: Option, + pub encryption_config_present: bool, + pub encryption_enabled: Option, + pub public_access_block_present: bool, + pub block_public_acls: Option, + pub ignore_public_acls: Option, + pub block_public_policy: Option, + pub restrict_public_buckets: Option, + pub bucket_policy_present: Option, + pub bucket_acl_present: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureBlobStorageHeartbeatData { + pub status: StorageHeartbeatStatus, + pub name: String, + pub storage_account_name: Option, + pub resource_group: Option, + pub location: Option, + pub account_kind: Option, + pub sku_name: Option, + pub sku_tier: Option, + pub access_tier: Option, + pub provisioning_state: Option, + pub primary_location: Option, + pub secondary_location: Option, + pub status_of_primary: Option, + pub status_of_secondary: Option, + pub public_network_access: Option, + pub allow_blob_public_access: Option, + pub encryption_key_source: Option, + pub blob_encryption_enabled: Option, + pub file_encryption_enabled: Option, + pub queue_encryption_enabled: Option, + pub table_encryption_enabled: Option, + pub blob_versioning_enabled: Option, + pub blob_delete_retention_enabled: Option, + pub blob_delete_retention_days: Option, + pub container_delete_retention_enabled: Option, + pub container_delete_retention_days: Option, + pub change_feed_enabled: Option, + pub change_feed_retention_days: Option, + pub container_public_access: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpCloudStorageHeartbeatData { + pub status: StorageHeartbeatStatus, + pub name: String, + pub bucket_id: Option, + pub location: Option, + pub location_type: Option, + pub storage_class: Option, + pub versioning_enabled: Option, + pub lifecycle_present: bool, + pub lifecycle_rule_count: Option, + pub retention_policy_effective_time: Option, + pub retention_policy_is_locked: Option, + pub retention_period: Option, + pub soft_delete_retention_duration_seconds: Option, + pub soft_delete_effective_time: Option, + pub uniform_bucket_level_access_enabled: Option, + pub uniform_bucket_level_access_locked_time: Option, + pub public_access_prevention: Option, + pub encryption_config_present: bool, + pub default_kms_key_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalStorageHeartbeatData { + pub status: StorageHeartbeatStatus, + pub path: String, + pub path_exists: bool, + pub is_directory: Option, + pub readonly: Option, + pub modified_at: Option>, +} diff --git a/crates/alien-core/src/heartbeat/tests.rs b/crates/alien-core/src/heartbeat/tests.rs new file mode 100644 index 000000000..aa009ebf6 --- /dev/null +++ b/crates/alien-core/src/heartbeat/tests.rs @@ -0,0 +1,372 @@ +use super::*; +use chrono::TimeZone as _; +use serde_json::json; + +fn observed_at() -> DateTime { + Utc.with_ymd_and_hms(2026, 5, 28, 10, 30, 0).unwrap() +} + +fn workload_status() -> WorkloadHeartbeatStatus { + WorkloadHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } +} + +fn workload_replicas() -> WorkloadReplicaStatus { + WorkloadReplicaStatus { + desired: Some(2), + current: Some(2), + ready: Some(1), + available: Some(1), + updated: Some(2), + misscheduled: None, + } +} + +fn heartbeat(data: ResourceHeartbeatData, resource_type: &str) -> ResourceHeartbeat { + ResourceHeartbeat { + deployment_id: Some("dep_123".to_string()), + resource_id: "api".to_string(), + resource_type: ResourceType::from(resource_type), + controller_platform: Platform::Kubernetes, + backend: HeartbeatBackend::Kubernetes, + observed_at: observed_at(), + data, + raw: vec![RawHeartbeatSnippet { + source: "kubernetes/apps/v1/deployments/api".to_string(), + format: RawHeartbeatSnippetFormat::Json, + collected_at: observed_at(), + body: r#"{"readyReplicas":1}"#.to_string(), + truncated: false, + }], + } +} + +#[test] +fn resource_heartbeat_roundtrips_managed_resource_data() { + let heartbeat_json = serde_json::to_value(heartbeat( + ResourceHeartbeatData::Container(ContainerHeartbeatData::Kubernetes( + KubernetesContainerHeartbeatData { + status: workload_status(), + namespace: "default".to_string(), + name: "api".to_string(), + workload_kind: KubernetesWorkloadKind::Deployment, + replicas: workload_replicas(), + restarts: None, + cpu: None, + memory: None, + workload: None, + pods: vec![], + events: vec![], + }, + )), + "container", + )) + .unwrap(); + + let parsed: ResourceHeartbeat = serde_json::from_value(heartbeat_json).unwrap(); + assert_eq!(parsed.resource_id, "api"); + assert!(matches!( + parsed.data, + ResourceHeartbeatData::Container(ContainerHeartbeatData::Kubernetes(_)) + )); +} + +#[test] +fn local_daemon_heartbeat_defaults_missing_daemon_name() { + let mut value = serde_json::to_value(DaemonHeartbeatData::Local(LocalDaemonHeartbeatData { + status: workload_status(), + daemon_name: "agent".to_string(), + runtime_id: "local-agent".to_string(), + pid: None, + command_supported: false, + image_path_present: true, + restart_count: None, + exit_reason: None, + daemon_instance: None, + events: vec![], + })) + .unwrap(); + + value.as_object_mut().unwrap().remove("daemonName"); + + let parsed: DaemonHeartbeatData = serde_json::from_value(value).unwrap(); + match parsed { + DaemonHeartbeatData::Local(data) => assert_eq!(data.daemon_name, ""), + other => panic!("expected local daemon heartbeat, got {other:?}"), + } +} + +#[test] +fn container_heartbeat_serializes_resource_first_data() { + let heartbeat = heartbeat( + ResourceHeartbeatData::Container(ContainerHeartbeatData::Kubernetes( + KubernetesContainerHeartbeatData { + status: workload_status(), + namespace: "default".to_string(), + name: "api".to_string(), + workload_kind: KubernetesWorkloadKind::Deployment, + replicas: workload_replicas(), + restarts: Some(1), + cpu: Some(MetricSample { + value: 0.5, + unit: MetricUnit::Cores, + }), + memory: None, + workload: None, + pods: vec![], + events: vec![], + }, + )), + "container", + ); + + let value = serde_json::to_value(&heartbeat).unwrap(); + + assert_eq!(value["resourceType"], "container"); + assert_eq!(value["data"]["resourceType"], "container"); + assert_eq!(value["data"]["data"]["backend"], "kubernetes"); + assert_eq!(value["raw"][0]["body"], r#"{"readyReplicas":1}"#); + assert!(value.get("collection").is_none()); + assert!(value["data"]["data"].get("summary").is_none()); + assert!(value["data"]["data"].get("detail").is_none()); +} + +#[test] +fn representative_workload_data_has_stable_tags() { + let daemon = serde_json::to_value(ResourceHeartbeatData::Daemon( + DaemonHeartbeatData::Kubernetes(KubernetesDaemonHeartbeatData { + status: workload_status(), + namespace: "default".to_string(), + name: "agent".to_string(), + replicas: workload_replicas(), + restarts: Some(0), + command_supported: true, + cpu: None, + memory: None, + workload: None, + pods: vec![], + events: vec![], + }), + )) + .unwrap(); + let worker = serde_json::to_value(ResourceHeartbeatData::Worker( + WorkerHeartbeatData::AwsLambda(AwsLambdaWorkerHeartbeatData { + status: workload_status(), + function_name: "handler".to_string(), + runtime: Some("nodejs22.x".to_string()), + package_type: None, + memory_size_mb: None, + timeout_seconds: None, + version: None, + revision_id: None, + last_modified: None, + state: None, + state_reason: None, + state_reason_code: None, + last_update_status: None, + last_update_status_reason: None, + last_update_status_reason_code: None, + code_sha256: None, + layer_count: 0, + function_url_auth_type: None, + function_url_cors_present: false, + trigger_count: 0, + }), + )) + .unwrap(); + + assert_eq!(daemon["resourceType"], "daemon"); + assert_eq!(daemon["data"]["backend"], "kubernetes"); + assert_eq!(worker["resourceType"], "worker"); + assert_eq!(worker["data"]["backend"], "awsLambda"); +} + +#[test] +fn representative_cluster_and_data_variants_have_optional_counts() { + let cluster = serde_json::to_value(ResourceHeartbeatData::KubernetesCluster( + KubernetesClusterHeartbeatData { + status: WorkloadHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + }, + node_counts: ObservedCounts { + desired: Some(3), + current: Some(3), + ready: None, + }, + pod_counts: ObservedCounts { + desired: None, + current: Some(12), + ready: Some(11), + }, + cpu: None, + memory: None, + name: "prod".to_string(), + region: Some("us-east-1".to_string()), + namespace: None, + version: Some("1.33".to_string()), + node_statuses: vec![], + events: vec![], + }, + )) + .unwrap(); + let queue = serde_json::to_value(ResourceHeartbeatData::Queue(QueueHeartbeatData::AwsSqs( + AwsSqsQueueHeartbeatData { + status: QueueHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + }, + name: "jobs".to_string(), + region: Some("us-east-1".to_string()), + queue_url: Some("https://sqs.us-east-1.amazonaws.com/123/jobs".to_string()), + queue_arn: Some("arn:aws:sqs:us-east-1:123:jobs".to_string()), + visibility_timeout_seconds: Some(30), + message_retention_period_seconds: Some(345600), + delay_seconds: Some(0), + receive_message_wait_time_seconds: Some(0), + maximum_message_size: Some(262144), + redrive_policy: None, + redrive_allow_policy: None, + fifo_queue: Some(false), + content_based_deduplication: None, + deduplication_scope: None, + fifo_throughput_limit: None, + sse_enabled: Some(false), + kms_master_key_id: None, + kms_data_key_reuse_period_seconds: None, + sqs_managed_sse_enabled: Some(false), + approximate_visible_messages: Some(42), + approximate_in_flight_messages: Some(1), + approximate_delayed_messages: Some(0), + approximate_counts: true, + }, + ))) + .unwrap(); + let storage = serde_json::to_value(ResourceHeartbeatData::Storage( + StorageHeartbeatData::AwsS3(AwsS3StorageHeartbeatData { + status: StorageHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + }, + name: "assets".to_string(), + region: Some("us-east-1".to_string()), + bucket_location: Some("us-east-1".to_string()), + versioning_status: Some("Enabled".to_string()), + versioning_enabled: Some(true), + lifecycle_present: false, + lifecycle_rule_count: Some(0), + encryption_config_present: true, + encryption_enabled: Some(true), + public_access_block_present: true, + block_public_acls: Some(true), + ignore_public_acls: Some(true), + block_public_policy: Some(true), + restrict_public_buckets: Some(true), + bucket_policy_present: Some(false), + bucket_acl_present: Some(true), + }), + )) + .unwrap(); + let gcp_storage = serde_json::to_value(ResourceHeartbeatData::Storage( + StorageHeartbeatData::GcpCloudStorage(GcpCloudStorageHeartbeatData { + status: StorageHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + }, + name: "assets".to_string(), + bucket_id: Some("project/assets".to_string()), + location: Some("US".to_string()), + location_type: Some("multi-region".to_string()), + storage_class: Some("STANDARD".to_string()), + versioning_enabled: Some(true), + lifecycle_present: false, + lifecycle_rule_count: Some(0), + retention_policy_effective_time: None, + retention_policy_is_locked: None, + retention_period: None, + soft_delete_retention_duration_seconds: None, + soft_delete_effective_time: None, + uniform_bucket_level_access_enabled: Some(true), + uniform_bucket_level_access_locked_time: None, + public_access_prevention: Some("enforced".to_string()), + encryption_config_present: true, + default_kms_key_name: Some( + "projects/p/locations/l/keyRings/r/cryptoKeys/k".to_string(), + ), + }), + )) + .unwrap(); + let kv = serde_json::to_value(ResourceHeartbeatData::Kv(KvHeartbeatData::AwsDynamoDb( + AwsDynamoDbKvHeartbeatData { + status: KvHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + }, + name: "state".to_string(), + region: Some("us-east-1".to_string()), + table_arn: None, + table_status: Some("ACTIVE".to_string()), + billing_mode: Some("PAY_PER_REQUEST".to_string()), + key_schema: vec![AwsDynamoDbKeySchemaElement { + attribute_name: "pk".to_string(), + key_type: "HASH".to_string(), + }], + global_secondary_index_count: Some(0), + local_secondary_index_count: Some(0), + item_count: None, + table_size_bytes: None, + stream_enabled: Some(false), + stream_view_type: None, + ttl_status: Some("ENABLED".to_string()), + ttl_attribute_name: Some("ttl".to_string()), + deletion_protection_enabled: Some(false), + sse_status: Some("ENABLED".to_string()), + sse_type: Some("KMS".to_string()), + table_class: None, + replica_count: Some(0), + restore_in_progress: None, + }, + ))) + .unwrap(); + + assert_eq!(cluster["resourceType"], "kubernetes-cluster"); + assert!(cluster["data"].get("summary").is_none()); + assert!(cluster["data"].get("detail").is_none()); + assert_eq!(cluster["data"]["name"], "prod"); + assert_eq!(queue["data"]["backend"], "awsSqs"); + assert_eq!(queue["data"]["approximateVisibleMessages"], 42); + assert!(queue["data"].get("summary").is_none()); + assert_eq!(storage["data"]["backend"], "awsS3"); + assert!(storage["data"].get("summary").is_none()); + assert_eq!(gcp_storage["data"]["backend"], "gcpCloudStorage"); + assert_eq!(gcp_storage["data"]["publicAccessPrevention"], "enforced"); + assert_eq!(kv["data"]["backend"], "awsDynamoDb"); + assert!(kv["data"].get("summary").is_none()); + assert_eq!(kv["data"]["itemCount"], json!(null)); +} diff --git a/crates/alien-core/src/heartbeat/workload.rs b/crates/alien-core/src/heartbeat/workload.rs new file mode 100644 index 000000000..23b697068 --- /dev/null +++ b/crates/alien-core/src/heartbeat/workload.rs @@ -0,0 +1,466 @@ +use super::*; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum WorkerHeartbeatData { + AwsLambda(AwsLambdaWorkerHeartbeatData), + GcpCloudRun(GcpCloudRunWorkerHeartbeatData), + AzureContainerApps(AzureContainerAppsWorkerHeartbeatData), + Kubernetes(KubernetesWorkerHeartbeatData), + Local(LocalWorkerHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum ContainerHeartbeatData { + HorizonPlatform(HorizonContainerHeartbeatData), + Kubernetes(KubernetesContainerHeartbeatData), + Local(LocalContainerHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum DaemonHeartbeatData { + Aws(AwsDaemonHeartbeatData), + Gcp(GcpDaemonHeartbeatData), + Azure(AzureDaemonHeartbeatData), + Machines(MachinesDaemonHeartbeatData), + Kubernetes(KubernetesDaemonHeartbeatData), + Local(LocalDaemonHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct WorkloadHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct WorkloadReplicaStatus { + pub desired: Option, + pub current: Option, + pub ready: Option, + pub available: Option, + pub updated: Option, + pub misscheduled: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsLambdaWorkerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub function_name: String, + pub runtime: Option, + pub package_type: Option, + pub memory_size_mb: Option, + pub timeout_seconds: Option, + pub version: Option, + pub revision_id: Option, + pub last_modified: Option, + pub state: Option, + pub state_reason: Option, + pub state_reason_code: Option, + pub last_update_status: Option, + pub last_update_status_reason: Option, + pub last_update_status_reason_code: Option, + pub code_sha256: Option, + pub layer_count: u32, + pub function_url_auth_type: Option, + pub function_url_cors_present: bool, + pub trigger_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpCloudRunWorkerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub service: String, + pub region: Option, + pub uri: Option, + pub urls: Vec, + pub latest_created_revision: Option, + pub latest_ready_revision: Option, + pub generation: Option, + pub observed_generation: Option, + pub traffic_count: u32, + pub min_instance_count: Option, + pub max_instance_count: Option, + pub container_image: Option, + pub cpu_limit: Option, + pub memory_limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureContainerAppsWorkerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub app_name: String, + pub revision: Option, + pub environment_name: Option, + pub provisioning_state: Option, + pub running_status: Option, + pub ingress_fqdn: Option, + pub min_replicas: Option, + pub max_replicas: Option, + pub cpu: Option, + pub memory: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesWorkerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub namespace: String, + pub name: String, + pub workload_kind: KubernetesWorkloadKind, + pub replicas: WorkloadReplicaStatus, + pub restarts: Option, + pub cpu: Option, + pub memory: Option, + pub workload: Option, + pub pods: Vec, + pub trigger_count: u32, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalWorkerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub pid: Option, + pub command_supported: bool, + pub image_path_present: bool, + pub readiness_probe_ok: Option, + pub trigger_count: u32, + pub cpu: Option, + pub memory: Option, + pub process: Option, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct HorizonContainerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub container_id: String, + pub image: Option, + #[serde(default)] + pub observed_image: Option, + #[serde(default)] + pub latest_update_timestamp: Option, + pub scheduling_mode: HorizonWorkloadSchedulingMode, + pub replicas: WorkloadReplicaStatus, + pub cpu: Option, + pub memory: Option, + pub attention_count: u32, + pub replica_units: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub enum HorizonWorkloadSchedulingMode { + Replicated, + Stateful, + Daemon, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesContainerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub namespace: String, + pub name: String, + pub workload_kind: KubernetesWorkloadKind, + pub replicas: WorkloadReplicaStatus, + pub restarts: Option, + pub cpu: Option, + pub memory: Option, + pub workload: Option, + pub pods: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalContainerHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub container_id: Option, + pub name: Option, + pub image: Option, + pub runtime_status: Option, + pub restart_count: Option, + pub port_count: u32, + pub bind_mount_count: u32, + pub local_url: Option, + pub runtime_reachable: bool, + pub cpu: Option, + pub memory: Option, + pub container_unit: Option, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsDaemonHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub horizon_cluster_id: String, + #[serde(default)] + pub daemon_name: String, + pub horizon_status: String, + pub horizon_status_reason: Option, + pub horizon_status_message: Option, + pub capacity_group: String, + pub desired_machines: u32, + pub assigned_machines: u32, + pub healthy_instances: u32, + pub unavailable_instances: u32, + pub command_supported: bool, + #[serde(default)] + pub observed_image: Option, + pub latest_update_timestamp: String, + pub daemon_instances: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpDaemonHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub horizon_cluster_id: String, + #[serde(default)] + pub daemon_name: String, + pub horizon_status: String, + pub horizon_status_reason: Option, + pub horizon_status_message: Option, + pub capacity_group: String, + pub desired_machines: u32, + pub assigned_machines: u32, + pub healthy_instances: u32, + pub unavailable_instances: u32, + pub command_supported: bool, + #[serde(default)] + pub observed_image: Option, + pub latest_update_timestamp: String, + pub daemon_instances: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureDaemonHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub horizon_cluster_id: String, + #[serde(default)] + pub daemon_name: String, + pub horizon_status: String, + pub horizon_status_reason: Option, + pub horizon_status_message: Option, + pub capacity_group: String, + pub desired_machines: u32, + pub assigned_machines: u32, + pub healthy_instances: u32, + pub unavailable_instances: u32, + pub command_supported: bool, + #[serde(default)] + pub observed_image: Option, + pub latest_update_timestamp: String, + pub daemon_instances: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct MachinesDaemonHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub horizon_cluster_id: String, + #[serde(default)] + pub daemon_name: String, + pub horizon_status: String, + pub horizon_status_reason: Option, + pub horizon_status_message: Option, + pub capacity_group: String, + pub desired_machines: u32, + pub assigned_machines: u32, + pub healthy_instances: u32, + pub unavailable_instances: u32, + pub command_supported: bool, + #[serde(default)] + pub observed_image: Option, + pub latest_update_timestamp: String, + pub daemon_instances: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ManagedRuntimeUnitStatus { + pub replica_id: String, + pub name: String, + pub machine_id: Option, + pub node_name: Option, + pub ip: Option, + pub ready: bool, + pub phase: Option, + pub status: Option, + pub reason: Option, + pub message: Option, + pub restart_count: Option, + pub waiting_reason: Option, + pub terminated_reason: Option, + pub metrics_healthy: Option, + pub metrics_status: Option, + pub metrics_last_updated: Option, + pub cpu: Option, + pub memory: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesDaemonHeartbeatData { + pub status: WorkloadHeartbeatStatus, + pub namespace: String, + pub name: String, + pub replicas: WorkloadReplicaStatus, + pub restarts: Option, + pub command_supported: bool, + pub cpu: Option, + pub memory: Option, + pub workload: Option, + pub pods: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalDaemonHeartbeatData { + pub status: WorkloadHeartbeatStatus, + #[serde(default)] + pub daemon_name: String, + pub runtime_id: String, + pub pid: Option, + pub command_supported: bool, + pub image_path_present: bool, + pub restart_count: Option, + pub exit_reason: Option, + pub daemon_instance: Option, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct LocalRuntimeUnitStatus { + pub unit_id: String, + pub name: String, + pub kind: LocalRuntimeUnitKind, + pub ready: bool, + pub phase: Option, + pub pid: Option, + pub restart_count: Option, + pub cpu: Option, + pub memory: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub enum LocalRuntimeUnitKind { + Container, + Process, + Daemon, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub enum KubernetesWorkloadKind { + Deployment, + StatefulSet, + DaemonSet, + ReplicaSet, + Pod, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesWorkloadStatus { + pub desired_replicas: Option, + pub ready_replicas: Option, + pub available_replicas: Option, + pub updated_replicas: Option, + pub observed_generation: Option, + pub desired_generation: Option, + pub rollout_reason: Option, + pub conditions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesWorkloadCondition { + #[serde(rename = "type")] + pub condition_type: String, + pub status: String, + pub reason: Option, + pub message: Option, + pub last_transition_time: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesPodRuntimeUnitStatus { + pub name: String, + pub uid: Option, + pub phase: Option, + pub ready: bool, + pub restart_count: u32, + pub waiting_reason: Option, + pub terminated_reason: Option, + pub node_name: Option, + pub pod_ip: Option, + pub owner_references: Vec, + pub cpu: Option, + pub memory: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct KubernetesOwnerReference { + pub kind: String, + pub name: String, + pub uid: String, + pub controller: bool, +} diff --git a/crates/alien-core/src/instance_catalog.rs b/crates/alien-core/src/instance_catalog.rs index 96fcf3b7f..4ac9e92d4 100644 --- a/crates/alien-core/src/instance_catalog.rs +++ b/crates/alien-core/src/instance_catalog.rs @@ -1476,540 +1476,4 @@ fn system_reserve_memory_bytes(memory_bytes: u64) -> u64 { // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use crate::BinaryTarget; - - // -- Parsing tests -- - - #[test] - fn test_parse_cpu_plain() { - assert_eq!(parse_cpu("1").unwrap(), 1.0); - assert_eq!(parse_cpu("0.5").unwrap(), 0.5); - assert_eq!(parse_cpu("2.0").unwrap(), 2.0); - assert_eq!(parse_cpu("16").unwrap(), 16.0); - } - - #[test] - fn test_parse_cpu_millicore() { - assert_eq!(parse_cpu("500m").unwrap(), 0.5); - assert_eq!(parse_cpu("250m").unwrap(), 0.25); - assert_eq!(parse_cpu("1000m").unwrap(), 1.0); - assert_eq!(parse_cpu("100m").unwrap(), 0.1); - } - - #[test] - fn test_parse_cpu_invalid() { - assert!(parse_cpu("").is_err()); - assert!(parse_cpu("abc").is_err()); - assert!(parse_cpu("m").is_err()); - } - - #[test] - fn test_parse_memory_binary_suffixes() { - assert_eq!(parse_memory_bytes("1Ki").unwrap(), 1024); - assert_eq!(parse_memory_bytes("1Mi").unwrap(), 1024 * 1024); - assert_eq!(parse_memory_bytes("1Gi").unwrap(), 1024 * 1024 * 1024); - assert_eq!(parse_memory_bytes("4Gi").unwrap(), 4 * 1024 * 1024 * 1024); - assert_eq!(parse_memory_bytes("512Mi").unwrap(), 512 * 1024 * 1024); - assert_eq!( - parse_memory_bytes("1Ti").unwrap(), - 1024u64 * 1024 * 1024 * 1024 - ); - } - - #[test] - fn test_parse_memory_decimal_suffixes() { - assert_eq!(parse_memory_bytes("1k").unwrap(), 1000); - assert_eq!(parse_memory_bytes("1M").unwrap(), 1_000_000); - assert_eq!(parse_memory_bytes("1G").unwrap(), 1_000_000_000); - assert_eq!(parse_memory_bytes("1T").unwrap(), 1_000_000_000_000); - } - - #[test] - fn test_parse_memory_plain_bytes() { - assert_eq!(parse_memory_bytes("1024").unwrap(), 1024); - assert_eq!(parse_memory_bytes("0").unwrap(), 0); - } - - #[test] - fn test_parse_memory_invalid() { - assert!(parse_memory_bytes("").is_err()); - assert!(parse_memory_bytes("abc").is_err()); - assert!(parse_memory_bytes("Gi").is_err()); - } - - #[test] - fn test_parse_memory_fractional() { - assert_eq!(parse_memory_bytes("0.5Gi").unwrap(), GI / 2); - assert_eq!(parse_memory_bytes("1.5Gi").unwrap(), GI + GI / 2); - } - - // -- Catalog lookup tests -- - - #[test] - fn test_catalog_has_entries_for_all_cloud_platforms() { - assert!(!catalog_for_platform(Platform::Aws).is_empty()); - assert!(!catalog_for_platform(Platform::Gcp).is_empty()); - assert!(!catalog_for_platform(Platform::Azure).is_empty()); - } - - #[test] - fn test_catalog_no_entries_for_non_cloud_platforms() { - assert!(catalog_for_platform(Platform::Local).is_empty()); - assert!(catalog_for_platform(Platform::Kubernetes).is_empty()); - } - - #[test] - fn test_find_known_instance_type() { - let spec = - find_instance_type(Platform::Aws, "m7g.2xlarge").expect("should find m7g.2xlarge"); - assert_eq!(spec.vcpu, 8); - assert_eq!(spec.memory_bytes, 32 * GI); - assert_eq!(spec.family, InstanceFamily::GeneralPurpose); - } - - #[test] - fn test_find_aws_c8i_nested_virt_instance_type() { - let spec = find_instance_type(Platform::Aws, "c8i.large").expect("should find c8i.large"); - assert_eq!(spec.vcpu, 2); - assert_eq!(spec.memory_bytes, 4 * GI); - assert_eq!(spec.family, InstanceFamily::ComputeOptimized); - assert_eq!(spec.architecture, Architecture::X86_64); - assert!(spec.is_nested_virt_capable()); - } - - #[test] - fn test_find_unknown_instance_type() { - assert!(find_instance_type(Platform::Aws, "nonexistent.xlarge").is_none()); - } - - #[test] - fn test_find_wrong_platform() { - assert!(find_instance_type(Platform::Gcp, "m7g.2xlarge").is_none()); - } - - #[test] - fn test_to_machine_profile() { - let spec = find_instance_type(Platform::Aws, "m7g.2xlarge").unwrap(); - let profile = spec.to_machine_profile(); - assert_eq!(profile.cpu, "8.0"); - assert_eq!(profile.memory_bytes, 32 * GI); - assert_eq!(profile.ephemeral_storage_bytes, 20 * GI); - assert!(profile.gpu.is_none()); - } - - #[test] - fn test_to_machine_profile_with_gpu() { - let spec = find_instance_type(Platform::Aws, "p4d.24xlarge").unwrap(); - let profile = spec.to_machine_profile(); - let gpu = profile.gpu.as_ref().expect("should have GPU"); - assert_eq!(gpu.gpu_type, "nvidia-a100"); - assert_eq!(gpu.count, 8); - } - - // -- Selection algorithm tests -- - - #[test] - fn test_select_burstable_for_small_workload() { - let req = WorkloadRequirements { - total_cpu_at_desired: 1.0, - total_memory_bytes_at_desired: 2 * GI, - total_cpu_at_max: 1.0, - total_memory_bytes_at_max: 2 * GI, - max_cpu_per_container: 0.5, - max_memory_per_container: 1 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.family, InstanceFamily::Burstable); - } - - #[test] - fn test_selects_smallest_burstable_machine_with_real_headroom() { - let req = WorkloadRequirements { - total_cpu_at_desired: 1.0, - total_memory_bytes_at_desired: 2 * GI, - total_cpu_at_max: 1.0, - total_memory_bytes_at_max: 2 * GI, - max_cpu_per_container: 1.0, - max_memory_per_container: 2 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - - let selection = select_instance_type(Platform::Aws, &req).unwrap(); - - assert_eq!(selection.instance_type, "t4g.medium"); - assert_eq!(selection.min_machines, 1); - assert_eq!(selection.max_machines, 1); - } - - #[test] - fn test_select_general_purpose_for_standard_workload() { - // Standard workloads always get GeneralPurpose regardless of CPU:memory ratio - let req = WorkloadRequirements { - total_cpu_at_desired: 20.0, - total_memory_bytes_at_desired: 80 * GI, - total_cpu_at_max: 20.0, - total_memory_bytes_at_max: 80 * GI, - max_cpu_per_container: 2.0, - max_memory_per_container: 8 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.family, InstanceFamily::GeneralPurpose); - } - - #[test] - fn test_select_general_purpose_even_for_cpu_heavy() { - // CPU-heavy workloads still get GeneralPurpose (no more ComputeOptimized auto-select) - let req = WorkloadRequirements { - total_cpu_at_desired: 20.0, - total_memory_bytes_at_desired: 20 * GI, - total_cpu_at_max: 20.0, - total_memory_bytes_at_max: 20 * GI, - max_cpu_per_container: 2.0, - max_memory_per_container: 2 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.family, InstanceFamily::GeneralPurpose); - } - - #[test] - fn test_select_storage_optimized_for_large_ephemeral() { - let req = WorkloadRequirements { - total_cpu_at_desired: 8.0, - total_memory_bytes_at_desired: 32 * GI, - total_cpu_at_max: 8.0, - total_memory_bytes_at_max: 32 * GI, - max_cpu_per_container: 2.0, - max_memory_per_container: 8 * GI, - max_ephemeral_storage_bytes: 500 * GI, - gpu: None, - architecture: Some(Architecture::X86_64), - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.family, InstanceFamily::StorageOptimized); - } - - #[test] - fn test_select_gpu_instance() { - let req = WorkloadRequirements { - total_cpu_at_desired: 8.0, - total_memory_bytes_at_desired: 32 * GI, - total_cpu_at_max: 8.0, - total_memory_bytes_at_max: 32 * GI, - max_cpu_per_container: 4.0, - max_memory_per_container: 16 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: Some(GpuSpec { - gpu_type: "nvidia-a100".to_string(), - count: 1, - }), - architecture: Some(Architecture::X86_64), - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.family, InstanceFamily::GpuCompute); - assert!(spec.gpu.is_some()); - } - - #[test] - fn test_select_uses_each_cloud_image_target_architecture() { - let req = WorkloadRequirements { - total_cpu_at_desired: 4.0, - total_memory_bytes_at_desired: 16 * GI, - total_cpu_at_max: 4.0, - total_memory_bytes_at_max: 16 * GI, - max_cpu_per_container: 1.0, - max_memory_per_container: 4 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] { - let sel = select_instance_type(platform, &req) - .unwrap_or_else(|error| panic!("selection failed for {platform}: {error}")); - let spec = find_instance_type(platform, sel.instance_type) - .expect("selected machine should exist in the catalog"); - assert_eq!( - Some(spec.architecture), - default_architecture(platform), - "machine architecture must match the image target for {platform}" - ); - } - } - - #[test] - fn test_machine_count_reasonable() { - // Single container: 1 CPU, 2Gi, maxReplicas=20 - let req = WorkloadRequirements { - total_cpu_at_desired: 20.0, - total_memory_bytes_at_desired: 40 * GI, - total_cpu_at_max: 20.0, - total_memory_bytes_at_max: 40 * GI, - max_cpu_per_container: 1.0, - max_memory_per_container: 2 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - assert!(sel.min_machines >= 1); - assert!(sel.max_machines <= MAX_MACHINES_PER_CLUSTER); - assert!(sel.max_machines >= sel.min_machines); - } - - #[test] - fn test_instance_size_capped_at_8_vcpu() { - // Even with very large containers, instance size is capped at 8 vCPUs - let req = WorkloadRequirements { - total_cpu_at_desired: 70.0, - total_memory_bytes_at_desired: 140 * GI, - total_cpu_at_max: 70.0, - total_memory_bytes_at_max: 140 * GI, - max_cpu_per_container: 2.0, - max_memory_per_container: 4 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Gcp, &req).unwrap(); - let spec = find_instance_type(Platform::Gcp, sel.instance_type).unwrap(); - assert!( - spec.vcpu <= MAX_STANDARD_VCPU, - "selected {} with {} vCPUs, expected <= {}", - spec.name, - spec.vcpu, - MAX_STANDARD_VCPU - ); - assert_eq!(spec.family, InstanceFamily::GeneralPurpose); - // Should scale horizontally instead - assert!(sel.max_machines > 1); - } - - #[test] - fn test_larger_autoscaled_workload_gets_reasonable_instance() { - // Simulates a larger autoscaled workload: 4 containers, each 2 CPU / 4 GiB - // maxReplicas: 10, 10, 10, 5 - let req = WorkloadRequirements { - total_cpu_at_desired: 70.0, - total_memory_bytes_at_desired: 140 * GI, - total_cpu_at_max: 70.0, // 2*10 + 2*10 + 2*10 + 2*5 - total_memory_bytes_at_max: 140 * GI, // 4*10 + 4*10 + 4*10 + 4*5 - max_cpu_per_container: 2.0, - max_memory_per_container: 4 * GI, - max_ephemeral_storage_bytes: 20 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Gcp, &req).unwrap(); - // Should pick n2-standard-8 (8 vCPU, 32 GiB) — NOT c3-standard-44 - assert_eq!(sel.instance_type, "n2-standard-8"); - assert!(sel.max_machines >= 2); - } - - /// When `nested_virt` is set on the workload, the selector must - /// restrict to nested-virt-capable families. On AWS that means an m8i - /// (or other 8th-gen Intel) entry, never a Graviton (`*7g`, `t4g`) or - /// burstable. Without this filter the launch template gets created - /// with `CpuOptions.NestedVirtualization=enabled` paired with an - /// instance type AWS rejects at RunInstances. - #[test] - fn test_select_aws_picks_m8i_when_nested_virt_required() { - let req = WorkloadRequirements { - total_cpu_at_desired: 4.0, - total_memory_bytes_at_desired: 8 * GI, - total_cpu_at_max: 4.0, - total_memory_bytes_at_max: 8 * GI, - max_cpu_per_container: 4.0, - max_memory_per_container: 8 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: Some(Architecture::X86_64), - nested_virt: true, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - assert!( - sel.instance_type.starts_with("m8i.") - || sel.instance_type.starts_with("c8i.") - || sel.instance_type.starts_with("r8i."), - "expected an m8i/c8i/r8i instance, got {}", - sel.instance_type - ); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert!(spec.is_nested_virt_capable()); - } - - #[test] - fn test_select_aws_defaults_to_image_target_architecture() { - let req = WorkloadRequirements { - total_cpu_at_desired: 4.0, - total_memory_bytes_at_desired: 8 * GI, - total_cpu_at_max: 4.0, - total_memory_bytes_at_max: 8 * GI, - max_cpu_per_container: 4.0, - max_memory_per_container: 8 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.architecture, Architecture::Arm64); - } - - #[test] - fn test_cloud_defaults_match_image_target_architectures() { - for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] { - let target = BinaryTarget::defaults_for_platform(platform) - .into_iter() - .next() - .expect("managed cloud should have a default image target"); - let image_architecture = match target.oci_arch() { - "arm64" => Architecture::Arm64, - "amd64" => Architecture::X86_64, - architecture => { - panic!("unsupported managed-cloud image architecture {architecture}") - } - }; - - assert_eq!(default_architecture(platform), Some(image_architecture)); - } - } - - /// ARM remains available when the workload or capacity profile declares it. - #[test] - fn test_select_aws_uses_graviton_for_explicit_arm64() { - let req = WorkloadRequirements { - total_cpu_at_desired: 4.0, - total_memory_bytes_at_desired: 8 * GI, - total_cpu_at_max: 4.0, - total_memory_bytes_at_max: 8 * GI, - max_cpu_per_container: 4.0, - max_memory_per_container: 8 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: Some(Architecture::Arm64), - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); - assert_eq!(spec.architecture, Architecture::Arm64); - } - - #[test] - fn test_select_rejects_explicit_architecture_missing_from_cloud_catalog() { - let req = WorkloadRequirements { - total_cpu_at_desired: 1.0, - total_memory_bytes_at_desired: 2 * GI, - total_cpu_at_max: 1.0, - total_memory_bytes_at_max: 2 * GI, - max_cpu_per_container: 1.0, - max_memory_per_container: 2 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: Some(Architecture::Arm64), - nested_virt: false, - }; - - let error = select_instance_type(Platform::Gcp, &req) - .expect_err("GCP catalog has no ARM64 machine"); - - assert!(error.contains("architecture Arm64 is unavailable")); - } - - #[test] - fn test_profile_has_required_fields() { - let req = WorkloadRequirements { - total_cpu_at_desired: 4.0, - total_memory_bytes_at_desired: 16 * GI, - total_cpu_at_max: 4.0, - total_memory_bytes_at_max: 16 * GI, - max_cpu_per_container: 1.0, - max_memory_per_container: 4 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: None, - architecture: None, - nested_virt: false, - }; - let sel = select_instance_type(Platform::Aws, &req).unwrap(); - assert!(!sel.profile.cpu.is_empty()); - assert!(sel.profile.memory_bytes > 0); - assert!(sel.profile.ephemeral_storage_bytes > 0); - } - - #[test] - fn test_error_for_unsupported_gpu_type() { - let req = WorkloadRequirements { - total_cpu_at_desired: 8.0, - total_memory_bytes_at_desired: 32 * GI, - total_cpu_at_max: 8.0, - total_memory_bytes_at_max: 32 * GI, - max_cpu_per_container: 4.0, - max_memory_per_container: 16 * GI, - max_ephemeral_storage_bytes: 10 * GI, - gpu: Some(GpuSpec { - gpu_type: "amd-mi300".to_string(), - count: 1, - }), - architecture: None, - nested_virt: false, - }; - let result = select_instance_type(Platform::Aws, &req); - assert!(result.is_err()); - } - - #[test] - fn test_catalog_instance_types_sorted_by_vcpu_within_family() { - // Verify that within each (platform, family) group, vcpu is non-decreasing. - // This ensures our "min_by_key(vcpu)" logic works correctly. - for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] { - let entries = catalog_for_platform(platform); - let mut by_family: std::collections::HashMap<_, Vec<_>> = - std::collections::HashMap::new(); - for entry in entries { - by_family - .entry(format!("{:?}", entry.family)) - .or_default() - .push(entry); - } - for (family, instances) in &by_family { - for window in instances.windows(2) { - assert!( - window[0].vcpu <= window[1].vcpu, - "catalog not sorted by vcpu for {platform}/{family}: {} ({}) > {} ({})", - window[0].name, - window[0].vcpu, - window[1].name, - window[1].vcpu - ); - } - } - } - } -} +mod tests; diff --git a/crates/alien-core/src/instance_catalog/tests.rs b/crates/alien-core/src/instance_catalog/tests.rs new file mode 100644 index 000000000..6ef10d076 --- /dev/null +++ b/crates/alien-core/src/instance_catalog/tests.rs @@ -0,0 +1,533 @@ +use super::*; +use crate::BinaryTarget; + +// -- Parsing tests -- + +#[test] +fn test_parse_cpu_plain() { + assert_eq!(parse_cpu("1").unwrap(), 1.0); + assert_eq!(parse_cpu("0.5").unwrap(), 0.5); + assert_eq!(parse_cpu("2.0").unwrap(), 2.0); + assert_eq!(parse_cpu("16").unwrap(), 16.0); +} + +#[test] +fn test_parse_cpu_millicore() { + assert_eq!(parse_cpu("500m").unwrap(), 0.5); + assert_eq!(parse_cpu("250m").unwrap(), 0.25); + assert_eq!(parse_cpu("1000m").unwrap(), 1.0); + assert_eq!(parse_cpu("100m").unwrap(), 0.1); +} + +#[test] +fn test_parse_cpu_invalid() { + assert!(parse_cpu("").is_err()); + assert!(parse_cpu("abc").is_err()); + assert!(parse_cpu("m").is_err()); +} + +#[test] +fn test_parse_memory_binary_suffixes() { + assert_eq!(parse_memory_bytes("1Ki").unwrap(), 1024); + assert_eq!(parse_memory_bytes("1Mi").unwrap(), 1024 * 1024); + assert_eq!(parse_memory_bytes("1Gi").unwrap(), 1024 * 1024 * 1024); + assert_eq!(parse_memory_bytes("4Gi").unwrap(), 4 * 1024 * 1024 * 1024); + assert_eq!(parse_memory_bytes("512Mi").unwrap(), 512 * 1024 * 1024); + assert_eq!( + parse_memory_bytes("1Ti").unwrap(), + 1024u64 * 1024 * 1024 * 1024 + ); +} + +#[test] +fn test_parse_memory_decimal_suffixes() { + assert_eq!(parse_memory_bytes("1k").unwrap(), 1000); + assert_eq!(parse_memory_bytes("1M").unwrap(), 1_000_000); + assert_eq!(parse_memory_bytes("1G").unwrap(), 1_000_000_000); + assert_eq!(parse_memory_bytes("1T").unwrap(), 1_000_000_000_000); +} + +#[test] +fn test_parse_memory_plain_bytes() { + assert_eq!(parse_memory_bytes("1024").unwrap(), 1024); + assert_eq!(parse_memory_bytes("0").unwrap(), 0); +} + +#[test] +fn test_parse_memory_invalid() { + assert!(parse_memory_bytes("").is_err()); + assert!(parse_memory_bytes("abc").is_err()); + assert!(parse_memory_bytes("Gi").is_err()); +} + +#[test] +fn test_parse_memory_fractional() { + assert_eq!(parse_memory_bytes("0.5Gi").unwrap(), GI / 2); + assert_eq!(parse_memory_bytes("1.5Gi").unwrap(), GI + GI / 2); +} + +// -- Catalog lookup tests -- + +#[test] +fn test_catalog_has_entries_for_all_cloud_platforms() { + assert!(!catalog_for_platform(Platform::Aws).is_empty()); + assert!(!catalog_for_platform(Platform::Gcp).is_empty()); + assert!(!catalog_for_platform(Platform::Azure).is_empty()); +} + +#[test] +fn test_catalog_no_entries_for_non_cloud_platforms() { + assert!(catalog_for_platform(Platform::Local).is_empty()); + assert!(catalog_for_platform(Platform::Kubernetes).is_empty()); +} + +#[test] +fn test_find_known_instance_type() { + let spec = find_instance_type(Platform::Aws, "m7g.2xlarge").expect("should find m7g.2xlarge"); + assert_eq!(spec.vcpu, 8); + assert_eq!(spec.memory_bytes, 32 * GI); + assert_eq!(spec.family, InstanceFamily::GeneralPurpose); +} + +#[test] +fn test_find_aws_c8i_nested_virt_instance_type() { + let spec = find_instance_type(Platform::Aws, "c8i.large").expect("should find c8i.large"); + assert_eq!(spec.vcpu, 2); + assert_eq!(spec.memory_bytes, 4 * GI); + assert_eq!(spec.family, InstanceFamily::ComputeOptimized); + assert_eq!(spec.architecture, Architecture::X86_64); + assert!(spec.is_nested_virt_capable()); +} + +#[test] +fn test_find_unknown_instance_type() { + assert!(find_instance_type(Platform::Aws, "nonexistent.xlarge").is_none()); +} + +#[test] +fn test_find_wrong_platform() { + assert!(find_instance_type(Platform::Gcp, "m7g.2xlarge").is_none()); +} + +#[test] +fn test_to_machine_profile() { + let spec = find_instance_type(Platform::Aws, "m7g.2xlarge").unwrap(); + let profile = spec.to_machine_profile(); + assert_eq!(profile.cpu, "8.0"); + assert_eq!(profile.memory_bytes, 32 * GI); + assert_eq!(profile.ephemeral_storage_bytes, 20 * GI); + assert!(profile.gpu.is_none()); +} + +#[test] +fn test_to_machine_profile_with_gpu() { + let spec = find_instance_type(Platform::Aws, "p4d.24xlarge").unwrap(); + let profile = spec.to_machine_profile(); + let gpu = profile.gpu.as_ref().expect("should have GPU"); + assert_eq!(gpu.gpu_type, "nvidia-a100"); + assert_eq!(gpu.count, 8); +} + +// -- Selection algorithm tests -- + +#[test] +fn test_select_burstable_for_small_workload() { + let req = WorkloadRequirements { + total_cpu_at_desired: 1.0, + total_memory_bytes_at_desired: 2 * GI, + total_cpu_at_max: 1.0, + total_memory_bytes_at_max: 2 * GI, + max_cpu_per_container: 0.5, + max_memory_per_container: 1 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.family, InstanceFamily::Burstable); +} + +#[test] +fn test_selects_smallest_burstable_machine_with_real_headroom() { + let req = WorkloadRequirements { + total_cpu_at_desired: 1.0, + total_memory_bytes_at_desired: 2 * GI, + total_cpu_at_max: 1.0, + total_memory_bytes_at_max: 2 * GI, + max_cpu_per_container: 1.0, + max_memory_per_container: 2 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + + let selection = select_instance_type(Platform::Aws, &req).unwrap(); + + assert_eq!(selection.instance_type, "t4g.medium"); + assert_eq!(selection.min_machines, 1); + assert_eq!(selection.max_machines, 1); +} + +#[test] +fn test_select_general_purpose_for_standard_workload() { + // Standard workloads always get GeneralPurpose regardless of CPU:memory ratio + let req = WorkloadRequirements { + total_cpu_at_desired: 20.0, + total_memory_bytes_at_desired: 80 * GI, + total_cpu_at_max: 20.0, + total_memory_bytes_at_max: 80 * GI, + max_cpu_per_container: 2.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.family, InstanceFamily::GeneralPurpose); +} + +#[test] +fn test_select_general_purpose_even_for_cpu_heavy() { + // CPU-heavy workloads still get GeneralPurpose (no more ComputeOptimized auto-select) + let req = WorkloadRequirements { + total_cpu_at_desired: 20.0, + total_memory_bytes_at_desired: 20 * GI, + total_cpu_at_max: 20.0, + total_memory_bytes_at_max: 20 * GI, + max_cpu_per_container: 2.0, + max_memory_per_container: 2 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.family, InstanceFamily::GeneralPurpose); +} + +#[test] +fn test_select_storage_optimized_for_large_ephemeral() { + let req = WorkloadRequirements { + total_cpu_at_desired: 8.0, + total_memory_bytes_at_desired: 32 * GI, + total_cpu_at_max: 8.0, + total_memory_bytes_at_max: 32 * GI, + max_cpu_per_container: 2.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 500 * GI, + gpu: None, + architecture: Some(Architecture::X86_64), + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.family, InstanceFamily::StorageOptimized); +} + +#[test] +fn test_select_gpu_instance() { + let req = WorkloadRequirements { + total_cpu_at_desired: 8.0, + total_memory_bytes_at_desired: 32 * GI, + total_cpu_at_max: 8.0, + total_memory_bytes_at_max: 32 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 16 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: Some(GpuSpec { + gpu_type: "nvidia-a100".to_string(), + count: 1, + }), + architecture: Some(Architecture::X86_64), + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.family, InstanceFamily::GpuCompute); + assert!(spec.gpu.is_some()); +} + +#[test] +fn test_select_uses_each_cloud_image_target_architecture() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 16 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 16 * GI, + max_cpu_per_container: 1.0, + max_memory_per_container: 4 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] { + let sel = select_instance_type(platform, &req) + .unwrap_or_else(|error| panic!("selection failed for {platform}: {error}")); + let spec = find_instance_type(platform, sel.instance_type) + .expect("selected machine should exist in the catalog"); + assert_eq!( + Some(spec.architecture), + default_architecture(platform), + "machine architecture must match the image target for {platform}" + ); + } +} + +#[test] +fn test_machine_count_reasonable() { + // Single container: 1 CPU, 2Gi, maxReplicas=20 + let req = WorkloadRequirements { + total_cpu_at_desired: 20.0, + total_memory_bytes_at_desired: 40 * GI, + total_cpu_at_max: 20.0, + total_memory_bytes_at_max: 40 * GI, + max_cpu_per_container: 1.0, + max_memory_per_container: 2 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + assert!(sel.min_machines >= 1); + assert!(sel.max_machines <= MAX_MACHINES_PER_CLUSTER); + assert!(sel.max_machines >= sel.min_machines); +} + +#[test] +fn test_instance_size_capped_at_8_vcpu() { + // Even with very large containers, instance size is capped at 8 vCPUs + let req = WorkloadRequirements { + total_cpu_at_desired: 70.0, + total_memory_bytes_at_desired: 140 * GI, + total_cpu_at_max: 70.0, + total_memory_bytes_at_max: 140 * GI, + max_cpu_per_container: 2.0, + max_memory_per_container: 4 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Gcp, &req).unwrap(); + let spec = find_instance_type(Platform::Gcp, sel.instance_type).unwrap(); + assert!( + spec.vcpu <= MAX_STANDARD_VCPU, + "selected {} with {} vCPUs, expected <= {}", + spec.name, + spec.vcpu, + MAX_STANDARD_VCPU + ); + assert_eq!(spec.family, InstanceFamily::GeneralPurpose); + // Should scale horizontally instead + assert!(sel.max_machines > 1); +} + +#[test] +fn test_larger_autoscaled_workload_gets_reasonable_instance() { + // Simulates a larger autoscaled workload: 4 containers, each 2 CPU / 4 GiB + // maxReplicas: 10, 10, 10, 5 + let req = WorkloadRequirements { + total_cpu_at_desired: 70.0, + total_memory_bytes_at_desired: 140 * GI, + total_cpu_at_max: 70.0, // 2*10 + 2*10 + 2*10 + 2*5 + total_memory_bytes_at_max: 140 * GI, // 4*10 + 4*10 + 4*10 + 4*5 + max_cpu_per_container: 2.0, + max_memory_per_container: 4 * GI, + max_ephemeral_storage_bytes: 20 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Gcp, &req).unwrap(); + // Should pick n2-standard-8 (8 vCPU, 32 GiB) — NOT c3-standard-44 + assert_eq!(sel.instance_type, "n2-standard-8"); + assert!(sel.max_machines >= 2); +} + +/// When `nested_virt` is set on the workload, the selector must +/// restrict to nested-virt-capable families. On AWS that means an m8i +/// (or other 8th-gen Intel) entry, never a Graviton (`*7g`, `t4g`) or +/// burstable. Without this filter the launch template gets created +/// with `CpuOptions.NestedVirtualization=enabled` paired with an +/// instance type AWS rejects at RunInstances. +#[test] +fn test_select_aws_picks_m8i_when_nested_virt_required() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 8 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 8 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: Some(Architecture::X86_64), + nested_virt: true, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + assert!( + sel.instance_type.starts_with("m8i.") + || sel.instance_type.starts_with("c8i.") + || sel.instance_type.starts_with("r8i."), + "expected an m8i/c8i/r8i instance, got {}", + sel.instance_type + ); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert!(spec.is_nested_virt_capable()); +} + +#[test] +fn test_select_aws_defaults_to_image_target_architecture() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 8 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 8 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.architecture, Architecture::Arm64); +} + +#[test] +fn test_cloud_defaults_match_image_target_architectures() { + for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] { + let target = BinaryTarget::defaults_for_platform(platform) + .into_iter() + .next() + .expect("managed cloud should have a default image target"); + let image_architecture = match target.oci_arch() { + "arm64" => Architecture::Arm64, + "amd64" => Architecture::X86_64, + architecture => { + panic!("unsupported managed-cloud image architecture {architecture}") + } + }; + + assert_eq!(default_architecture(platform), Some(image_architecture)); + } +} + +/// ARM remains available when the workload or capacity profile declares it. +#[test] +fn test_select_aws_uses_graviton_for_explicit_arm64() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 8 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 8 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 8 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: Some(Architecture::Arm64), + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + let spec = find_instance_type(Platform::Aws, sel.instance_type).unwrap(); + assert_eq!(spec.architecture, Architecture::Arm64); +} + +#[test] +fn test_select_rejects_explicit_architecture_missing_from_cloud_catalog() { + let req = WorkloadRequirements { + total_cpu_at_desired: 1.0, + total_memory_bytes_at_desired: 2 * GI, + total_cpu_at_max: 1.0, + total_memory_bytes_at_max: 2 * GI, + max_cpu_per_container: 1.0, + max_memory_per_container: 2 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: Some(Architecture::Arm64), + nested_virt: false, + }; + + let error = + select_instance_type(Platform::Gcp, &req).expect_err("GCP catalog has no ARM64 machine"); + + assert!(error.contains("architecture Arm64 is unavailable")); +} + +#[test] +fn test_profile_has_required_fields() { + let req = WorkloadRequirements { + total_cpu_at_desired: 4.0, + total_memory_bytes_at_desired: 16 * GI, + total_cpu_at_max: 4.0, + total_memory_bytes_at_max: 16 * GI, + max_cpu_per_container: 1.0, + max_memory_per_container: 4 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: None, + architecture: None, + nested_virt: false, + }; + let sel = select_instance_type(Platform::Aws, &req).unwrap(); + assert!(!sel.profile.cpu.is_empty()); + assert!(sel.profile.memory_bytes > 0); + assert!(sel.profile.ephemeral_storage_bytes > 0); +} + +#[test] +fn test_error_for_unsupported_gpu_type() { + let req = WorkloadRequirements { + total_cpu_at_desired: 8.0, + total_memory_bytes_at_desired: 32 * GI, + total_cpu_at_max: 8.0, + total_memory_bytes_at_max: 32 * GI, + max_cpu_per_container: 4.0, + max_memory_per_container: 16 * GI, + max_ephemeral_storage_bytes: 10 * GI, + gpu: Some(GpuSpec { + gpu_type: "amd-mi300".to_string(), + count: 1, + }), + architecture: None, + nested_virt: false, + }; + let result = select_instance_type(Platform::Aws, &req); + assert!(result.is_err()); +} + +#[test] +fn test_catalog_instance_types_sorted_by_vcpu_within_family() { + // Verify that within each (platform, family) group, vcpu is non-decreasing. + // This ensures our "min_by_key(vcpu)" logic works correctly. + for platform in [Platform::Aws, Platform::Gcp, Platform::Azure] { + let entries = catalog_for_platform(platform); + let mut by_family: std::collections::HashMap<_, Vec<_>> = std::collections::HashMap::new(); + for entry in entries { + by_family + .entry(format!("{:?}", entry.family)) + .or_default() + .push(entry); + } + for (family, instances) in &by_family { + for window in instances.windows(2) { + assert!( + window[0].vcpu <= window[1].vcpu, + "catalog not sorted by vcpu for {platform}/{family}: {} ({}) > {} ({})", + window[0].name, + window[0].vcpu, + window[1].name, + window[1].vcpu + ); + } + } + } +} diff --git a/crates/alien-deploy-cli/src/commands/up.rs b/crates/alien-deploy-cli/src/commands/up.rs deleted file mode 100644 index 38ffbe6fc..000000000 --- a/crates/alien-deploy-cli/src/commands/up.rs +++ /dev/null @@ -1,3991 +0,0 @@ -//! Deploy command — sets up and runs a deployment. -//! -//! Push model (AWS, GCP, Azure): runs initial setup locally, then the manager -//! continues reconciliation remotely. -//! -//! Pull model (Local, Kubernetes): installs and starts the alien-operator service. - -use crate::deployment_tracking::{DeploymentTracker, TrackedLocalDeployment}; -use crate::error::{ErrorData, Result}; -use crate::output; -use alien_cli_common::network::{self, NetworkArgs, NetworkMode}; -use alien_core::embedded_config::DeployCliConfig; -use alien_core::{ - parse_public_endpoint_assignment, validate_public_endpoint_urls, ClientConfig, ComputeSettings, - Container, Daemon, DeploymentConfig, DeploymentModel, DeploymentState, DeploymentStatus, - ManagementConfig, NetworkSettings, Platform, PublicEndpointUrls, ReleaseInfo, Stack, - StackInputDefinition, StackInputKind, StackInputProvider, StackSettings, TelemetryMode, - UpdatesMode, Worker, -}; -use alien_deployment::{ - loop_contract::{LoopOperation, LoopOutcome, LoopResult, LoopStopReason}, - manager_api_transport::{ - acquire_runtime_delete_deployment, acquire_setup_delete_deployment, - acquire_setup_run_deployment, final_reconcile, release_deployment, ManagerApiTransport, - SetupDeleteAcquireOutcome, - }, - runner::{run_step_loop as shared_run_step_loop, RunnerPolicy, RunnerResult}, -}; -use alien_error::{AlienError, Context, ContextError, IntoAlienError}; -use alien_infra::ClientConfigExt; -use alien_manager_api::{Client as ServerClient, SdkResultExt as ManagerSdkResultExt}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use clap::Parser; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{BTreeSet, HashMap}, - io::{IsTerminal, Write}, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, -}; - -#[derive(Parser, Debug, Clone)] -#[command( - about = "Deploy the application to a target environment", - after_help = "EXAMPLES: - # Deploy to AWS using a deployment group token - alien-deploy deploy --token ax_dg_abc123... --platform aws - - # Deploy using a token file so the token is not exposed in argv - alien-deploy deploy --token-file /run/alien/token --platform local - - # Deploy a local pull-model workload behind customer-managed ingress - alien-deploy deploy --token-file /run/alien/token --platform local --public-endpoint gateway.api=https://gateway.example.com - - # Deploy with an isolated VPC - alien-deploy deploy --token ax_dg_abc123... --platform aws --network create - - # Deploy into an existing VPC - alien-deploy deploy --token ax_dg_abc123... --platform aws --network byo --vpc-id vpc-0abc123 --public-subnet-ids subnet-pub1 --private-subnet-ids subnet-priv1 - - # Redeploy an existing tracked deployment - alien-deploy deploy --name production - - # Deploy to a standalone (OSS) manager - ALIEN_MANAGER_URL=https://manager.example.com alien-deploy deploy --token ax_dg_abc123... --platform aws" -)] -pub struct UpArgs { - /// Authentication token (deployment or deployment group token) - #[arg(long, env = "ALIEN_TOKEN")] - pub token: Option, - - /// Read authentication token from a file. - #[arg(long, conflicts_with = "token")] - pub token_file: Option, - - /// Manager URL override for pull-model platforms. - /// Cloud push deployments resolve their manager and install context from - /// the platform API so setup has the management configuration it needs. - #[arg(long, env = "ALIEN_MANAGER_URL")] - pub manager_url: Option, - - /// Platform API base URL. - /// Used for manager discovery when ALIEN_MANAGER_URL is not set. - #[arg(long, env = "ALIEN_BASE_URL")] - pub base_url: Option, - - /// Target platform (aws, gcp, azure, kubernetes, machines, local) - #[arg(long)] - pub platform: Option, - - /// Base cloud platform for managed Kubernetes setup (aws, gcp, azure). - #[arg(long, env = "OPERATOR_BASE_PLATFORM")] - pub base_platform: Option, - - /// Deployment name (for tracking) - #[arg(long)] - pub name: Option, - - /// Encryption key for operator database (required for pull model) - #[arg(long, env = "OPERATOR_ENCRYPTION_KEY")] - pub encryption_key: Option, - - /// Skip confirmation prompt - #[arg(long, short = 'y')] - pub yes: bool, - - /// Run the operator in the foreground instead of installing as a service. - /// Useful for testing — Ctrl+C to stop. - #[arg(long)] - pub foreground: bool, - - /// Data directory for operator state (foreground mode only). - /// Defaults to ~/.alien/operator-data. - #[arg(long)] - pub data_dir: Option, - - /// Enable Local runtime debug commands and shells on the installed operator service. - #[arg(long)] - pub enable_local_debug: bool, - - /// Override the shell command used by Local runtime debug shells. - #[arg(long)] - pub local_debug_shell_command: Option, - - /// Kubernetes namespace for Helm installs. - #[arg(long, env = "ALIEN_KUBERNETES_NAMESPACE")] - pub namespace: Option, - - /// Helm release name for Kubernetes installs. - #[arg(long, env = "ALIEN_HELM_RELEASE")] - pub helm_release: Option, - - /// Kubeconfig path for Kubernetes installs. Defaults to KUBECONFIG or kubectl defaults. - #[arg(long, env = "KUBECONFIG")] - pub kubeconfig: Option, - - /// Kubernetes context for Helm installs. - #[arg(long, env = "ALIEN_KUBE_CONTEXT")] - pub kube_context: Option, - - /// alien-operator image for Kubernetes Helm installs. - #[arg(long, env = "ALIEN_OPERATOR_IMAGE")] - pub operator_image: Option, - - /// TOML file containing deployment settings. - #[arg(long)] - pub config: Option, - - /// Stack input value for setup (id=value). - #[arg(long = "input")] - pub input_values: Vec, - - /// Secret stack input value for setup (id=value). - #[arg(long = "secret-input")] - pub secret_input_values: Vec, - - /// Public URL for an exposed endpoint in .= form. - /// - /// Intended for pull-model deployments where DNS, TLS, and ingress are - /// owned outside Alien. Repeat this flag for multiple endpoints. - #[arg(long = "public-endpoint")] - pub public_endpoints: Vec, - - #[command(flatten)] - pub network: NetworkArgs, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct DeployConfigFile { - /// Deployment name. - name: Option, - /// Target platform: aws, gcp, azure, kubernetes, machines, or local. - platform: Option, - /// Base cloud platform when `platform = "kubernetes"`. - base_platform: Option, - /// Network settings for cloud deployments. - network: Option, - /// Update delivery mode. - updates: Option, - /// Telemetry delivery mode. - telemetry: Option, - /// Static compute selections for Alien-managed runtime pools. - compute: Option, - /// Generic public endpoint URLs for pull-model deployments. - public_endpoints: Option, - /// Deployer-provided stack inputs. - inputs: Option>, - /// Secret deployer-provided stack inputs. - secret_inputs: Option>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] -enum DeployConfigNetwork { - UseDefault, - Create { - cidr: Option, - #[serde(default = "default_config_availability_zones")] - availability_zones: u8, - }, - ByoVpcAws { - vpc_id: String, - public_subnet_ids: Vec, - private_subnet_ids: Vec, - #[serde(default)] - security_group_ids: Vec, - }, - ByoVpcGcp { - network_name: String, - subnet_name: String, - region: String, - }, - ByoVnetAzure { - vnet_resource_id: String, - public_subnet_name: String, - private_subnet_name: String, - }, -} - -fn default_config_availability_zones() -> u8 { - 2 -} - -impl From for NetworkSettings { - fn from(value: DeployConfigNetwork) -> Self { - match value { - DeployConfigNetwork::UseDefault => NetworkSettings::UseDefault, - DeployConfigNetwork::Create { - cidr, - availability_zones, - } => NetworkSettings::Create { - cidr, - availability_zones, - }, - DeployConfigNetwork::ByoVpcAws { - vpc_id, - public_subnet_ids, - private_subnet_ids, - security_group_ids, - } => NetworkSettings::ByoVpcAws { - vpc_id, - public_subnet_ids, - private_subnet_ids, - security_group_ids, - }, - DeployConfigNetwork::ByoVpcGcp { - network_name, - subnet_name, - region, - } => NetworkSettings::ByoVpcGcp { - network_name, - subnet_name, - region, - }, - DeployConfigNetwork::ByoVnetAzure { - vnet_resource_id, - public_subnet_name, - private_subnet_name, - } => NetworkSettings::ByoVnetAzure { - vnet_resource_id, - public_subnet_name, - private_subnet_name, - application_gateway_subnet_name: None, - private_endpoint_subnet_name: None, - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - use std::io::Write; - - #[test] - fn cloud_push_platforms_require_install_context() { - assert!(requires_install_context(Platform::Aws)); - assert!(requires_install_context(Platform::Gcp)); - assert!(requires_install_context(Platform::Azure)); - } - - #[test] - fn pull_model_platforms_do_not_require_install_context() { - assert!(!requires_install_context(Platform::Kubernetes)); - assert!(!requires_install_context(Platform::Machines)); - assert!(!requires_install_context(Platform::Local)); - assert!(!requires_install_context(Platform::Test)); - } - - #[test] - fn deployment_readiness_not_ready_blocks_with_check_codes() { - let info = DeploymentInfoResponse { - setup_config: None, - readiness: Some(DeploymentReadiness { - status: "notReady".to_string(), - checks: vec![ - DeploymentReadinessCheck { - code: "MACHINE_BUNDLE_ARTIFACTS".to_string(), - status: "failed".to_string(), - message: "Machine bundle manifest is missing linux-x64.".to_string(), - }, - DeploymentReadinessCheck { - code: "MANAGER_ACQUIRES_PLATFORM".to_string(), - status: "passed".to_string(), - message: "A machines-capable manager is ready.".to_string(), - }, - ], - }), - }; - - let error = validate_deployment_readiness(&info, Platform::Machines) - .expect_err("notReady readiness must block the deploy"); - assert!(error.message.contains("machines deployments are not ready")); - assert!(error.message.contains("MACHINE_BUNDLE_ARTIFACTS")); - assert!(!error.message.contains("MANAGER_ACQUIRES_PLATFORM")); - } - - #[test] - fn deployment_readiness_unknown_checks_do_not_block() { - let info = DeploymentInfoResponse { - setup_config: None, - readiness: Some(DeploymentReadiness { - status: "unknown".to_string(), - checks: vec![DeploymentReadinessCheck { - code: "MACHINES_HORIZON_CONTROL_PLANE_REACHABLE".to_string(), - status: "unknown".to_string(), - message: "Horizon control plane reachability could not be confirmed." - .to_string(), - }], - }), - }; - - validate_deployment_readiness(&info, Platform::Machines) - .expect("unknown readiness must not block the deploy"); - } - - #[test] - fn absent_readiness_does_not_block() { - let info = DeploymentInfoResponse { - setup_config: None, - readiness: None, - }; - - validate_deployment_readiness(&info, Platform::Machines) - .expect("absent readiness document must not block the deploy"); - } - - #[test] - fn machines_join_guidance_uses_install_script_when_available() { - assert_eq!( - machines_join_command( - "acmectl", - Some("https://packages.example.com/ws/prj/install.sh"), - "aj1_wrapped-token" - ), - "curl -fsSL 'https://packages.example.com/ws/prj/install.sh' | sudo bash -s -- join --token 'aj1_wrapped-token'" - ); - } - - #[test] - fn machines_join_guidance_shell_quotes_install_script_and_token() { - assert_eq!( - machines_join_command( - "alien-deploy", - Some("https://packages.example.com/ws/prj/install script.sh"), - "aj1_token'with-quote" - ), - "curl -fsSL 'https://packages.example.com/ws/prj/install script.sh' | sudo bash -s -- join --token 'aj1_token'\"'\"'with-quote'" - ); - } - - #[test] - fn machines_join_guidance_falls_back_to_installed_cli_without_install_script() { - assert_eq!( - machines_join_command("acmectl", None, "aj1_wrapped-token"), - "sudo acmectl join --token 'aj1_wrapped-token'" - ); - } - - #[test] - fn machines_join_token_response_preserves_wrapped_token() { - let token = normalize_machines_join_token_response(MachinesJoinTokenResponse { - join_token: " aj1_wrapped-token ".to_string(), - control_plane_url: Some("https://horizon.example.com".to_string()), - cluster_id: Some("cluster-123".to_string()), - }) - .expect("wrapped token should be accepted"); - - assert_eq!(token, "aj1_wrapped-token"); - } - - #[test] - fn machines_join_token_response_wraps_raw_token_with_context() { - let token = normalize_machines_join_token_response(MachinesJoinTokenResponse { - join_token: " hj_secret ".to_string(), - control_plane_url: Some(" https://horizon.example.com ".to_string()), - cluster_id: Some(" cluster-123 ".to_string()), - }) - .expect("raw token with context should be wrapped"); - - assert!(token.starts_with("aj1_")); - let payload = URL_SAFE_NO_PAD - .decode(token.trim_start_matches("aj1_")) - .expect("wrapped token should be base64url"); - let payload: serde_json::Value = - serde_json::from_slice(&payload).expect("wrapped token should be JSON"); - assert_eq!(payload["joinToken"], "hj_secret"); - assert_eq!(payload["controlPlaneUrl"], "https://horizon.example.com"); - assert_eq!(payload["clusterId"], "cluster-123"); - } - - #[test] - fn machines_join_token_response_rejects_raw_token_without_context() { - let error = normalize_machines_join_token_response(MachinesJoinTokenResponse { - join_token: "hj_secret".to_string(), - control_plane_url: None, - cluster_id: Some("cluster-123".to_string()), - }) - .expect_err("raw token should require context"); - - assert_eq!(error.code, "CONFIGURATION_ERROR"); - assert!(error.message.contains("without control plane context")); - } - - #[test] - fn machines_deploy_prints_only_join_command() { - assert!(!should_print_deploy_progress(Platform::Machines)); - } - - #[test] - fn machines_push_setup_suppresses_neutral_completion_message() { - assert!(!should_print_push_setup_neutral_completion( - Platform::Machines - )); - assert!(should_print_push_setup_neutral_completion(Platform::Aws)); - } - - #[test] - fn machines_push_setup_does_not_collect_cloud_environment() { - assert!(!should_collect_push_setup_environment_info( - Platform::Machines - )); - assert!(should_collect_push_setup_environment_info(Platform::Aws)); - assert!(should_collect_push_setup_environment_info( - Platform::Kubernetes - )); - } - - #[test] - fn non_machines_deploy_prints_progress() { - assert!(should_print_deploy_progress(Platform::Aws)); - assert!(should_print_deploy_progress(Platform::Local)); - } - - #[test] - fn load_stack_settings_omits_server_owned_deployment_model() { - let args = UpArgs::parse_from(["alien-deploy", "--platform", "machines"]); - let settings = - load_stack_settings(&args, Platform::Machines, None).expect("settings should load"); - - let wire = serde_json::to_value(settings).expect("settings should serialize"); - assert_eq!(wire.get("deploymentModel"), None); - } - - #[test] - fn load_stack_settings_requests_pull_for_local() { - let args = UpArgs::parse_from(["alien-deploy", "--platform", "local"]); - let settings = - load_stack_settings(&args, Platform::Local, None).expect("settings should load"); - - assert_eq!(settings.deployment_model, DeploymentModel::Pull); - let wire = serde_json::to_value(sdk_stack_settings(&settings).expect("sdk settings")) - .expect("settings should serialize"); - assert_eq!( - wire.get("deploymentModel"), - Some(&serde_json::json!("pull")) - ); - } - - #[test] - fn sdk_stack_settings_serializes_explicit_deployment_model() { - let settings = StackSettings { - deployment_model: DeploymentModel::Pull, - ..StackSettings::default() - }; - - let wire = serde_json::to_value( - sdk_stack_settings(&settings).expect("settings should convert to SDK type"), - ) - .expect("settings should serialize"); - - assert_eq!( - wire.get("deploymentModel"), - Some(&serde_json::json!("pull")) - ); - } - - #[test] - fn local_tracking_uses_service_data_dir_by_default() { - let args = UpArgs::parse_from(["alien-deploy", "--platform", "local"]); - let local = local_tracking_metadata(&args, Platform::Local) - .expect("local deployments should be tracked with local metadata"); - - assert_eq!( - local.data_dir, - crate::commands::operator::default_service_data_dir() - ); - assert!(local.service_managed); - } - - #[test] - fn local_tracking_uses_foreground_data_dir() { - let args = UpArgs::parse_from([ - "alien-deploy", - "--platform", - "local", - "--foreground", - "--data-dir", - "/tmp/alien-foreground-state", - ]); - let local = local_tracking_metadata(&args, Platform::Local) - .expect("local deployments should be tracked with local metadata"); - - assert_eq!(local.data_dir, "/tmp/alien-foreground-state"); - assert!(!local.service_managed); - } - - #[test] - fn stack_settings_external_bindings_are_copied_to_deployment_config() { - let mut external_bindings = alien_core::ExternalBindings::new(); - external_bindings.insert( - "storage", - alien_core::ExternalBinding::Storage(alien_core::StorageBinding::s3("test-bucket")), - ); - let stack_settings = StackSettings { - external_bindings: Some(external_bindings), - ..StackSettings::default() - }; - let mut config = DeploymentConfig::builder() - .stack_settings(stack_settings.clone()) - .environment_variables(alien_core::EnvironmentVariablesSnapshot { - variables: vec![], - hash: String::new(), - created_at: String::new(), - }) - .external_bindings(alien_core::ExternalBindings::default()) - .allow_frozen_changes(false) - .build(); - - assert!(!config.external_bindings.has("storage")); - apply_external_bindings_from_stack_settings(&mut config, &stack_settings); - - assert!(config.external_bindings.has("storage")); - } - - #[test] - fn deploy_config_file_accepts_public_endpoints() { - let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); - writeln!( - file, - r#" - name = "local-gateway" - platform = "local" - - [publicEndpoints.gateway] - api = "https://gateway.example.test" - "# - ) - .expect("config should be written"); - - let args = UpArgs::parse_from([ - "alien-deploy", - "--config", - file.path().to_str().expect("temp path should be UTF-8"), - ]); - - let config = load_deploy_config(&args) - .expect("config should load") - .expect("config should exist"); - - assert_eq!(config.name.as_deref(), Some("local-gateway")); - assert_eq!( - config - .public_endpoints - .as_ref() - .and_then(|resources| resources.get("gateway")) - .and_then(|endpoints| endpoints.get("api")) - .map(String::as_str), - Some("https://gateway.example.test") - ); - } - - #[test] - fn public_endpoint_flag_overrides_config_public_endpoint() { - let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); - writeln!( - file, - r#" -platform = "local" - -[publicEndpoints.gateway] -api = "https://old.example.test" -"# - ) - .expect("config should be written"); - - let args = UpArgs::parse_from([ - "alien-deploy", - "--config", - file.path().to_str().expect("temp path should be UTF-8"), - "--public-endpoint", - "gateway.api=https://new.example.test", - ]); - let config = load_deploy_config(&args) - .expect("config should load") - .expect("config should exist"); - let public_endpoints = load_public_endpoints(&args, Platform::Local, Some(&config)) - .expect("public endpoints should load") - .expect("public endpoints should exist"); - - assert_eq!( - public_endpoints - .get("gateway") - .and_then(|endpoints| endpoints.get("api")) - .map(String::as_str), - Some("https://new.example.test") - ); - } - - #[test] - fn public_endpoint_flag_rejects_cloud_platforms() { - let args = UpArgs::parse_from([ - "alien-deploy", - "--platform", - "aws", - "--public-endpoint", - "gateway.api=https://gateway.example.test", - ]); - - let error = - load_public_endpoints(&args, Platform::Aws, None).expect_err("aws should be rejected"); - assert_eq!(error.code, "VALIDATION_ERROR"); - } - - #[test] - fn public_endpoint_flag_accepts_machines_platform() { - let args = UpArgs::parse_from([ - "alien-deploy", - "--platform", - "machines", - "--public-endpoint", - "gateway.api=https://gateway.example.test", - ]); - - let public_endpoints = load_public_endpoints(&args, Platform::Machines, None) - .expect("machines should accept external public endpoints") - .expect("public endpoints should exist"); - - assert_eq!( - public_endpoints - .get("gateway") - .and_then(|endpoints| endpoints.get("api")) - .map(String::as_str), - Some("https://gateway.example.test") - ); - } - - #[test] - fn public_endpoint_names_must_be_declared() { - let daemon = alien_core::Daemon::new("gateway".to_string()) - .code(alien_core::DaemonCode::Image { - image: "gateway:latest".to_string(), - }) - .permissions("default".to_string()) - .public_endpoint(alien_core::PublicEndpoint { - name: "api".to_string(), - port: 8080, - protocol: alien_core::ExposeProtocol::Http, - host_label: None, - wildcard_subdomains: false, - }) - .build(); - let stack = Stack::new("test".to_string()) - .add(daemon, alien_core::ResourceLifecycle::Live) - .build(); - - let valid = HashMap::from([( - "gateway".to_string(), - HashMap::from([( - "api".to_string(), - "https://gateway.example.test".to_string(), - )]), - )]); - validate_public_endpoint_names(&valid, &stack).expect("gateway exposes a public endpoint"); - - let invalid = HashMap::from([( - "gateway".to_string(), - HashMap::from([( - "missing".to_string(), - "https://missing.example.test".to_string(), - )]), - )]); - let error = validate_public_endpoint_names(&invalid, &stack) - .expect_err("missing endpoint should fail"); - assert_eq!(error.code, "VALIDATION_ERROR"); - } - - #[test] - fn deploy_config_file_accepts_compute_pool_selection() { - let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); - writeln!( - file, - r#" -name = "cloud-runtime" -platform = "aws" - -[compute.pools.general] -mode = "autoscale" -min = 2 -max = 5 -machine = "m8i.xlarge" -"# - ) - .expect("config should be written"); - - let args = UpArgs::parse_from([ - "alien-deploy", - "--config", - file.path().to_str().expect("temp path should be UTF-8"), - ]); - - let config = load_deploy_config(&args) - .expect("config should load") - .expect("config should exist"); - let settings = load_stack_settings(&args, Platform::Aws, Some(&config)) - .expect("stack settings should load"); - let selection = settings - .compute - .as_ref() - .and_then(|compute| compute.pools.get("general")) - .expect("general compute pool should be configured"); - - assert_eq!(selection.machine(), Some("m8i.xlarge")); - assert_eq!(selection.min_size(), 2); - assert_eq!(selection.max_size(), 5); - } - - #[test] - fn parses_cloud_base_platform_for_kubernetes() { - assert_eq!( - parse_base_platform(Platform::Kubernetes, Some("aws")).unwrap(), - Some(Platform::Aws) - ); - } - - #[test] - fn rejects_base_platform_without_kubernetes_runtime() { - assert!(parse_base_platform(Platform::Aws, Some("gcp")).is_err()); - } - - #[test] - fn rejects_non_cloud_base_platform_for_kubernetes() { - assert!(parse_base_platform(Platform::Kubernetes, Some("local")).is_err()); - } - - #[test] - fn release_stack_for_kubernetes_uses_runtime_platform_not_base_platform() { - let stack = alien_manager_api::types::StackByPlatform { - aws: Some(serde_json::json!({ "id": "aws-stack" })), - gcp: Some(serde_json::json!({ "id": "gcp-stack" })), - azure: Some(serde_json::json!({ "id": "azure-stack" })), - kubernetes: Some(serde_json::json!({ "id": "kubernetes-stack" })), - machines: None, - local: None, - test: None, - }; - - let selected = release_stack_value_for_platform(stack, Platform::Kubernetes).unwrap(); - assert_eq!(selected["id"], "kubernetes-stack"); - } - - #[test] - fn split_image_tag_defaults_missing_tag_to_latest() { - assert_eq!( - split_image_tag("ghcr.io/alienplatform/alien-operator").unwrap(), - ( - "ghcr.io/alienplatform/alien-operator".to_string(), - "latest".to_string() - ) - ); - } - - #[test] - fn split_image_tag_preserves_registry_port() { - assert_eq!( - split_image_tag("localhost:5000/alien-operator:v1").unwrap(), - ( - "localhost:5000/alien-operator".to_string(), - "v1".to_string() - ) - ); - } - - #[test] - fn sanitize_kubernetes_dns_label_falls_back_when_empty() { - assert_eq!(sanitize_kubernetes_dns_label("___"), "alien"); - } - - #[test] - fn resolve_token_reads_token_file() { - let mut token_file = tempfile::NamedTempFile::new().expect("token file"); - token_file - .write_all(b" ax_dg_file_token\n") - .expect("write token"); - - let cli = crate::Cli::try_parse_from([ - "alien-deploy", - "deploy", - "--token-file", - token_file.path().to_str().expect("utf8 path"), - "--platform", - "local", - ]) - .expect("parse deploy"); - let crate::Commands::Deploy(args) = cli.command else { - panic!("expected deploy variant"); - }; - - let token = resolve_token(&args, None).expect("token should resolve"); - assert_eq!(token, "ax_dg_file_token"); - } - - #[test] - fn resolve_token_rejects_empty_token_file() { - let token_file = tempfile::NamedTempFile::new().expect("token file"); - - let cli = crate::Cli::try_parse_from([ - "alien-deploy", - "deploy", - "--token-file", - token_file.path().to_str().expect("utf8 path"), - "--platform", - "local", - ]) - .expect("parse deploy"); - let crate::Commands::Deploy(args) = cli.command else { - panic!("expected deploy variant"); - }; - - let error = resolve_token(&args, None).expect_err("empty token file should fail"); - assert_eq!(error.code, "VALIDATION_ERROR"); - } - - fn stack_input(id: &str, kind: StackInputKind, required: bool) -> StackInputDefinition { - StackInputDefinition { - id: id.to_string(), - kind, - provided_by: vec![StackInputProvider::Deployer], - required, - label: id.to_string(), - description: "Test input".to_string(), - placeholder: None, - default: None, - platforms: None, - validation: None, - env: vec![], - } - } - - #[test] - fn deploy_config_file_accepts_stack_inputs() { - let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); - writeln!( - file, - r#" -platform = "local" - -[inputs] -region = "us-east-1" - -[secretInputs] -apiKey = "secret-value" -"# - ) - .expect("config should be written"); - - let args = UpArgs::parse_from([ - "alien-deploy", - "--config", - file.path().to_str().expect("temp path should be UTF-8"), - ]); - let config = load_deploy_config(&args) - .expect("config should load") - .expect("config should exist"); - let values = collect_deployer_input_values( - &[ - stack_input("region", StackInputKind::String, true), - stack_input("apiKey", StackInputKind::Secret, true), - ], - &[], - &[], - Some(&config), - ) - .expect("input values should parse"); - - assert_eq!(values.get("region"), Some(&serde_json::json!("us-east-1"))); - assert_eq!( - values.get("apiKey"), - Some(&serde_json::json!("secret-value")) - ); - } - - #[test] - fn stack_input_flags_override_config_values() { - let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); - writeln!( - file, - r#" -platform = "local" - -[inputs] -region = "old" -"# - ) - .expect("config should be written"); - - let args = UpArgs::parse_from([ - "alien-deploy", - "--config", - file.path().to_str().expect("temp path should be UTF-8"), - "--input", - "region=new", - ]); - let config = load_deploy_config(&args) - .expect("config should load") - .expect("config should exist"); - let values = collect_deployer_input_values( - &[stack_input("region", StackInputKind::String, true)], - &args.input_values, - &args.secret_input_values, - Some(&config), - ) - .expect("input values should parse"); - - assert_eq!(values.get("region"), Some(&serde_json::json!("new"))); - } - - #[test] - fn required_stack_inputs_fail_non_interactively() { - let error = collect_deployer_input_values( - &[stack_input("apiKey", StackInputKind::Secret, true)], - &[], - &[], - None, - ) - .expect_err("missing required input should fail"); - - assert_eq!(error.code, "VALIDATION_ERROR"); - assert!(error.message.contains("Missing deployer input")); - } - - #[test] - fn stack_input_values_are_typed() { - let values = collect_deployer_input_values( - &[ - stack_input("replicas", StackInputKind::Integer, true), - stack_input("enabled", StackInputKind::Boolean, true), - stack_input("hosts", StackInputKind::StringList, true), - ], - &[ - "replicas=3".to_string(), - "enabled=true".to_string(), - "hosts=a.example.com,b.example.com".to_string(), - ], - &[], - None, - ) - .expect("input values should parse"); - - assert_eq!(values.get("replicas"), Some(&serde_json::json!(3))); - assert_eq!(values.get("enabled"), Some(&serde_json::json!(true))); - assert_eq!( - values.get("hosts"), - Some(&serde_json::json!(["a.example.com", "b.example.com"])) - ); - } -} - -pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) -> Result<()> { - let deploy_config = load_deploy_config(&args)?; - // Resolve token and platform from args, embedded config, or tracked deployment - let resolved = resolve_deployment_info(&args, embedded_config, deploy_config.as_ref())?; - let token = resolved.token; - let platform_str = resolved.platform; - let base_platform_str = resolved.base_platform; - let name = resolved.name; - - let platform = Platform::from_str(&platform_str).map_err(|e| { - AlienError::new(ErrorData::ValidationError { - field: "platform".to_string(), - message: e, - }) - })?; - let print_progress = should_print_deploy_progress(platform); - let base_platform = parse_base_platform(platform, base_platform_str.as_deref())?; - let public_endpoints = load_public_endpoints(&args, platform, deploy_config.as_ref())?; - let deployer_inputs = match fetch_deployment_info(&resolved.base_url, &token, platform).await { - Ok(info) => { - validate_deployment_readiness(&info, platform)?; - deployer_inputs_from_info(&info, platform) - } - Err(error) => { - if !args.input_values.is_empty() || !args.secret_input_values.is_empty() { - output::warn(&format!( - "Could not load stack input metadata; the platform API will validate supplied inputs: {error}" - )); - } - Vec::new() - } - }; - let stack_input_values = collect_deployer_input_values( - &deployer_inputs, - &args.input_values, - &args.secret_input_values, - deploy_config.as_ref(), - )?; - - let display_platform = match platform_str.as_str() { - "aws" => "AWS", - "gcp" => "Google Cloud", - "azure" => "Azure", - "kubernetes" => "Kubernetes", - "machines" => "Your machines", - "local" => "Local", - other => other, - }; - - let install_context_platform = base_platform.unwrap_or(platform); - let install_context_platform_str = install_context_platform.as_str().to_string(); - let (manager_url, install_management_config) = - if requires_install_context(install_context_platform) { - if print_progress { - output::info("Resolving deployment install context via platform API..."); - } - let context = discover_manager_install_context( - &resolved.base_url, - &token, - &install_context_platform_str, - ) - .await?; - // `management_config` is required by the production SaaS API to - // describe the cross-account role used at provisioning time. The - // standalone manager returns it as `None` when it runs in a - // single-account setup (where the deployment account *is* the - // managing account and no cross-account access is involved); - // downstream code is already `Option`-aware. - (context.manager_url, context.management_config) - } else { - match resolved.manager_url { - Some(url) => (url, None), - None => { - if print_progress { - output::info("Discovering manager via platform API..."); - } - let context = discover_manager_install_context( - &resolved.base_url, - &token, - &install_context_platform_str, - ) - .await?; - (context.manager_url, context.management_config) - } - } - }; - - if print_progress { - let banner_title = embedded_config - .and_then(|c| c.display_name.as_deref()) - .unwrap_or("Alien Deploy"); - output::banner(banner_title); - output::label_value("Platform", display_platform); - if let Some(base_platform) = base_platform { - output::label_value("Base platform", base_platform.as_str()); - } - output::label_value("Manager", &manager_url); - output::label_value("Name", &name); - if let Some(public_endpoints) = public_endpoints.as_ref() { - let endpoint_count: usize = public_endpoints.values().map(HashMap::len).sum(); - output::label_value("Public endpoints", &endpoint_count.to_string()); - } - eprintln!(); - } - - let stack_settings = load_stack_settings(&args, platform, deploy_config.as_ref())?; - - // Create authenticated manager client - let client = create_manager_client(&token, &manager_url)?; - - // Initialize with manager - let init = initialize_deployment( - &client, - &token, - platform, - base_platform, - &name, - &stack_settings, - stack_input_values, - ) - .await?; - let deployment_id = init.deployment_id; - if print_progress { - output::success("Connected to manager"); - } - - // Use deployment-scoped token if the manager returned one, otherwise keep the original. - let effective_token = init.deployment_token.unwrap_or_else(|| token.clone()); - let client = create_manager_client(&effective_token, &manager_url)?; - let local_tracking = local_tracking_metadata(&args, platform); - - // Track the deployment locally - let mut tracker = DeploymentTracker::new()?; - tracker.track( - name.clone(), - deployment_id.clone(), - effective_token.clone(), - manager_url.clone(), - platform_str.clone(), - local_tracking, - )?; - - // Check if the deployment is already active — nothing to do. - let current_deployment = client - .get_deployment() - .id(&deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - if let Some(public_endpoints) = public_endpoints.as_ref() { - let release_id = current_deployment - .desired_release_id - .as_deref() - .or(current_deployment.current_release_id.as_deref()) - .ok_or_else(|| { - AlienError::new(ErrorData::ValidationError { - field: "public-endpoint".to_string(), - message: - "Cannot validate public endpoints because the deployment has no release" - .to_string(), - }) - })?; - let stack = fetch_release_stack_by_id(&client, release_id, platform).await?; - validate_public_endpoint_names(public_endpoints, &stack)?; - } - - if current_deployment.status == "running" - && public_endpoints.is_none() - && platform != Platform::Machines - { - eprintln!(); - output::success(&format!("Deployment '{}' is already active.", name)); - return Ok(()); - } else if current_deployment.status == "running" { - output::info( - "Deployment is already active; updating local operator public endpoint config.", - ); - } - - match init.deployment_model { - DeploymentModel::Pull => { - run_pull_model( - &client, - &args, - &manager_url, - &effective_token, - &deployment_id, - &name, - &stack_settings, - platform, - embedded_config, - public_endpoints.as_ref(), - ) - .await?; - } - DeploymentModel::Push => match platform { - Platform::Machines => { - push_initial_setup( - &client, - &deployment_id, - platform, - base_platform, - ClientConfig::Machines, - install_management_config, - &manager_url, - &effective_token, - None, - None, - ) - .await?; - - let join_token = create_machines_join_token( - &resolved.base_url, - &effective_token, - &deployment_id, - ) - .await?; - let cli_name = embedded_config - .and_then(|config| config.name.as_deref()) - .unwrap_or("alien-deploy"); - let install_script_url = - embedded_config.and_then(|config| config.install_script_url.as_deref()); - println!( - "{}", - machines_join_command(cli_name, install_script_url, &join_token) - ); - return Ok(()); - } - Platform::Test => { - output::info("Test platform — no deployment action needed."); - } - Platform::Aws - | Platform::Gcp - | Platform::Azure - | Platform::Kubernetes - | Platform::Local => { - // Build progress callback - let progress = - std::sync::Arc::new(std::sync::Mutex::new(output::DeployProgress::new())); - let progress_clone = progress.clone(); - let on_progress: alien_deployment::runner::ProgressCallback = - Box::new(move |step_progress| { - let mut p = progress_clone.lock().unwrap_or_else(|e| e.into_inner()); - p.update(step_progress); - }); - - run_push_model( - &client, - &deployment_id, - platform, - base_platform, - &manager_url, - &effective_token, - install_management_config, - &args.network, - Some(on_progress), - ) - .await?; - - // Clear the live progress display - let mut p = progress.lock().unwrap_or_else(|e| e.into_inner()); - p.finish(); - } - }, - } - - eprintln!(); - output::success(&format!("Deployment '{}' is active.", name)); - - Ok(()) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MachinesJoinTokenResponse { - join_token: String, - control_plane_url: Option, - cluster_id: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct WrappedMachinesJoinToken<'a> { - join_token: &'a str, - control_plane_url: &'a str, - cluster_id: &'a str, -} - -async fn create_machines_join_token( - base_url: &str, - token: &str, - deployment_id: &str, -) -> Result { - let http_client = create_manager_http_client(token)?; - let url = format!( - "{}/v1/machines/deployments/{}/join-tokens/rotate", - base_url.trim_end_matches('/'), - urlencoding::encode(deployment_id), - ); - - let response = http_client - .post(&url) - .send() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to create Machines join token from platform API".to_string(), - })?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Failed to create Machines join token (HTTP {status}): {body}"), - })); - } - - let response: MachinesJoinTokenResponse = - response - .json() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to parse Machines join token response".to_string(), - })?; - - normalize_machines_join_token_response(response) -} - -fn normalize_machines_join_token_response(response: MachinesJoinTokenResponse) -> Result { - let join_token = response.join_token.trim(); - if join_token.is_empty() { - return Err(AlienError::new(ErrorData::ConfigurationError { - message: "Platform API returned an empty Machines join token".to_string(), - })); - } - if join_token.starts_with("aj1_") { - return Ok(join_token.to_string()); - } - - let control_plane_url = response - .control_plane_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let cluster_id = response - .cluster_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - - match (control_plane_url, cluster_id) { - (Some(control_plane_url), Some(cluster_id)) => { - validate_machines_control_plane_url(control_plane_url)?; - let payload = WrappedMachinesJoinToken { - join_token, - control_plane_url, - cluster_id, - }; - let json = serde_json::to_vec(&payload).into_alien_error().context( - ErrorData::ConfigurationError { - message: "Failed to encode Machines join token context".to_string(), - }, - )?; - Ok(format!("aj1_{}", URL_SAFE_NO_PAD.encode(json))) - } - _ => Err(AlienError::new(ErrorData::ConfigurationError { - message: - "Platform API returned a raw Machines join token without control plane context" - .to_string(), - })), - } -} - -fn validate_machines_control_plane_url(value: &str) -> Result<()> { - let url = reqwest::Url::parse(value).map_err(|e| { - AlienError::new(ErrorData::ConfigurationError { - message: format!("Platform API returned an invalid Machines control plane URL: {e}"), - }) - })?; - if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { - return Err(AlienError::new(ErrorData::ConfigurationError { - message: "Platform API returned an invalid Machines control plane URL".to_string(), - })); - } - Ok(()) -} - -fn machines_join_command( - cli_name: &str, - install_script_url: Option<&str>, - join_token: &str, -) -> String { - if let Some(install_script_url) = install_script_url { - return format!( - "curl -fsSL {} | sudo bash -s -- join --token {}", - shell_single_quote(install_script_url), - shell_single_quote(join_token) - ); - } - - format!( - "sudo {cli_name} join --token {}", - shell_single_quote(join_token) - ) -} - -fn should_print_deploy_progress(platform: Platform) -> bool { - platform != Platform::Machines -} - -fn should_print_push_setup_neutral_completion(platform: Platform) -> bool { - platform != Platform::Machines -} - -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - -/// Resolved deployment info before manager connection. -struct ResolvedInfo { - token: String, - /// Manager URL (from override, tracker, or to be discovered via platform API). - manager_url: Option, - /// Platform API base URL used when manager URL must be discovered. - base_url: String, - platform: String, - base_platform: Option, - name: String, -} - -fn requires_install_context(platform: Platform) -> bool { - matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) -} - -fn release_stack_value_for_platform( - stack: alien_manager_api::types::StackByPlatform, - platform: Platform, -) -> Option { - match platform { - Platform::Aws => stack.aws, - Platform::Gcp => stack.gcp, - Platform::Azure => stack.azure, - Platform::Kubernetes => stack.kubernetes, - Platform::Machines => stack.machines, - Platform::Local => stack.local, - Platform::Test => stack.test, - } -} - -async fn fetch_release_stack_by_id( - client: &ServerClient, - release_id: &str, - platform: Platform, -) -> Result { - let release = client - .get_release() - .id(release_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to fetch release '{release_id}' from manager"), - })? - .into_inner(); - let stack_value = - release_stack_value_for_platform(release.stack, platform).ok_or_else(|| { - AlienError::new(ErrorData::ConfigurationError { - message: format!( - "Release '{}' has no stack for platform {}", - release_id, - platform.as_str() - ), - }) - })?; - - serde_json::from_value(stack_value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to parse release stack from release '{release_id}'"), - }) -} - -fn validate_public_endpoint_names( - public_endpoints: &PublicEndpointUrls, - stack: &Stack, -) -> Result<()> { - let valid_endpoints = public_endpoint_names(stack); - for (resource_id, endpoints) in public_endpoints { - for endpoint_name in endpoints.keys() { - let key = format!("{resource_id}.{endpoint_name}"); - if valid_endpoints.contains(&key) { - continue; - } - - let available = if valid_endpoints.is_empty() { - "none".to_string() - } else { - valid_endpoints - .iter() - .cloned() - .collect::>() - .join(", ") - }; - return Err(AlienError::new(ErrorData::ValidationError { - field: "public-endpoint".to_string(), - message: format!( - "Endpoint '{key}' is not declared by the stack. Available public endpoints: {available}" - ), - })); - } - } - Ok(()) -} - -fn public_endpoint_names(stack: &Stack) -> BTreeSet { - stack - .resources() - .flat_map(|(resource_id, entry)| { - if let Some(daemon) = entry.config.downcast_ref::() { - return daemon - .public_endpoints - .iter() - .map(|endpoint| format!("{resource_id}.{}", endpoint.name)) - .collect::>(); - } - if let Some(container) = entry.config.downcast_ref::() { - return container - .public_endpoints - .iter() - .map(|endpoint| format!("{resource_id}.{}", endpoint.name)) - .collect::>(); - } - if let Some(worker) = entry.config.downcast_ref::() { - return worker - .public_endpoints - .iter() - .map(|endpoint| format!("{resource_id}.{}", endpoint.name)) - .collect::>(); - } - Vec::new() - }) - .collect() -} - -fn parse_base_platform( - platform: Platform, - base_platform: Option<&str>, -) -> Result> { - let Some(base_platform) = base_platform else { - return Ok(None); - }; - - let parsed = Platform::from_str(base_platform).map_err(|e| { - AlienError::new(ErrorData::ValidationError { - field: "base-platform".to_string(), - message: e, - }) - })?; - - if platform != Platform::Kubernetes { - return Err(AlienError::new(ErrorData::ValidationError { - field: "base-platform".to_string(), - message: "--base-platform is only supported with --platform kubernetes".to_string(), - })); - } - - match parsed { - Platform::Aws | Platform::Gcp | Platform::Azure => Ok(Some(parsed)), - Platform::Kubernetes | Platform::Machines | Platform::Local | Platform::Test => { - Err(AlienError::new(ErrorData::ValidationError { - field: "base-platform".to_string(), - message: "--base-platform must be one of: aws, gcp, azure".to_string(), - })) - } - } -} - -fn load_deploy_config(args: &UpArgs) -> Result> { - let Some(path) = &args.config else { - return Ok(None); - }; - - let text = std::fs::read_to_string(path).into_alien_error().context( - ErrorData::ConfigurationError { - message: format!("Failed to read deployment config {}", path.display()), - }, - )?; - let config = - toml::from_str(&text) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to parse deployment config {}", path.display()), - })?; - Ok(Some(config)) -} - -fn load_public_endpoints( - args: &UpArgs, - platform: Platform, - deploy_config: Option<&DeployConfigFile>, -) -> Result> { - let mut public_endpoints = deploy_config - .and_then(|config| config.public_endpoints.clone()) - .unwrap_or_default(); - if !public_endpoints.is_empty() { - validate_public_endpoint_urls(&public_endpoints).context(ErrorData::ValidationError { - field: "publicEndpoints".to_string(), - message: "Invalid public endpoint URL in deployment config".to_string(), - })?; - } - - let mut cli_endpoints = BTreeSet::new(); - for value in &args.public_endpoints { - let (resource_id, endpoint_name, public_url) = parse_public_endpoint_assignment(value) - .context(ErrorData::ValidationError { - field: "public-endpoint".to_string(), - message: "Expected --public-endpoint .=" - .to_string(), - })?; - let key = format!("{resource_id}.{endpoint_name}"); - if !cli_endpoints.insert(key.clone()) { - return Err(AlienError::new(ErrorData::ValidationError { - field: "public-endpoint".to_string(), - message: format!("Duplicate public endpoint URL for '{key}'"), - })); - } - public_endpoints - .entry(resource_id) - .or_default() - .insert(endpoint_name, public_url); - } - - if public_endpoints.is_empty() { - return Ok(None); - } - - match platform { - Platform::Local | Platform::Machines => Ok(Some(public_endpoints)), - Platform::Aws | Platform::Gcp | Platform::Azure | Platform::Kubernetes | Platform::Test => { - Err(AlienError::new(ErrorData::ValidationError { - field: "public-endpoint".to_string(), - message: format!( - "--public-endpoint is currently supported only for local or machines deployments, got '{}'", - platform.as_str() - ), - })) - } - } -} - -fn resolve_deployment_info( - args: &UpArgs, - embedded_config: Option<&DeployCliConfig>, - deploy_config: Option<&DeployConfigFile>, -) -> Result { - // If name is provided, try to load from tracker - let requested_name = args - .name - .as_ref() - .or_else(|| deploy_config.and_then(|c| c.name.as_ref())); - if let Some(name) = requested_name { - let tracker = DeploymentTracker::new()?; - if let Some(tracked) = tracker.get(name) { - let token = - resolve_token(args, embedded_config).unwrap_or_else(|_| tracked.token.clone()); - let manager_url = args - .manager_url - .clone() - .or(Some(tracked.manager_url.clone())); - let platform = args - .platform - .clone() - .or_else(|| deploy_config.and_then(|c| c.platform.clone())) - .unwrap_or_else(|| tracked.platform.clone()); - let base_platform = args - .base_platform - .clone() - .or_else(|| deploy_config.and_then(|c| c.base_platform.clone())); - return Ok(ResolvedInfo { - token, - manager_url, - base_url: resolve_base_url(args, embedded_config), - platform, - base_platform, - name: name.clone(), - }); - } - } - - // CLI args override embedded config, which overrides nothing (required) - let token = resolve_token(args, embedded_config)?; - - // Manager URL: explicit override only. If not set, will be discovered via platform API. - let manager_url = args.manager_url.clone(); - - let platform = args - .platform - .clone() - .or_else(|| deploy_config.and_then(|c| c.platform.clone())) - .or_else(|| embedded_config.and_then(|c| c.default_platform.clone())) - .ok_or_else(|| { - AlienError::new(ErrorData::ValidationError { - field: "platform".to_string(), - message: - "--platform is required for new deployments. Choose from: aws, gcp, azure, kubernetes, machines, local." - .to_string(), - }) - })?; - let base_platform = args - .base_platform - .clone() - .or_else(|| deploy_config.and_then(|c| c.base_platform.clone())); - - let name = match args.name.clone() { - Some(n) => n, - None => match deploy_config.and_then(|c| c.name.clone()) { - Some(n) => n, - None if platform == "local" => hostname::get() - .ok() - .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| "default".to_string()), - None => { - return Err(AlienError::new(ErrorData::ValidationError { - field: "name".to_string(), - message: "--name or config field `name` is required for non-local deployments." - .to_string(), - })); - } - }, - }; - - Ok(ResolvedInfo { - token, - manager_url, - base_url: resolve_base_url(args, embedded_config), - platform, - base_platform, - name, - }) -} - -pub(crate) fn resolve_optional_token( - token: Option, - token_file: Option<&PathBuf>, - embedded_config: Option<&DeployCliConfig>, -) -> Result> { - Ok(token - .map(Ok) - .or_else(|| token_file.map(|path| read_token_file(path))) - .transpose()? - .or_else(|| { - embedded_config - .and_then(|c| c.token_env_var.as_ref()) - .and_then(|env_var| std::env::var(env_var).ok()) - }) - .or_else(|| embedded_config.and_then(|c| c.token.clone()))) -} - -fn resolve_token(args: &UpArgs, embedded_config: Option<&DeployCliConfig>) -> Result { - resolve_optional_token(args.token.clone(), args.token_file.as_ref(), embedded_config)? - .ok_or_else(|| { - let branded_hint = embedded_config - .and_then(|c| c.token_env_var.as_deref()) - .map(|env_var| format!(" or set ${env_var}")) - .unwrap_or_default(); - AlienError::new(ErrorData::ValidationError { - field: "token".to_string(), - message: format!( - "--token is required for new deployments{branded_hint}. Use the deployment token from the deploy page." - ), - }) - }) -} - -pub(crate) fn read_token_file(path: &Path) -> Result { - let token = std::fs::read_to_string(path).into_alien_error().context( - ErrorData::ConfigurationError { - message: format!("Failed to read token file {}", path.display()), - }, - )?; - let token = token.trim().to_string(); - if token.is_empty() { - return Err(AlienError::new(ErrorData::ValidationError { - field: "token-file".to_string(), - message: format!("Token file {} is empty", path.display()), - })); - } - Ok(token) -} - -pub(crate) fn resolve_base_url_option( - base_url: Option<&String>, - embedded_config: Option<&DeployCliConfig>, -) -> String { - base_url - .cloned() - .or_else(|| embedded_config.and_then(|c| c.api_base_url.clone())) - .unwrap_or_else(|| "https://api.alien.dev".to_string()) -} - -fn resolve_base_url(args: &UpArgs, embedded_config: Option<&DeployCliConfig>) -> String { - resolve_base_url_option(args.base_url.as_ref(), embedded_config) -} - -pub(crate) fn resolve_platform_option( - platform: Option<&String>, - embedded_config: Option<&DeployCliConfig>, - command: &str, -) -> Result { - platform - .cloned() - .or_else(|| embedded_config.and_then(|c| c.default_platform.clone())) - .ok_or_else(|| { - AlienError::new(ErrorData::ValidationError { - field: "platform".to_string(), - message: format!( - "--platform is required for {command} when --manager-url is not set and the binary has no embedded default platform." - ), - }) - }) -} - -fn load_stack_settings( - args: &UpArgs, - platform: Platform, - deploy_config: Option<&DeployConfigFile>, -) -> Result { - let mut settings = StackSettings::default(); - - // The manager owns the deployment model for cloud platforms (push) and - // Kubernetes (pull), so this CLI omits it there. Local is the exception: - // the manager defaults Local to push for its own embedded dev loop, while - // `deploy --platform local` is the remote-operator install flow — it must - // request pull explicitly or the manager creates a push deployment whose - // initial setup has no local platform services. - if platform == Platform::Local { - settings.deployment_model = DeploymentModel::Pull; - } - - if let Some(config) = deploy_config { - if let Some(network) = config.network.clone() { - settings.network = Some(network.into()); - } - if let Some(updates) = config.updates { - settings.updates = updates; - } - if let Some(telemetry) = config.telemetry { - settings.telemetry = telemetry; - } - if let Some(compute) = config.compute.clone() { - settings.compute = Some(compute); - } - } - - if args.network.network_mode != NetworkMode::Auto { - let network_override = network::parse_network_settings(&args.network, platform.as_str()) - .map_err(|e| { - AlienError::new(ErrorData::ValidationError { - field: "network".to_string(), - message: e, - }) - })?; - if let Some(network) = network_override { - settings.network = Some(network); - } - } - - Ok(settings) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeploymentInfoResponse { - setup_config: Option, - readiness: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeploymentInfoSetupConfig { - inputs: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeploymentReadiness { - status: String, - checks: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeploymentReadinessCheck { - code: String, - status: String, - message: String, -} - -async fn fetch_deployment_info( - base_url: &str, - token: &str, - platform: Platform, -) -> Result { - let http_client = { - use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; - - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {}", token)) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Invalid token format".to_string(), - })?, - ); - headers.insert(USER_AGENT, HeaderValue::from_static("alien-deploy-cli")); - - reqwest::Client::builder() - .default_headers(headers) - .build() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to build HTTP client".to_string(), - })? - }; - - let mut url = reqwest::Url::parse(&format!( - "{}/v1/deployment-info", - base_url.trim_end_matches('/') - )) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Invalid platform API base URL".to_string(), - })?; - url.query_pairs_mut() - .append_pair("platform", platform.as_str()); - let response = http_client - .get(url) - .send() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to fetch deployment info from platform API".to_string(), - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Failed to fetch deployment info (HTTP {status}): {body}"), - })); - } - - response - .json() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to parse deployment info response".to_string(), - }) -} - -fn deployer_inputs_from_info( - info: &DeploymentInfoResponse, - platform: Platform, -) -> Vec { - info.setup_config - .as_ref() - .and_then(|setup_config| setup_config.inputs.clone()) - .unwrap_or_default() - .into_iter() - .filter(|input| stack_input_matches_context(input, platform)) - .collect() -} - -fn validate_deployment_readiness(info: &DeploymentInfoResponse, platform: Platform) -> Result<()> { - let Some(readiness) = &info.readiness else { - return Ok(()); - }; - if readiness.status == "notReady" { - let failures = readiness - .checks - .iter() - .filter(|check| check.status == "failed") - .map(|check| format!("{}: {}", check.code, check.message)) - .collect::>() - .join("; "); - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!( - "{} deployments are not ready: {failures}", - platform.as_str() - ), - })); - } - for check in readiness - .checks - .iter() - .filter(|check| check.status == "unknown") - { - output::warn(&format!( - "Readiness unknown ({}): {}", - check.code, check.message - )); - } - Ok(()) -} - -fn stack_input_matches_context(input: &StackInputDefinition, platform: Platform) -> bool { - if !input.provided_by.contains(&StackInputProvider::Deployer) { - return false; - } - if let Some(platforms) = &input.platforms { - if !platforms.contains(&platform) { - return false; - } - } - true -} - -fn collect_deployer_input_values( - inputs: &[StackInputDefinition], - input_values: &[String], - secret_input_values: &[String], - deploy_config: Option<&DeployConfigFile>, -) -> Result> { - let mut raw_values = HashMap::::new(); - - if let Some(config_inputs) = deploy_config.and_then(|config| config.inputs.as_ref()) { - for (id, value) in config_inputs { - raw_values.insert(id.clone(), value.clone()); - } - } - if let Some(config_inputs) = deploy_config.and_then(|config| config.secret_inputs.as_ref()) { - for (id, value) in config_inputs { - raw_values.insert(id.clone(), value.clone()); - } - } - for input in input_values { - let (id, value) = parse_stack_input_arg(input, "--input")?; - raw_values.insert(id, value); - } - for input in secret_input_values { - let (id, value) = parse_stack_input_arg(input, "--secret-input")?; - raw_values.insert(id, value); - } - - if inputs.is_empty() { - return Ok(raw_values - .into_iter() - .map(|(id, value)| (id, serde_json::Value::String(value))) - .collect()); - } - - for id in raw_values.keys() { - if !inputs.iter().any(|input| input.id == *id) { - return Err(AlienError::new(ErrorData::ValidationError { - field: "input".to_string(), - message: format!("Unknown or unavailable deployer stack input '{id}'."), - })); - } - } - - for input in inputs.iter().filter(|input| input.required) { - if raw_values.contains_key(&input.id) { - continue; - } - if !can_prompt() { - return Err(AlienError::new(ErrorData::ValidationError { - field: "input".to_string(), - message: format!( - "Missing deployer input: {}. Pass {} {}=... or add [{}] to deployment.toml.", - input.label, - if matches!(input.kind, StackInputKind::Secret) { - "--secret-input" - } else { - "--input" - }, - input.id, - if matches!(input.kind, StackInputKind::Secret) { - "secretInputs" - } else { - "inputs" - } - ), - })); - } - let value = prompt_input_value(input)?; - raw_values.insert(input.id.clone(), value); - } - - let mut values = HashMap::new(); - for input in inputs { - let Some(raw_value) = raw_values.get(&input.id) else { - continue; - }; - values.insert(input.id.clone(), parse_stack_input_value(input, raw_value)?); - } - Ok(values) -} - -fn parse_stack_input_arg(input: &str, flag: &str) -> Result<(String, String)> { - let Some((id, value)) = input.split_once('=') else { - return Err(AlienError::new(ErrorData::ValidationError { - field: flag.trim_start_matches("--").to_string(), - message: format!("Invalid {flag} format: '{input}'. Use id=value"), - })); - }; - if id.trim().is_empty() { - return Err(AlienError::new(ErrorData::ValidationError { - field: flag.trim_start_matches("--").to_string(), - message: format!("Invalid {flag} format: input id is required"), - })); - } - Ok((id.trim().to_string(), value.to_string())) -} - -fn parse_stack_input_value(input: &StackInputDefinition, value: &str) -> Result { - match input.kind { - StackInputKind::String | StackInputKind::Secret | StackInputKind::Enum => { - validate_string_stack_input(input, value)?; - Ok(serde_json::Value::String(value.to_string())) - } - StackInputKind::Number => { - let number = value.parse::().map_err(|_| { - AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} must be a number.", input.label), - }) - })?; - serde_json::Number::from_f64(number) - .map(serde_json::Value::Number) - .ok_or_else(|| { - AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} must be a finite number.", input.label), - }) - }) - } - StackInputKind::Integer => { - let number = value.parse::().map_err(|_| { - AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} must be a whole number.", input.label), - }) - })?; - Ok(serde_json::Value::Number(number.into())) - } - StackInputKind::Boolean => { - let parsed = value.parse::().map_err(|_| { - AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} must be true or false.", input.label), - }) - })?; - Ok(serde_json::Value::Bool(parsed)) - } - StackInputKind::StringList => { - let values = value - .split(',') - .map(str::trim) - .filter(|item| !item.is_empty()) - .map(|item| serde_json::Value::String(item.to_string())) - .collect::>(); - Ok(serde_json::Value::Array(values)) - } - } -} - -fn validate_string_stack_input(input: &StackInputDefinition, value: &str) -> Result<()> { - if let Some(validation) = &input.validation { - if let Some(values) = &validation.values { - if !values.iter().any(|candidate| candidate == value) { - return Err(AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} must be one of: {}.", input.label, values.join(", ")), - })); - } - } - if let Some(min) = validation.min_length { - if value.len() < min as usize { - return Err(AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} is too short.", input.label), - })); - } - } - if let Some(max) = validation.max_length { - if value.len() > max as usize { - return Err(AlienError::new(ErrorData::ValidationError { - field: input.id.clone(), - message: format!("{} is too long.", input.label), - })); - } - } - } - Ok(()) -} - -fn can_prompt() -> bool { - std::io::stdin().is_terminal() && std::io::stderr().is_terminal() -} - -fn prompt_input_value(input: &StackInputDefinition) -> Result { - let mut stderr = std::io::stderr(); - let prompt = if matches!(input.kind, StackInputKind::Secret) { - format!("{} (secret): ", input.label) - } else if let Some(placeholder) = input.placeholder.as_deref() { - format!("{} [{}]: ", input.label, placeholder) - } else { - format!("{}: ", input.label) - }; - stderr - .write_all(prompt.as_bytes()) - .and_then(|_| stderr.flush()) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to write input prompt".to_string(), - })?; - - let value = if matches!(input.kind, StackInputKind::Secret) { - read_secret_line()? - } else { - let mut value = String::new(); - std::io::stdin() - .read_line(&mut value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to read input value".to_string(), - })?; - value - }; - let value = value.trim_end_matches(['\r', '\n']).to_string(); - if value.is_empty() { - if let Some(placeholder) = input.placeholder.as_deref() { - return Ok(placeholder.to_string()); - } - } - Ok(value) -} - -#[cfg(unix)] -fn read_secret_line() -> Result { - use std::os::fd::AsRawFd; - - let stdin = std::io::stdin(); - let fd = stdin.as_raw_fd(); - let mut termios = std::mem::MaybeUninit::::uninit(); - let original = unsafe { - if libc::tcgetattr(fd, termios.as_mut_ptr()) != 0 { - return read_line_with_echo(); - } - termios.assume_init() - }; - let mut hidden = original; - hidden.c_lflag &= !libc::ECHO; - unsafe { - libc::tcsetattr(fd, libc::TCSANOW, &hidden); - } - - let result = read_line_with_echo(); - unsafe { - libc::tcsetattr(fd, libc::TCSANOW, &original); - } - eprintln!(); - result -} - -#[cfg(not(unix))] -fn read_secret_line() -> Result { - read_line_with_echo() -} - -fn read_line_with_echo() -> Result { - let mut value = String::new(); - std::io::stdin() - .read_line(&mut value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to read input value".to_string(), - })?; - Ok(value) -} - -struct ManagerInstallContext { - manager_url: String, - management_config: Option, -} - -/// Discover the manager URL and platform-managed install context via the platform API. -/// -/// Calls GET /v1/resolve?platform=X to resolve the manager. -/// The token's scope (DG, project, etc.) provides the project context -/// to the server — no need to call whoami first. -async fn discover_manager_install_context( - base_url: &str, - token: &str, - platform: &str, -) -> Result { - let http_client = { - use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; - - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {}", token)) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Invalid token format".to_string(), - })?, - ); - headers.insert(USER_AGENT, HeaderValue::from_static("alien-deploy-cli")); - - reqwest::Client::builder() - .default_headers(headers) - .build() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to build HTTP client".to_string(), - })? - }; - - let url = format!( - "{}/v1/resolve?platform={}", - base_url, - urlencoding::encode(platform), - ); - - let resp = http_client - .get(&url) - .send() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to call /v1/resolve on platform API".to_string(), - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!( - "Failed to resolve manager via platform API (HTTP {}): {}", - status, body - ), - })); - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct ResolveResponse { - manager_url: String, - install_context: Option, - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct ResolveInstallContext { - management_config: ManagementConfig, - } - - let resolved: ResolveResponse = - resp.json() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to parse /v1/resolve response".to_string(), - })?; - - Ok(ManagerInstallContext { - manager_url: resolved.manager_url, - management_config: resolved - .install_context - .map(|context| context.management_config), - }) -} - -pub(crate) async fn resolve_manager_url_option( - manager_url: Option, - base_url: &str, - token: &str, - platform: &str, -) -> Result { - if let Some(manager_url) = manager_url { - return Ok(manager_url); - } - - discover_manager_install_context(base_url, token, platform) - .await - .map(|context| context.manager_url) -} - -pub fn create_manager_client(token: &str, manager_url: &str) -> Result { - let http_client = create_manager_http_client(token)?; - Ok(ServerClient::new_with_client(manager_url, http_client)) -} - -pub(crate) fn create_manager_http_client(token: &str) -> Result { - use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; - - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {}", token)) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Invalid token format".to_string(), - })?, - ); - headers.insert(USER_AGENT, HeaderValue::from_static("alien-deploy-cli")); - - reqwest::Client::builder() - .default_headers(headers) - .build() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to create HTTP client".to_string(), - }) -} - -fn parse_deployment_status(raw_status: &str) -> Result { - match raw_status.to_ascii_lowercase().as_str() { - "pending" => Ok(DeploymentStatus::Pending), - "preflights-failed" => Ok(DeploymentStatus::PreflightsFailed), - "initial-setup" => Ok(DeploymentStatus::InitialSetup), - "initial-setup-failed" => Ok(DeploymentStatus::InitialSetupFailed), - "provisioning" => Ok(DeploymentStatus::Provisioning), - "waiting-for-machines" => Ok(DeploymentStatus::WaitingForMachines), - "provisioning-failed" => Ok(DeploymentStatus::ProvisioningFailed), - "running" => Ok(DeploymentStatus::Running), - "refresh-failed" => Ok(DeploymentStatus::RefreshFailed), - "update-pending" => Ok(DeploymentStatus::UpdatePending), - "updating" => Ok(DeploymentStatus::Updating), - "update-failed" => Ok(DeploymentStatus::UpdateFailed), - "delete-pending" => Ok(DeploymentStatus::DeletePending), - "deleting" => Ok(DeploymentStatus::Deleting), - "delete-failed" => Ok(DeploymentStatus::DeleteFailed), - "teardown-required" => Ok(DeploymentStatus::TeardownRequired), - "teardown-failed" => Ok(DeploymentStatus::TeardownFailed), - "deleted" => Ok(DeploymentStatus::Deleted), - "error" => Ok(DeploymentStatus::Error), - _ => Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Unknown deployment status returned by manager: {raw_status}"), - })), - } -} - -fn deployment_status_str(status: DeploymentStatus) -> &'static str { - match status { - DeploymentStatus::Pending => "pending", - DeploymentStatus::PreflightsFailed => "preflights-failed", - DeploymentStatus::InitialSetup => "initial-setup", - DeploymentStatus::InitialSetupFailed => "initial-setup-failed", - DeploymentStatus::Provisioning => "provisioning", - DeploymentStatus::WaitingForMachines => "waiting-for-machines", - DeploymentStatus::ProvisioningFailed => "provisioning-failed", - DeploymentStatus::Running => "running", - DeploymentStatus::RefreshFailed => "refresh-failed", - DeploymentStatus::UpdatePending => "update-pending", - DeploymentStatus::Updating => "updating", - DeploymentStatus::UpdateFailed => "update-failed", - DeploymentStatus::DeletePending => "delete-pending", - DeploymentStatus::Deleting => "deleting", - DeploymentStatus::DeleteFailed => "delete-failed", - DeploymentStatus::TeardownRequired => "teardown-required", - DeploymentStatus::TeardownFailed => "teardown-failed", - DeploymentStatus::Deleted => "deleted", - DeploymentStatus::Error => "error", - } -} - -/// Result of initializing with the manager. -struct InitResult { - deployment_id: String, - deployment_model: DeploymentModel, - /// Deployment-scoped token returned by the manager (when using a deployment group token). - /// If present, this should replace the original token for subsequent requests. - deployment_token: Option, -} - -async fn initialize_deployment( - client: &ServerClient, - _token: &str, - platform: Platform, - base_platform: Option, - name: &str, - stack_settings: &StackSettings, - input_values: HashMap, -) -> Result { - let body = alien_manager_api::types::InitializeRequest { - name: Some(name.to_string()), - platform: Some(sdk_platform(platform)), - base_platform: base_platform.map(sdk_platform), - initial_desired_release: alien_manager_api::types::InitialDesiredRelease::Active, - stack_settings: Some(sdk_stack_settings(stack_settings)?), - input_values: input_values.into_iter().collect(), - scope: None, - permission: None, - setup_method: None, - }; - - let response = match client.initialize().body(body).send().await { - Ok(response) => response, - Err(error) => { - // Read the error body so server-side rejections surface their own - // message; the manager-URL hint only applies when the manager was - // unreachable. - let error = alien_manager_api::convert_sdk_error_reading_body(error).await; - let context = if error.code == "COMMUNICATION_ERROR" { - ErrorData::ConfigurationError { - message: "Failed to initialize with manager. Is the manager running? Check that --manager-url is correct.".to_string(), - } - } else { - ErrorData::DeploymentFailed { - operation: "initialize".to_string(), - } - }; - return Err(error).context(context); - } - }; - - let init = response.into_inner(); - let deployment_model = manager_deployment_model(init.deployment_model)?; - Ok(InitResult { - deployment_id: init.deployment_id, - deployment_model, - deployment_token: init.token, - }) -} - -fn manager_deployment_model(deployment_model: T) -> Result { - let value = serde_json::to_value(deployment_model) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to serialize manager deployment model".to_string(), - })?; - serde_json::from_value(value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize manager deployment model".to_string(), - }) -} - -fn sdk_platform(platform: Platform) -> alien_manager_api::types::Platform { - match platform { - Platform::Aws => alien_manager_api::types::Platform::Aws, - Platform::Gcp => alien_manager_api::types::Platform::Gcp, - Platform::Azure => alien_manager_api::types::Platform::Azure, - Platform::Kubernetes => alien_manager_api::types::Platform::Kubernetes, - Platform::Machines => alien_manager_api::types::Platform::Machines, - Platform::Local => alien_manager_api::types::Platform::Local, - Platform::Test => alien_manager_api::types::Platform::Test, - } -} - -fn sdk_stack_settings( - stack_settings: &StackSettings, -) -> Result { - let value = serde_json::to_value(stack_settings) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to serialize stack settings".to_string(), - })?; - serde_json::from_value(value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to convert stack settings for manager API".to_string(), - }) -} - -async fn run_pull_model( - client: &ServerClient, - args: &UpArgs, - manager_url: &str, - token: &str, - deployment_id: &str, - deployment_name: &str, - stack_settings: &StackSettings, - platform: Platform, - embedded_config: Option<&DeployCliConfig>, - public_endpoints: Option<&PublicEndpointUrls>, -) -> Result<()> { - match platform { - Platform::Kubernetes => { - run_kubernetes_pull_model( - client, - args, - manager_url, - token, - deployment_id, - deployment_name, - stack_settings, - ) - .await - } - _ => { - let data_dir = local_operator_data_dir(args); - run_local_pull_model( - args, - manager_url, - token, - deployment_id, - deployment_name, - &platform.to_string(), - embedded_config, - public_endpoints, - data_dir.as_deref(), - ) - .await - } - } -} - -fn default_foreground_data_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join(".alien") - .join("operator-data") -} - -fn local_operator_data_dir(args: &UpArgs) -> Option { - args.data_dir.clone().or_else(|| { - if args.foreground { - Some(default_foreground_data_dir().to_string_lossy().to_string()) - } else { - Some(super::operator::default_service_data_dir()) - } - }) -} - -fn local_tracking_metadata(args: &UpArgs, platform: Platform) -> Option { - if platform != Platform::Local { - return None; - } - - local_operator_data_dir(args).map(|data_dir| TrackedLocalDeployment { - data_dir, - service_managed: !args.foreground, - }) -} - -async fn run_local_pull_model( - args: &UpArgs, - manager_url: &str, - token: &str, - deployment_id: &str, - deployment_name: &str, - platform: &str, - embedded_config: Option<&DeployCliConfig>, - public_endpoints: Option<&PublicEndpointUrls>, - data_dir: Option<&str>, -) -> Result<()> { - let encryption_key = args.encryption_key.clone().unwrap_or_else(|| { - use super::operator::generate_encryption_key_public; - generate_encryption_key_public() - }); - - // Find or download the alien-operator binary - let binary_path = find_or_download_operator_binary(embedded_config).await?; - - output::info(&format!("Operator binary: {}", binary_path.display())); - - if args.foreground { - return run_operator_foreground( - &binary_path, - manager_url, - token, - deployment_id, - deployment_name, - platform, - &encryption_key, - data_dir, - public_endpoints, - args.enable_local_debug, - args.local_debug_shell_command.as_deref(), - ) - .await; - } - - output::info("Installing alien-operator as a system service..."); - - // Delegate to the operator install logic - let install_args = super::operator::InstallArgs { - binary: Some(binary_path), - sync_url: manager_url.to_string(), - sync_token: token.to_string(), - deployment_id: Some(deployment_id.to_string()), - operator_name: Some(deployment_name.to_string()), - platform: platform.to_string(), - data_dir: data_dir.map(ToOwned::to_owned), - encryption_key: args.encryption_key.clone(), - public_endpoints: public_endpoints.cloned(), - enable_local_debug: args.enable_local_debug, - local_debug_shell_command: args.local_debug_shell_command.clone(), - }; - - super::operator::install_service(install_args)?; - - output::success("alien-operator installed and running as a system service."); - output::info("The operator will sync with the manager and deploy updates automatically."); - output::info("Use 'alien-deploy operator status' to check the service."); - - Ok(()) -} - -/// Run the operator as a foreground child process (for testing). -async fn run_operator_foreground( - binary_path: &std::path::Path, - manager_url: &str, - token: &str, - deployment_id: &str, - operator_name: &str, - platform: &str, - encryption_key: &str, - data_dir_override: Option<&str>, - public_endpoints: Option<&PublicEndpointUrls>, - enable_local_debug: bool, - local_debug_shell_command: Option<&str>, -) -> Result<()> { - use std::io::Write; - - output::info("Running operator in foreground (Ctrl+C to stop)..."); - - let data_dir = if let Some(dir) = data_dir_override { - std::path::PathBuf::from(dir) - } else { - default_foreground_data_dir() - }; - - // The operator rejects `--sync-token`/`--encryption-key` because argv is - // visible in `ps` / `/proc//cmdline`. Write each secret to its own - // tempfile (0o600 on Unix) and pass the path via `--*-file`. The - // `NamedTempFile`s must outlive the child process — drop deletes them. - let mut sync_token_file = tempfile::NamedTempFile::new().into_alien_error().context( - ErrorData::ConfigurationError { - message: "Failed to create temp file for sync token".to_string(), - }, - )?; - sync_token_file - .write_all(token.as_bytes()) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to write sync token".to_string(), - })?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - sync_token_file.path(), - std::fs::Permissions::from_mode(0o600), - ); - } - - let mut encryption_key_file = tempfile::NamedTempFile::new().into_alien_error().context( - ErrorData::ConfigurationError { - message: "Failed to create temp file for encryption key".to_string(), - }, - )?; - encryption_key_file - .write_all(encryption_key.as_bytes()) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to write encryption key".to_string(), - })?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - encryption_key_file.path(), - std::fs::Permissions::from_mode(0o600), - ); - } - - let mut public_endpoints_file = match public_endpoints { - Some(public_endpoints) => { - let mut file = tempfile::NamedTempFile::new().into_alien_error().context( - ErrorData::ConfigurationError { - message: "Failed to create temp file for public endpoints".to_string(), - }, - )?; - serde_json::to_writer(&mut file, public_endpoints) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to write public endpoints".to_string(), - })?; - Some(file) - } - None => None, - }; - - let mut command = tokio::process::Command::new(binary_path); - command - .arg("--platform") - .arg(platform) - .arg("--sync-url") - .arg(manager_url) - .arg("--sync-token-file") - .arg(sync_token_file.path()) - .arg("--deployment-id") - .arg(deployment_id) - .arg("--operator-name") - .arg(operator_name) - .arg("--encryption-key-file") - .arg(encryption_key_file.path()) - .arg("--data-dir") - .arg(&data_dir) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); - - if let Some(file) = public_endpoints_file.as_ref() { - command.arg("--public-endpoints-file").arg(file.path()); - } - if enable_local_debug { - command.arg("--enable-local-debug"); - } - if let Some(shell_command) = local_debug_shell_command { - command - .arg("--local-debug-shell-command") - .arg(shell_command); - } - - let status = - command - .status() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to run operator: {}", binary_path.display()), - })?; - - // Tempfiles drop here, after the child exits. - drop(sync_token_file); - drop(encryption_key_file); - drop(public_endpoints_file.take()); - - if !status.success() { - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Operator exited with status: {}", status), - })); - } - - Ok(()) -} - -async fn run_kubernetes_pull_model( - client: &ServerClient, - args: &UpArgs, - manager_url: &str, - token: &str, - deployment_id: &str, - deployment_name: &str, - stack_settings: &StackSettings, -) -> Result<()> { - output::info("Kubernetes platform detected — installing alien-operator with Helm."); - let stack = fetch_kubernetes_release_stack(client, deployment_id).await?; - let namespace = args - .namespace - .clone() - .unwrap_or_else(|| format!("alien-{}", sanitize_kubernetes_dns_label(deployment_name))); - let release = args - .helm_release - .clone() - .unwrap_or_else(|| "alien-operator".to_string()); - let operator_image = args - .operator_image - .clone() - .unwrap_or_else(|| "ghcr.io/alienplatform/alien-operator:latest".to_string()); - - let chart_dir = render_kubernetes_helm_chart(&stack, stack_settings, deployment_name)?; - let values_file = write_kubernetes_helm_values( - chart_dir.path(), - manager_url, - token, - deployment_id, - deployment_name, - stack_settings, - &operator_image, - )?; - - helm_upgrade_install( - chart_dir.path(), - &values_file, - &release, - &namespace, - args.kubeconfig.as_deref(), - args.kube_context.as_deref(), - ) - .await?; - - output::success(&format!( - "alien-operator Helm release '{}' is installed in namespace '{}'.", - release, namespace - )); - output::info(&format!("Deployment ID: {}", deployment_id)); - - Ok(()) -} - -async fn fetch_kubernetes_release_stack( - client: &ServerClient, - deployment_id: &str, -) -> Result { - let deployment = client - .get_deployment() - .id(deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - let release_id = deployment - .desired_release_id - .or(deployment.current_release_id) - .ok_or_else(|| { - AlienError::new(ErrorData::ConfigurationError { - message: "Deployment has no release to install as a Kubernetes Helm chart" - .to_string(), - }) - })?; - let release = client - .get_release() - .id(&release_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to fetch release '{release_id}' from manager"), - })? - .into_inner(); - let stack_value = release.stack.kubernetes.ok_or_else(|| { - AlienError::new(ErrorData::ConfigurationError { - message: format!("Release '{release_id}' does not contain a Kubernetes stack"), - }) - })?; - - serde_json::from_value(stack_value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to parse Kubernetes stack from release '{release_id}'"), - }) -} - -fn render_kubernetes_helm_chart( - stack: &Stack, - stack_settings: &StackSettings, - deployment_name: &str, -) -> Result { - let chart_dir = - tempfile::tempdir() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to create temporary Helm chart directory".to_string(), - })?; - let registry = alien_helm::HelmRegistry::built_in(); - let mut helm_settings = stack_settings.clone(); - // Helm charts install the Kubernetes operator, which always polls the manager. - helm_settings.deployment_model = DeploymentModel::Pull; - let chart = alien_helm::generate_helm_chart( - stack, - alien_helm::HelmOptions { - registry: ®istry, - stack_settings: helm_settings, - chart_name: sanitize_kubernetes_dns_label(deployment_name), - }, - ) - .map_err(|error| { - AlienError::new(ErrorData::ConfigurationError { - message: format!("Failed to generate Kubernetes Helm chart: {error}"), - }) - })?; - - for (relative_path, contents) in chart.files { - let path = chart_dir.path().join(relative_path); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "create directory".to_string(), - file_path: parent.display().to_string(), - reason: "Failed to create Helm chart output directory".to_string(), - }, - )?; - } - std::fs::write(&path, contents).into_alien_error().context( - ErrorData::FileOperationFailed { - operation: "write".to_string(), - file_path: path.display().to_string(), - reason: "Failed to write generated Helm chart file".to_string(), - }, - )?; - } - - Ok(chart_dir) -} - -fn write_kubernetes_helm_values( - chart_dir: &Path, - manager_url: &str, - token: &str, - deployment_id: &str, - deployment_name: &str, - stack_settings: &StackSettings, - operator_image: &str, -) -> Result { - let (repository, tag) = split_image_tag(operator_image)?; - let mut helm_settings = stack_settings.clone(); - // Helm values are consumed by the Kubernetes operator, which always runs pull-model. - helm_settings.deployment_model = DeploymentModel::Pull; - let values = serde_json::json!({ - "management": { - "token": token, - "name": deployment_name, - "url": manager_url, - "deploymentId": deployment_id, - "updates": "auto", - "telemetry": "auto", - "healthChecks": "on", - }, - "runtime": { - "image": { - "repository": repository, - "tag": tag, - "pullPolicy": "IfNotPresent", - }, - "encryption": { - "key": super::operator::generate_encryption_key_public(), - } - }, - "stackSettings": helm_settings, - "infrastructure": null, - }); - let values_path = chart_dir.join("alien-deploy-values.json"); - let contents = serde_json::to_string_pretty(&values) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to serialize Helm values".to_string(), - })?; - std::fs::write(&values_path, contents) - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "write".to_string(), - file_path: values_path.display().to_string(), - reason: "Failed to write Helm values file".to_string(), - })?; - Ok(values_path) -} - -async fn helm_upgrade_install( - chart_dir: &Path, - values_file: &Path, - release: &str, - namespace: &str, - kubeconfig: Option<&str>, - kube_context: Option<&str>, -) -> Result<()> { - let mut cmd = tokio::process::Command::new("helm"); - cmd.arg("upgrade") - .arg("--install") - .arg(release) - .arg(chart_dir) - .arg("--namespace") - .arg(namespace) - .arg("--create-namespace") - .arg("-f") - .arg(values_file) - .arg("--wait") - .arg("--timeout") - .arg("300s"); - - if let Some(kubeconfig) = kubeconfig { - cmd.env("KUBECONFIG", kubeconfig); - } - if let Some(context) = kube_context { - cmd.arg("--kube-context").arg(context); - } - - let output = cmd - .output() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to execute helm. Ensure Helm is installed and available on PATH." - .to_string(), - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Helm upgrade/install failed: {stderr}"), - })); - } - - Ok(()) -} - -fn split_image_tag(image: &str) -> Result<(String, String)> { - if image.contains('@') { - return Err(AlienError::new(ErrorData::ValidationError { - field: "operator-image".to_string(), - message: "Kubernetes Helm installs require a tag-based operator image, not a digest" - .to_string(), - })); - } - let last_slash = image.rfind('/').unwrap_or(0); - let tag_separator = image[last_slash..].rfind(':').map(|idx| last_slash + idx); - let Some(separator) = tag_separator else { - return Ok((image.to_string(), "latest".to_string())); - }; - Ok(( - image[..separator].to_string(), - image[separator + 1..].to_string(), - )) -} - -fn sanitize_kubernetes_dns_label(value: &str) -> String { - let mut out = String::new(); - let mut last_dash = false; - for ch in value.chars() { - let next = if ch.is_ascii_alphanumeric() { - last_dash = false; - ch.to_ascii_lowercase() - } else if !last_dash { - last_dash = true; - '-' - } else { - continue; - }; - out.push(next); - if out.len() == 63 { - break; - } - } - let out = out.trim_matches('-').to_string(); - if out.is_empty() { - "alien".to_string() - } else { - out - } -} - -/// Default releases URL for downloading binaries. -const DEFAULT_RELEASES_URL: &str = "https://releases.alien.dev"; - -/// Find the alien-operator binary locally, or download it from the releases URL. -async fn find_or_download_operator_binary( - embedded_config: Option<&DeployCliConfig>, -) -> Result { - // Try to find it locally first - if let Ok(path) = super::operator::which_operator_binary() { - return Ok(path); - } - - // Download to ~/.alien/bin/alien-operator - let home = dirs::home_dir().ok_or_else(|| { - AlienError::new(ErrorData::ConfigurationError { - message: "Could not determine home directory".to_string(), - }) - })?; - - let bin_dir = home.join(".alien").join("bin"); - std::fs::create_dir_all(&bin_dir) - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "create".to_string(), - file_path: bin_dir.display().to_string(), - reason: "Failed to create ~/.alien/bin directory".to_string(), - })?; - - let binary_path = bin_dir.join("alien-operator"); - - let (os, arch) = detect_os_arch()?; - let url = if let Some(url) = embedded_config.and_then(|config| config.agent_binary_url.as_ref()) - { - url.clone() - } else { - let releases_url = std::env::var("ALIEN_RELEASES_URL") - .unwrap_or_else(|_| DEFAULT_RELEASES_URL.to_string()); - format!( - "{}/alien-operator/latest/{}-{}/alien-operator", - releases_url, os, arch - ) - }; - - output::info(&format!("Downloading alien-operator from {}...", url)); - - let response = - reqwest::get(&url) - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to download alien-operator from {}", url), - })?; - - if !response.status().is_success() { - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!( - "Failed to download alien-operator: HTTP {}", - response.status() - ), - })); - } - - let bytes = - response - .bytes() - .await - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to read alien-operator download response".to_string(), - })?; - - std::fs::write(&binary_path, &bytes) - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "write".to_string(), - file_path: binary_path.display().to_string(), - reason: "Failed to write alien-operator binary".to_string(), - })?; - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)) - .into_alien_error() - .context(ErrorData::FileOperationFailed { - operation: "chmod".to_string(), - file_path: binary_path.display().to_string(), - reason: "Failed to make alien-operator executable".to_string(), - })?; - } - - output::success("alien-operator downloaded successfully."); - - Ok(binary_path) -} - -fn detect_os_arch() -> Result<(&'static str, &'static str)> { - let os = if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "macos") { - "darwin" - } else { - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Unsupported OS: {}", std::env::consts::OS), - })); - }; - - let arch = if cfg!(target_arch = "x86_64") { - "x86_64" - } else if cfg!(target_arch = "aarch64") { - "aarch64" - } else { - return Err(AlienError::new(ErrorData::ConfigurationError { - message: format!("Unsupported architecture: {}", std::env::consts::ARCH), - })); - }; - - Ok((os, arch)) -} - -async fn run_push_model( - client: &ServerClient, - deployment_id: &str, - platform: Platform, - base_platform: Option, - manager_url: &str, - deployment_token: &str, - management_config: Option, - network_args: &NetworkArgs, - on_progress: Option, -) -> Result<()> { - let credential_platform = base_platform.unwrap_or(platform); - let client_config = ClientConfig::from_std_env(credential_platform) - .await - .context(ErrorData::ConfigurationError { - message: format!( - "Failed to load {} credentials from environment. Ensure the required environment variables are set.", - credential_platform - ), - })?; - - push_initial_setup( - client, - deployment_id, - platform, - base_platform, - client_config, - management_config, - manager_url, - deployment_token, - Some(network_args), - on_progress, - ) - .await -} - -fn apply_external_bindings_from_stack_settings( - config: &mut DeploymentConfig, - stack_settings: &StackSettings, -) { - if let Some(ref external_bindings) = stack_settings.external_bindings { - config.external_bindings = external_bindings.clone(); - } -} - -/// Run the push-model initial setup flow for a deployment. -/// -/// Fetches deployment and release state from the manager, acquires a sync lock, -/// steps the deployment through InitialSetup until it reaches Provisioning (or a -/// terminal state), reconciles state back to the manager, and releases the lock. -/// -/// This is used by both `alien-deploy deploy` (push model) and `alien-test` (e2e setup). -pub async fn push_initial_setup( - client: &ServerClient, - deployment_id: &str, - platform: Platform, - base_platform: Option, - client_config: ClientConfig, - management_config: Option, - manager_base_url: &str, - deployment_token: &str, - network_args: Option<&NetworkArgs>, - on_progress: Option, -) -> Result<()> { - let setup_management_config = management_config.clone(); - - // Get deployment from manager - let deployment = client - .get_deployment() - .id(deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - // Reconstruct DeploymentState from flat API response - let status = parse_deployment_status(&deployment.status)?; - - let stack_state = deployment - .stack_state - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_state from manager".to_string(), - })?; - let environment_info = deployment - .environment_info - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize environment_info from manager".to_string(), - })?; - - // If there's a desired release, fetch the full release info. A failed fetch must fail the - // setup, not silently degrade to a no-release deploy: swallowing it would report success while - // having installed nothing the caller asked for. - let target_release = if let Some(ref release_id) = deployment.desired_release_id { - let resp = client - .get_release() - .id(release_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: format!("Failed to fetch desired release {release_id} from manager"), - })?; - let rel = resp.into_inner(); - let platform_stack_value = release_stack_value_for_platform(rel.stack, platform) - .ok_or_else(|| { - AlienError::new(ErrorData::ConfigurationError { - message: format!( - "Release {} has no stack for platform {}", - release_id, - platform.as_str() - ), - }) - })?; - - // No stack rewriting — release already stores proxy URIs. - // Controllers use image URIs as-is. - let stack = serde_json::from_value(platform_stack_value) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to parse release stack".to_string(), - })?; - - Some(ReleaseInfo { - release_id: Some(rel.id), - version: None, - description: None, - stack, - }) - } else { - None - }; - - let mut state = DeploymentState { - status, - platform, - current_release: None, - target_release, - stack_state, - error: None, - environment_info, - runtime_metadata: None, - retry_requested: deployment.retry_requested, - protocol_version: alien_core::DEPLOYMENT_PROTOCOL_VERSION, - }; - - // Always override environment_info with the target client_config. - // The manager may have already run the Pending step with management - // credentials, setting environment_info to the management project. - // push_initial_setup runs with *target* credentials, so re-collecting - // ensures the environment_info reflects the actual target project. - let environment_platform = base_platform.unwrap_or(platform); - // Fail fast rather than proceed with absent/stale environment info: a setup that silently drops - // the target environment would report success while the deployment's env_info is wrong. Wrap in - // DeploymentFailed (retryable/internal = inherit), not the hard-non-retryable ConfigurationError, - // so a transient cloud blip in collect_environment_info (live STS / project-metadata calls) stays - // retryable instead of becoming a permanent setup failure. - if should_collect_push_setup_environment_info(environment_platform) { - let env_info = - alien_deployment::collect_environment_info(environment_platform, &client_config) - .await - .context(ErrorData::DeploymentFailed { - operation: "target environment-info collection".to_string(), - })?; - state.environment_info = Some(env_info); - } else { - state.environment_info = None; - } - - // Reconstruct DeploymentConfig from stack_settings - let mut stack_settings: StackSettings = deployment - .stack_settings - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_settings from manager".to_string(), - })? - .unwrap_or_default(); - - // Override network settings if the customer provided CLI flags - if let Some(net_args) = network_args { - let network_platform = base_platform.unwrap_or(platform); - let network_override = network::parse_network_settings(net_args, network_platform.as_str()) - .map_err(|e| { - AlienError::new(ErrorData::ValidationError { - field: "network".to_string(), - message: e, - }) - })?; - if let Some(ns) = network_override { - stack_settings.network = Some(ns); - } - } - - // Build a minimal config JSON and deserialize to get proper defaults - let mut config: DeploymentConfig = serde_json::from_value(serde_json::json!({ - "stackSettings": serde_json::to_value(&stack_settings).unwrap_or_default(), - "managementConfig": serde_json::to_value(&management_config).unwrap_or_default(), - "environmentVariables": { - "variables": [], - "hash": "", - "createdAt": "" - } - })) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to construct deployment config".to_string(), - })?; - - // Set manager URL and deployment token so controllers can configure - // pull auth (RegistryCredentials, imagePullSecrets) for the manager's registry. - config.manager_url = Some(manager_base_url.to_string()); - config.deployment_token = Some(deployment_token.to_string()); - config.base_platform = base_platform; - - apply_external_bindings_from_stack_settings(&mut config, &stack_settings); - - // Acquire sync lock — retry until the specific deployment is locked by us. - // The manager's deployment loop may already hold the lock; we must wait for - // it to release before proceeding. 2 minutes (60 × 2s) is sufficient because - // the manager skips Pending/InitialSetup for push-mode deployments — if it - // holds the lock, it checks push-mode + Pending and releases immediately. - let session = format!("push-setup-{}", uuid::Uuid::new_v4()); - let acquired_deployment = acquire_setup_run_deployment( - client, - deployment_id, - &session, - stack_settings.deployment_model, - ) - .await - .context(ErrorData::DeploymentFailed { - operation: "acquire sync lock".to_string(), - })?; - - if let Some(acquired_config) = acquired_deployment.get("deploymentConfig").cloned() { - config = serde_json::from_value(acquired_config) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize deploymentConfig from acquired deployment" - .to_string(), - })?; - - if let Some(net_args) = network_args { - let network_platform = base_platform.unwrap_or(platform); - let network_override = - network::parse_network_settings(net_args, network_platform.as_str()).map_err( - |e| { - AlienError::new(ErrorData::ValidationError { - field: "network".to_string(), - message: e, - }) - }, - )?; - if let Some(ns) = network_override { - config.stack_settings.network = Some(ns); - } - } - - config.manager_url = Some(manager_base_url.to_string()); - config.deployment_token = Some(deployment_token.to_string()); - config.management_config = setup_management_config.clone(); - config.base_platform = base_platform.or(config.base_platform); - let acquired_stack_settings = config.stack_settings.clone(); - apply_external_bindings_from_stack_settings(&mut config, &acquired_stack_settings); - } - - // Re-fetch the deployment state now that we hold the lock. - // The manager may have advanced the state while we were waiting. - let deployment = client - .get_deployment() - .id(deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - let status = parse_deployment_status(&deployment.status)?; - - state.status = status; - state.stack_state = deployment - .stack_state - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_state from manager".to_string(), - })?; - state.runtime_metadata = deployment - .runtime_metadata - .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize runtime_metadata from manager".to_string(), - })?; - - tracing::info!( - has_runtime_metadata = state.runtime_metadata.is_some(), - "push_initial_setup: state after re-fetch (before step loop)" - ); - - // Run the shared step loop with per-step reconciliation via the manager API - let transport = ManagerApiTransport::new(client.clone(), session.clone()); - let policy = RunnerPolicy { - max_steps: 400, - // Push model: run initial setup only, then hand off to the manager. - // The CLI drives Pending → InitialSetup → Provisioning, then stops. - // The manager picks up from Provisioning and drives to Running. - operation: LoopOperation::InitialSetup, - delay_threshold: None, - }; - - let runner_result = shared_run_step_loop( - &mut state, - &mut config, - &client_config, - deployment_id, - &policy, - &transport, - None, - on_progress.as_ref(), - ) - .await; - - // Always reconcile + release, even on error. - final_reconcile(client, deployment_id, &session, &state).await; - release_deployment(client, deployment_id, &session).await; - - // Handle runner result after lock release - let result = runner_result.context(ErrorData::DeploymentFailed { - operation: "initial setup".to_string(), - })?; - - match result.loop_result.outcome { - LoopOutcome::Success => { - output::success("Deployment is running."); - Ok(()) - } - LoopOutcome::Failure => Err(AlienError::new(ErrorData::DeploymentFailed { - operation: format!( - "deployment failed at status {}", - deployment_status_str(result.loop_result.final_status) - ), - })), - LoopOutcome::Neutral => { - if should_print_push_setup_neutral_completion(platform) { - output::success( - "Setup complete. Your deployment is being provisioned and will be ready shortly.", - ); - } - Ok(()) - } - } -} - -fn should_collect_push_setup_environment_info(platform: Platform) -> bool { - !matches!(platform, Platform::Machines) -} - -/// Run the push-model deletion flow for a deployment. -/// -/// Fetches deployment and release state from the manager, acquires a sync lock, -/// steps the deployment through DeletePending → Deleting → Deleted (or DeleteFailed), -/// reconciles state back to the manager, and releases the lock. -pub async fn push_deletion( - client: &ServerClient, - deployment_id: &str, - platform: Platform, - client_config: ClientConfig, -) -> Result<()> { - let deployment = client - .get_deployment() - .id(deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - let status = parse_deployment_status(&deployment.status)?; - - let stack_state = deployment - .stack_state - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_state from manager".to_string(), - })?; - let environment_info = deployment - .environment_info - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize environment_info from manager".to_string(), - })?; - let runtime_metadata = deployment - .runtime_metadata - .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize runtime_metadata from manager".to_string(), - })?; - - let current_release = if let Some(ref release_id) = deployment.current_release_id { - match client.get_release().id(release_id).send().await { - Ok(resp) => { - let rel = resp.into_inner(); - let platform_stack_value = release_stack_value_for_platform(rel.stack, platform); - platform_stack_value - .and_then(|v| serde_json::from_value(v).ok()) - .map(|stack| ReleaseInfo { - release_id: Some(rel.id), - version: None, - description: None, - stack, - }) - } - Err(_) => None, - } - } else { - None - }; - - let mut state = DeploymentState { - status, - platform, - current_release: current_release.clone(), - target_release: current_release, - stack_state, - error: None, - environment_info, - runtime_metadata, - retry_requested: deployment.retry_requested, - protocol_version: alien_core::DEPLOYMENT_PROTOCOL_VERSION, - }; - - let stack_settings: StackSettings = deployment - .stack_settings - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_settings from manager".to_string(), - })? - .unwrap_or_default(); - - let mut config: DeploymentConfig = serde_json::from_value(serde_json::json!({ - "stackSettings": serde_json::to_value(&stack_settings).unwrap_or_default(), - "environmentVariables": { - "variables": [], - "hash": "", - "createdAt": "" - } - })) - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to construct deployment config".to_string(), - })?; - - apply_external_bindings_from_stack_settings(&mut config, &stack_settings); - let service_provider = runtime_service_provider(&client_config)?; - - if platform == Platform::Local - && !matches!( - state.status, - DeploymentStatus::TeardownRequired | DeploymentStatus::TeardownFailed - ) - { - run_runtime_deletion( - client, - deployment_id, - &mut state, - &mut config, - &client_config, - stack_settings.deployment_model, - service_provider.clone(), - ) - .await?; - - if state.status == DeploymentStatus::Deleted { - output::success("Deployment deleted successfully."); - return Ok(()); - } - } - - run_setup_deletion( - client, - deployment_id, - &mut state, - &mut config, - &client_config, - stack_settings.deployment_model, - service_provider, - ) - .await -} - -fn runtime_service_provider( - client_config: &ClientConfig, -) -> Result>> { - let ClientConfig::Local { state_directory } = client_config else { - return Ok(None); - }; - - let local_bindings = alien_local::LocalBindingsProvider::new(Path::new(state_directory)) - .context(ErrorData::ConfigurationError { - message: format!( - "Failed to create local runtime provider from '{}'", - state_directory - ), - })?; - - Ok(Some(Arc::new( - alien_infra::DefaultPlatformServiceProvider::with_local_bindings(local_bindings), - ))) -} - -async fn run_runtime_deletion( - client: &ServerClient, - deployment_id: &str, - state: &mut DeploymentState, - config: &mut DeploymentConfig, - client_config: &ClientConfig, - deployment_model: alien_core::DeploymentModel, - service_provider: Option>, -) -> Result<()> { - let session = format!("push-runtime-deletion-{}", uuid::Uuid::new_v4()); - acquire_runtime_delete_deployment(client, deployment_id, &session, deployment_model) - .await - .context(ErrorData::DeploymentFailed { - operation: "acquire runtime deletion lock".to_string(), - })?; - - // Re-fetch deployment under lock - let deployment = client - .get_deployment() - .id(deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - let status = parse_deployment_status(&deployment.status)?; - state.status = status; - state.stack_state = deployment - .stack_state - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_state from manager".to_string(), - })?; - state.runtime_metadata = deployment - .runtime_metadata - .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize runtime_metadata from manager".to_string(), - })?; - - let transport = ManagerApiTransport::new(client.clone(), session.clone()); - let policy = RunnerPolicy { - max_steps: 400, - operation: LoopOperation::Delete, - delay_threshold: None, - }; - - let runner_result = shared_run_step_loop( - state, - config, - client_config, - deployment_id, - &policy, - &transport, - service_provider, - None, - ) - .await; - - // Always reconcile + release, even on error - final_reconcile(client, deployment_id, &session, state).await; - release_deployment(client, deployment_id, &session).await; - - // Handle runner result after lock release - let result = runner_result.context(ErrorData::DeploymentFailed { - operation: "deletion".to_string(), - })?; - - match result.loop_result.outcome { - LoopOutcome::Success => Ok(()), - LoopOutcome::Failure => { - let operation = format!( - "deletion failed at status {}", - deployment_status_str(result.loop_result.final_status) - ); - if let Some(error) = state.error.clone() { - Err(error.context(ErrorData::DeploymentFailed { operation })) - } else { - Err(AlienError::new(ErrorData::DeploymentFailed { operation })) - } - } - LoopOutcome::Neutral => Ok(()), - } -} - -async fn run_setup_deletion( - client: &ServerClient, - deployment_id: &str, - state: &mut DeploymentState, - config: &mut DeploymentConfig, - client_config: &ClientConfig, - deployment_model: alien_core::DeploymentModel, - service_provider: Option>, -) -> Result<()> { - let session = format!("push-setup-deletion-{}", uuid::Uuid::new_v4()); - let acquire_outcome = - acquire_setup_delete_deployment(client, deployment_id, &session, deployment_model) - .await - .context(ErrorData::DeploymentFailed { - operation: "acquire setup teardown lock".to_string(), - })?; - - if matches!(acquire_outcome, SetupDeleteAcquireOutcome::AlreadyDeleted) { - output::success("Deployment deleted successfully."); - return Ok(()); - } - - // Re-fetch deployment under lock - let deployment = client - .get_deployment() - .id(deployment_id) - .send() - .await - .into_sdk_error() - .context(ErrorData::ConfigurationError { - message: "Failed to get deployment from manager".to_string(), - })? - .into_inner(); - - let status = parse_deployment_status(&deployment.status)?; - state.status = status; - state.stack_state = deployment - .stack_state - .map(serde_json::from_value) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize stack_state from manager".to_string(), - })?; - state.runtime_metadata = deployment - .runtime_metadata - .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) - .transpose() - .into_alien_error() - .context(ErrorData::ConfigurationError { - message: "Failed to deserialize runtime_metadata from manager".to_string(), - })?; - - let transport = ManagerApiTransport::new(client.clone(), session.clone()); - let policy = RunnerPolicy { - max_steps: 400, - operation: LoopOperation::Delete, - delay_threshold: None, - }; - - let runner_result = alien_deployment::setup_teardown::run_setup_teardown_after_handoff( - state, - config, - client_config, - deployment_id, - &policy, - &transport, - service_provider, - ) - .await - .map(|setup_result| { - setup_result.unwrap_or_else(|| RunnerResult { - loop_result: LoopResult { - stop_reason: LoopStopReason::Synced, - outcome: LoopOutcome::Neutral, - final_status: state.status, - }, - steps_executed: 0, - }) - }); - - // Always reconcile + release, even on error - final_reconcile(client, deployment_id, &session, state).await; - release_deployment(client, deployment_id, &session).await; - - let result = runner_result.context(ErrorData::DeploymentFailed { - operation: "setup teardown".to_string(), - })?; - - match result.loop_result.outcome { - LoopOutcome::Success => { - output::success("Deployment deleted successfully."); - Ok(()) - } - LoopOutcome::Failure => { - let operation = format!( - "setup teardown failed at status {}", - deployment_status_str(result.loop_result.final_status) - ); - if let Some(error) = state.error.clone() { - Err(error.context(ErrorData::DeploymentFailed { operation })) - } else { - Err(AlienError::new(ErrorData::DeploymentFailed { operation })) - } - } - LoopOutcome::Neutral => Ok(()), - } -} diff --git a/crates/alien-deploy-cli/src/commands/up/config.rs b/crates/alien-deploy-cli/src/commands/up/config.rs new file mode 100644 index 000000000..74ba28d14 --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/config.rs @@ -0,0 +1,225 @@ +use super::*; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct DeployConfigFile { + /// Deployment name. + pub(super) name: Option, + /// Target platform: aws, gcp, azure, kubernetes, machines, or local. + pub(super) platform: Option, + /// Base cloud platform when `platform = "kubernetes"`. + pub(super) base_platform: Option, + /// Network settings for cloud deployments. + pub(super) network: Option, + /// Update delivery mode. + pub(super) updates: Option, + /// Telemetry delivery mode. + pub(super) telemetry: Option, + /// Static compute selections for Alien-managed runtime pools. + pub(super) compute: Option, + /// Generic public endpoint URLs for pull-model deployments. + pub(super) public_endpoints: Option, + /// Deployer-provided stack inputs. + pub(super) inputs: Option>, + /// Secret deployer-provided stack inputs. + pub(super) secret_inputs: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub(super) enum DeployConfigNetwork { + UseDefault, + Create { + cidr: Option, + #[serde(default = "default_config_availability_zones")] + availability_zones: u8, + }, + ByoVpcAws { + vpc_id: String, + public_subnet_ids: Vec, + private_subnet_ids: Vec, + #[serde(default)] + security_group_ids: Vec, + }, + ByoVpcGcp { + network_name: String, + subnet_name: String, + region: String, + }, + ByoVnetAzure { + vnet_resource_id: String, + public_subnet_name: String, + private_subnet_name: String, + }, +} + +fn default_config_availability_zones() -> u8 { + 2 +} + +impl From for NetworkSettings { + fn from(value: DeployConfigNetwork) -> Self { + match value { + DeployConfigNetwork::UseDefault => NetworkSettings::UseDefault, + DeployConfigNetwork::Create { + cidr, + availability_zones, + } => NetworkSettings::Create { + cidr, + availability_zones, + }, + DeployConfigNetwork::ByoVpcAws { + vpc_id, + public_subnet_ids, + private_subnet_ids, + security_group_ids, + } => NetworkSettings::ByoVpcAws { + vpc_id, + public_subnet_ids, + private_subnet_ids, + security_group_ids, + }, + DeployConfigNetwork::ByoVpcGcp { + network_name, + subnet_name, + region, + } => NetworkSettings::ByoVpcGcp { + network_name, + subnet_name, + region, + }, + DeployConfigNetwork::ByoVnetAzure { + vnet_resource_id, + public_subnet_name, + private_subnet_name, + } => NetworkSettings::ByoVnetAzure { + vnet_resource_id, + public_subnet_name, + private_subnet_name, + application_gateway_subnet_name: None, + private_endpoint_subnet_name: None, + }, + } + } +} + +pub(super) fn load_deploy_config(args: &UpArgs) -> Result> { + let Some(path) = &args.config else { + return Ok(None); + }; + + let text = std::fs::read_to_string(path).into_alien_error().context( + ErrorData::ConfigurationError { + message: format!("Failed to read deployment config {}", path.display()), + }, + )?; + let config = + toml::from_str(&text) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to parse deployment config {}", path.display()), + })?; + Ok(Some(config)) +} + +pub(super) fn load_public_endpoints( + args: &UpArgs, + platform: Platform, + deploy_config: Option<&DeployConfigFile>, +) -> Result> { + let mut public_endpoints = deploy_config + .and_then(|config| config.public_endpoints.clone()) + .unwrap_or_default(); + if !public_endpoints.is_empty() { + validate_public_endpoint_urls(&public_endpoints).context(ErrorData::ValidationError { + field: "publicEndpoints".to_string(), + message: "Invalid public endpoint URL in deployment config".to_string(), + })?; + } + + let mut cli_endpoints = BTreeSet::new(); + for value in &args.public_endpoints { + let (resource_id, endpoint_name, public_url) = parse_public_endpoint_assignment(value) + .context(ErrorData::ValidationError { + field: "public-endpoint".to_string(), + message: "Expected --public-endpoint .=" + .to_string(), + })?; + let key = format!("{resource_id}.{endpoint_name}"); + if !cli_endpoints.insert(key.clone()) { + return Err(AlienError::new(ErrorData::ValidationError { + field: "public-endpoint".to_string(), + message: format!("Duplicate public endpoint URL for '{key}'"), + })); + } + public_endpoints + .entry(resource_id) + .or_default() + .insert(endpoint_name, public_url); + } + + if public_endpoints.is_empty() { + return Ok(None); + } + + match platform { + Platform::Local | Platform::Machines => Ok(Some(public_endpoints)), + Platform::Aws | Platform::Gcp | Platform::Azure | Platform::Kubernetes | Platform::Test => { + Err(AlienError::new(ErrorData::ValidationError { + field: "public-endpoint".to_string(), + message: format!( + "--public-endpoint is currently supported only for local or machines deployments, got '{}'", + platform.as_str() + ), + })) + } + } +} + +pub(super) fn load_stack_settings( + args: &UpArgs, + platform: Platform, + deploy_config: Option<&DeployConfigFile>, +) -> Result { + let mut settings = StackSettings::default(); + + // The manager owns the deployment model for cloud platforms (push) and + // Kubernetes (pull), so this CLI omits it there. Local is the exception: + // the manager defaults Local to push for its own embedded dev loop, while + // `deploy --platform local` is the remote-operator install flow — it must + // request pull explicitly or the manager creates a push deployment whose + // initial setup has no local platform services. + if platform == Platform::Local { + settings.deployment_model = DeploymentModel::Pull; + } + + if let Some(config) = deploy_config { + if let Some(network) = config.network.clone() { + settings.network = Some(network.into()); + } + if let Some(updates) = config.updates { + settings.updates = updates; + } + if let Some(telemetry) = config.telemetry { + settings.telemetry = telemetry; + } + if let Some(compute) = config.compute.clone() { + settings.compute = Some(compute); + } + } + + if args.network.network_mode != NetworkMode::Auto { + let network_override = network::parse_network_settings(&args.network, platform.as_str()) + .map_err(|e| { + AlienError::new(ErrorData::ValidationError { + field: "network".to_string(), + message: e, + }) + })?; + if let Some(network) = network_override { + settings.network = Some(network); + } + } + + Ok(settings) +} diff --git a/crates/alien-deploy-cli/src/commands/up/inputs.rs b/crates/alien-deploy-cli/src/commands/up/inputs.rs new file mode 100644 index 000000000..da8baaa34 --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/inputs.rs @@ -0,0 +1,280 @@ +use super::*; + +pub(super) fn stack_input_matches_context( + input: &StackInputDefinition, + platform: Platform, +) -> bool { + if !input.provided_by.contains(&StackInputProvider::Deployer) { + return false; + } + if let Some(platforms) = &input.platforms { + if !platforms.contains(&platform) { + return false; + } + } + true +} + +pub(super) fn collect_deployer_input_values( + inputs: &[StackInputDefinition], + input_values: &[String], + secret_input_values: &[String], + deploy_config: Option<&DeployConfigFile>, +) -> Result> { + let mut raw_values = HashMap::::new(); + + if let Some(config_inputs) = deploy_config.and_then(|config| config.inputs.as_ref()) { + for (id, value) in config_inputs { + raw_values.insert(id.clone(), value.clone()); + } + } + if let Some(config_inputs) = deploy_config.and_then(|config| config.secret_inputs.as_ref()) { + for (id, value) in config_inputs { + raw_values.insert(id.clone(), value.clone()); + } + } + for input in input_values { + let (id, value) = parse_stack_input_arg(input, "--input")?; + raw_values.insert(id, value); + } + for input in secret_input_values { + let (id, value) = parse_stack_input_arg(input, "--secret-input")?; + raw_values.insert(id, value); + } + + if inputs.is_empty() { + return Ok(raw_values + .into_iter() + .map(|(id, value)| (id, serde_json::Value::String(value))) + .collect()); + } + + for id in raw_values.keys() { + if !inputs.iter().any(|input| input.id == *id) { + return Err(AlienError::new(ErrorData::ValidationError { + field: "input".to_string(), + message: format!("Unknown or unavailable deployer stack input '{id}'."), + })); + } + } + + for input in inputs.iter().filter(|input| input.required) { + if raw_values.contains_key(&input.id) { + continue; + } + if !can_prompt() { + return Err(AlienError::new(ErrorData::ValidationError { + field: "input".to_string(), + message: format!( + "Missing deployer input: {}. Pass {} {}=... or add [{}] to deployment.toml.", + input.label, + if matches!(input.kind, StackInputKind::Secret) { + "--secret-input" + } else { + "--input" + }, + input.id, + if matches!(input.kind, StackInputKind::Secret) { + "secretInputs" + } else { + "inputs" + } + ), + })); + } + let value = prompt_input_value(input)?; + raw_values.insert(input.id.clone(), value); + } + + let mut values = HashMap::new(); + for input in inputs { + let Some(raw_value) = raw_values.get(&input.id) else { + continue; + }; + values.insert(input.id.clone(), parse_stack_input_value(input, raw_value)?); + } + Ok(values) +} + +fn parse_stack_input_arg(input: &str, flag: &str) -> Result<(String, String)> { + let Some((id, value)) = input.split_once('=') else { + return Err(AlienError::new(ErrorData::ValidationError { + field: flag.trim_start_matches("--").to_string(), + message: format!("Invalid {flag} format: '{input}'. Use id=value"), + })); + }; + if id.trim().is_empty() { + return Err(AlienError::new(ErrorData::ValidationError { + field: flag.trim_start_matches("--").to_string(), + message: format!("Invalid {flag} format: input id is required"), + })); + } + Ok((id.trim().to_string(), value.to_string())) +} + +fn parse_stack_input_value(input: &StackInputDefinition, value: &str) -> Result { + match input.kind { + StackInputKind::String | StackInputKind::Secret | StackInputKind::Enum => { + validate_string_stack_input(input, value)?; + Ok(serde_json::Value::String(value.to_string())) + } + StackInputKind::Number => { + let number = value.parse::().map_err(|_| { + AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} must be a number.", input.label), + }) + })?; + serde_json::Number::from_f64(number) + .map(serde_json::Value::Number) + .ok_or_else(|| { + AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} must be a finite number.", input.label), + }) + }) + } + StackInputKind::Integer => { + let number = value.parse::().map_err(|_| { + AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} must be a whole number.", input.label), + }) + })?; + Ok(serde_json::Value::Number(number.into())) + } + StackInputKind::Boolean => { + let parsed = value.parse::().map_err(|_| { + AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} must be true or false.", input.label), + }) + })?; + Ok(serde_json::Value::Bool(parsed)) + } + StackInputKind::StringList => { + let values = value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| serde_json::Value::String(item.to_string())) + .collect::>(); + Ok(serde_json::Value::Array(values)) + } + } +} + +fn validate_string_stack_input(input: &StackInputDefinition, value: &str) -> Result<()> { + if let Some(validation) = &input.validation { + if let Some(values) = &validation.values { + if !values.iter().any(|candidate| candidate == value) { + return Err(AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} must be one of: {}.", input.label, values.join(", ")), + })); + } + } + if let Some(min) = validation.min_length { + if value.len() < min as usize { + return Err(AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} is too short.", input.label), + })); + } + } + if let Some(max) = validation.max_length { + if value.len() > max as usize { + return Err(AlienError::new(ErrorData::ValidationError { + field: input.id.clone(), + message: format!("{} is too long.", input.label), + })); + } + } + } + Ok(()) +} + +fn can_prompt() -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() +} + +fn prompt_input_value(input: &StackInputDefinition) -> Result { + let mut stderr = std::io::stderr(); + let prompt = if matches!(input.kind, StackInputKind::Secret) { + format!("{} (secret): ", input.label) + } else if let Some(placeholder) = input.placeholder.as_deref() { + format!("{} [{}]: ", input.label, placeholder) + } else { + format!("{}: ", input.label) + }; + stderr + .write_all(prompt.as_bytes()) + .and_then(|_| stderr.flush()) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to write input prompt".to_string(), + })?; + + let value = if matches!(input.kind, StackInputKind::Secret) { + read_secret_line()? + } else { + let mut value = String::new(); + std::io::stdin() + .read_line(&mut value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to read input value".to_string(), + })?; + value + }; + let value = value.trim_end_matches(['\r', '\n']).to_string(); + if value.is_empty() { + if let Some(placeholder) = input.placeholder.as_deref() { + return Ok(placeholder.to_string()); + } + } + Ok(value) +} + +#[cfg(unix)] +fn read_secret_line() -> Result { + use std::os::fd::AsRawFd; + + let stdin = std::io::stdin(); + let fd = stdin.as_raw_fd(); + let mut termios = std::mem::MaybeUninit::::uninit(); + let original = unsafe { + if libc::tcgetattr(fd, termios.as_mut_ptr()) != 0 { + return read_line_with_echo(); + } + termios.assume_init() + }; + let mut hidden = original; + hidden.c_lflag &= !libc::ECHO; + unsafe { + libc::tcsetattr(fd, libc::TCSANOW, &hidden); + } + + let result = read_line_with_echo(); + unsafe { + libc::tcsetattr(fd, libc::TCSANOW, &original); + } + eprintln!(); + result +} + +#[cfg(not(unix))] +fn read_secret_line() -> Result { + read_line_with_echo() +} + +fn read_line_with_echo() -> Result { + let mut value = String::new(); + std::io::stdin() + .read_line(&mut value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to read input value".to_string(), + })?; + Ok(value) +} diff --git a/crates/alien-deploy-cli/src/commands/up/machines.rs b/crates/alien-deploy-cli/src/commands/up/machines.rs new file mode 100644 index 000000000..16e673370 --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/machines.rs @@ -0,0 +1,142 @@ +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MachinesJoinTokenResponse { + pub(super) join_token: String, + pub(super) control_plane_url: Option, + pub(super) cluster_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct WrappedMachinesJoinToken<'a> { + join_token: &'a str, + control_plane_url: &'a str, + cluster_id: &'a str, +} + +pub(super) async fn create_machines_join_token( + base_url: &str, + token: &str, + deployment_id: &str, +) -> Result { + let http_client = create_manager_http_client(token)?; + let url = format!( + "{}/v1/machines/deployments/{}/join-tokens/rotate", + base_url.trim_end_matches('/'), + urlencoding::encode(deployment_id), + ); + + let response = http_client + .post(&url) + .send() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to create Machines join token from platform API".to_string(), + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Failed to create Machines join token (HTTP {status}): {body}"), + })); + } + + let response: MachinesJoinTokenResponse = + response + .json() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to parse Machines join token response".to_string(), + })?; + + normalize_machines_join_token_response(response) +} + +pub(super) fn normalize_machines_join_token_response( + response: MachinesJoinTokenResponse, +) -> Result { + let join_token = response.join_token.trim(); + if join_token.is_empty() { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: "Platform API returned an empty Machines join token".to_string(), + })); + } + if join_token.starts_with("aj1_") { + return Ok(join_token.to_string()); + } + + let control_plane_url = response + .control_plane_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cluster_id = response + .cluster_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + match (control_plane_url, cluster_id) { + (Some(control_plane_url), Some(cluster_id)) => { + validate_machines_control_plane_url(control_plane_url)?; + let payload = WrappedMachinesJoinToken { + join_token, + control_plane_url, + cluster_id, + }; + let json = serde_json::to_vec(&payload).into_alien_error().context( + ErrorData::ConfigurationError { + message: "Failed to encode Machines join token context".to_string(), + }, + )?; + Ok(format!("aj1_{}", URL_SAFE_NO_PAD.encode(json))) + } + _ => Err(AlienError::new(ErrorData::ConfigurationError { + message: + "Platform API returned a raw Machines join token without control plane context" + .to_string(), + })), + } +} + +fn validate_machines_control_plane_url(value: &str) -> Result<()> { + let url = reqwest::Url::parse(value).map_err(|e| { + AlienError::new(ErrorData::ConfigurationError { + message: format!("Platform API returned an invalid Machines control plane URL: {e}"), + }) + })?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: "Platform API returned an invalid Machines control plane URL".to_string(), + })); + } + Ok(()) +} + +pub(super) fn machines_join_command( + cli_name: &str, + install_script_url: Option<&str>, + join_token: &str, +) -> String { + if let Some(install_script_url) = install_script_url { + return format!( + "curl -fsSL {} | sudo bash -s -- join --token {}", + shell_single_quote(install_script_url), + shell_single_quote(join_token) + ); + } + + format!( + "sudo {cli_name} join --token {}", + shell_single_quote(join_token) + ) +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} diff --git a/crates/alien-deploy-cli/src/commands/up/mod.rs b/crates/alien-deploy-cli/src/commands/up/mod.rs new file mode 100644 index 000000000..7b12eac59 --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/mod.rs @@ -0,0 +1,472 @@ +//! Deploy command — sets up and runs a deployment. +//! +//! Push model (AWS, GCP, Azure): runs initial setup locally, then the manager +//! continues reconciliation remotely. +//! +//! Pull model (Local, Kubernetes): installs and starts the alien-operator service. + +use crate::deployment_tracking::{DeploymentTracker, TrackedLocalDeployment}; +use crate::error::{ErrorData, Result}; +use crate::output; +use alien_cli_common::network::{self, NetworkArgs, NetworkMode}; +use alien_core::embedded_config::DeployCliConfig; +use alien_core::{ + parse_public_endpoint_assignment, validate_public_endpoint_urls, ClientConfig, ComputeSettings, + Container, Daemon, DeploymentConfig, DeploymentModel, DeploymentState, DeploymentStatus, + ManagementConfig, NetworkSettings, Platform, PublicEndpointUrls, ReleaseInfo, Stack, + StackInputDefinition, StackInputKind, StackInputProvider, StackSettings, TelemetryMode, + UpdatesMode, Worker, +}; +use alien_deployment::{ + loop_contract::{LoopOperation, LoopOutcome, LoopResult, LoopStopReason}, + manager_api_transport::{ + acquire_runtime_delete_deployment, acquire_setup_delete_deployment, + acquire_setup_run_deployment, final_reconcile, release_deployment, ManagerApiTransport, + SetupDeleteAcquireOutcome, + }, + runner::{run_step_loop as shared_run_step_loop, RunnerPolicy, RunnerResult}, +}; +use alien_error::{AlienError, Context, ContextError, IntoAlienError}; +use alien_infra::ClientConfigExt; +use alien_manager_api::{Client as ServerClient, SdkResultExt as ManagerSdkResultExt}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeSet, HashMap}, + io::{IsTerminal, Write}, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; + +mod config; +mod inputs; +mod machines; +mod pull; +mod push; +mod resolve; +#[cfg(test)] +mod tests; + +use config::*; +use inputs::*; +use machines::*; +use pull::*; +use push::*; +use resolve::*; + +pub use pull::create_manager_client; +pub(crate) use pull::create_manager_http_client; +pub use push::{push_deletion, push_initial_setup}; +pub(crate) use resolve::{ + read_token_file, resolve_base_url_option, resolve_manager_url_option, resolve_optional_token, + resolve_platform_option, +}; + +#[derive(Parser, Debug, Clone)] +#[command( + about = "Deploy the application to a target environment", + after_help = "EXAMPLES: + # Deploy to AWS using a deployment group token + alien-deploy deploy --token ax_dg_abc123... --platform aws + + # Deploy using a token file so the token is not exposed in argv + alien-deploy deploy --token-file /run/alien/token --platform local + + # Deploy a local pull-model workload behind customer-managed ingress + alien-deploy deploy --token-file /run/alien/token --platform local --public-endpoint gateway.api=https://gateway.example.com + + # Deploy with an isolated VPC + alien-deploy deploy --token ax_dg_abc123... --platform aws --network create + + # Deploy into an existing VPC + alien-deploy deploy --token ax_dg_abc123... --platform aws --network byo --vpc-id vpc-0abc123 --public-subnet-ids subnet-pub1 --private-subnet-ids subnet-priv1 + + # Redeploy an existing tracked deployment + alien-deploy deploy --name production + + # Deploy to a standalone (OSS) manager + ALIEN_MANAGER_URL=https://manager.example.com alien-deploy deploy --token ax_dg_abc123... --platform aws" +)] +pub struct UpArgs { + /// Authentication token (deployment or deployment group token) + #[arg(long, env = "ALIEN_TOKEN")] + pub token: Option, + + /// Read authentication token from a file. + #[arg(long, conflicts_with = "token")] + pub token_file: Option, + + /// Manager URL override for pull-model platforms. + /// Cloud push deployments resolve their manager and install context from + /// the platform API so setup has the management configuration it needs. + #[arg(long, env = "ALIEN_MANAGER_URL")] + pub manager_url: Option, + + /// Platform API base URL. + /// Used for manager discovery when ALIEN_MANAGER_URL is not set. + #[arg(long, env = "ALIEN_BASE_URL")] + pub base_url: Option, + + /// Target platform (aws, gcp, azure, kubernetes, machines, local) + #[arg(long)] + pub platform: Option, + + /// Base cloud platform for managed Kubernetes setup (aws, gcp, azure). + #[arg(long, env = "OPERATOR_BASE_PLATFORM")] + pub base_platform: Option, + + /// Deployment name (for tracking) + #[arg(long)] + pub name: Option, + + /// Encryption key for operator database (required for pull model) + #[arg(long, env = "OPERATOR_ENCRYPTION_KEY")] + pub encryption_key: Option, + + /// Skip confirmation prompt + #[arg(long, short = 'y')] + pub yes: bool, + + /// Run the operator in the foreground instead of installing as a service. + /// Useful for testing — Ctrl+C to stop. + #[arg(long)] + pub foreground: bool, + + /// Data directory for operator state (foreground mode only). + /// Defaults to ~/.alien/operator-data. + #[arg(long)] + pub data_dir: Option, + + /// Enable Local runtime debug commands and shells on the installed operator service. + #[arg(long)] + pub enable_local_debug: bool, + + /// Override the shell command used by Local runtime debug shells. + #[arg(long)] + pub local_debug_shell_command: Option, + + /// Kubernetes namespace for Helm installs. + #[arg(long, env = "ALIEN_KUBERNETES_NAMESPACE")] + pub namespace: Option, + + /// Helm release name for Kubernetes installs. + #[arg(long, env = "ALIEN_HELM_RELEASE")] + pub helm_release: Option, + + /// Kubeconfig path for Kubernetes installs. Defaults to KUBECONFIG or kubectl defaults. + #[arg(long, env = "KUBECONFIG")] + pub kubeconfig: Option, + + /// Kubernetes context for Helm installs. + #[arg(long, env = "ALIEN_KUBE_CONTEXT")] + pub kube_context: Option, + + /// alien-operator image for Kubernetes Helm installs. + #[arg(long, env = "ALIEN_OPERATOR_IMAGE")] + pub operator_image: Option, + + /// TOML file containing deployment settings. + #[arg(long)] + pub config: Option, + + /// Stack input value for setup (id=value). + #[arg(long = "input")] + pub input_values: Vec, + + /// Secret stack input value for setup (id=value). + #[arg(long = "secret-input")] + pub secret_input_values: Vec, + + /// Public URL for an exposed endpoint in .= form. + /// + /// Intended for pull-model deployments where DNS, TLS, and ingress are + /// owned outside Alien. Repeat this flag for multiple endpoints. + #[arg(long = "public-endpoint")] + pub public_endpoints: Vec, + + #[command(flatten)] + pub network: NetworkArgs, +} + +pub async fn up_command(args: UpArgs, embedded_config: Option<&DeployCliConfig>) -> Result<()> { + let deploy_config = load_deploy_config(&args)?; + // Resolve token and platform from args, embedded config, or tracked deployment + let resolved = resolve_deployment_info(&args, embedded_config, deploy_config.as_ref())?; + let token = resolved.token; + let platform_str = resolved.platform; + let base_platform_str = resolved.base_platform; + let name = resolved.name; + + let platform = Platform::from_str(&platform_str).map_err(|e| { + AlienError::new(ErrorData::ValidationError { + field: "platform".to_string(), + message: e, + }) + })?; + let print_progress = should_print_deploy_progress(platform); + let base_platform = parse_base_platform(platform, base_platform_str.as_deref())?; + let public_endpoints = load_public_endpoints(&args, platform, deploy_config.as_ref())?; + let deployer_inputs = match fetch_deployment_info(&resolved.base_url, &token, platform).await { + Ok(info) => { + validate_deployment_readiness(&info, platform)?; + deployer_inputs_from_info(&info, platform) + } + Err(error) => { + if !args.input_values.is_empty() || !args.secret_input_values.is_empty() { + output::warn(&format!( + "Could not load stack input metadata; the platform API will validate supplied inputs: {error}" + )); + } + Vec::new() + } + }; + let stack_input_values = collect_deployer_input_values( + &deployer_inputs, + &args.input_values, + &args.secret_input_values, + deploy_config.as_ref(), + )?; + + let display_platform = match platform_str.as_str() { + "aws" => "AWS", + "gcp" => "Google Cloud", + "azure" => "Azure", + "kubernetes" => "Kubernetes", + "machines" => "Your machines", + "local" => "Local", + other => other, + }; + + let install_context_platform = base_platform.unwrap_or(platform); + let install_context_platform_str = install_context_platform.as_str().to_string(); + let (manager_url, install_management_config) = + if requires_install_context(install_context_platform) { + if print_progress { + output::info("Resolving deployment install context via platform API..."); + } + let context = discover_manager_install_context( + &resolved.base_url, + &token, + &install_context_platform_str, + ) + .await?; + // `management_config` is required by the production SaaS API to + // describe the cross-account role used at provisioning time. The + // standalone manager returns it as `None` when it runs in a + // single-account setup (where the deployment account *is* the + // managing account and no cross-account access is involved); + // downstream code is already `Option`-aware. + (context.manager_url, context.management_config) + } else { + match resolved.manager_url { + Some(url) => (url, None), + None => { + if print_progress { + output::info("Discovering manager via platform API..."); + } + let context = discover_manager_install_context( + &resolved.base_url, + &token, + &install_context_platform_str, + ) + .await?; + (context.manager_url, context.management_config) + } + } + }; + + if print_progress { + let banner_title = embedded_config + .and_then(|c| c.display_name.as_deref()) + .unwrap_or("Alien Deploy"); + output::banner(banner_title); + output::label_value("Platform", display_platform); + if let Some(base_platform) = base_platform { + output::label_value("Base platform", base_platform.as_str()); + } + output::label_value("Manager", &manager_url); + output::label_value("Name", &name); + if let Some(public_endpoints) = public_endpoints.as_ref() { + let endpoint_count: usize = public_endpoints.values().map(HashMap::len).sum(); + output::label_value("Public endpoints", &endpoint_count.to_string()); + } + eprintln!(); + } + + let stack_settings = load_stack_settings(&args, platform, deploy_config.as_ref())?; + + // Create authenticated manager client + let client = create_manager_client(&token, &manager_url)?; + + // Initialize with manager + let init = initialize_deployment( + &client, + &token, + platform, + base_platform, + &name, + &stack_settings, + stack_input_values, + ) + .await?; + let deployment_id = init.deployment_id; + if print_progress { + output::success("Connected to manager"); + } + + // Use deployment-scoped token if the manager returned one, otherwise keep the original. + let effective_token = init.deployment_token.unwrap_or_else(|| token.clone()); + let client = create_manager_client(&effective_token, &manager_url)?; + let local_tracking = local_tracking_metadata(&args, platform); + + // Track the deployment locally + let mut tracker = DeploymentTracker::new()?; + tracker.track( + name.clone(), + deployment_id.clone(), + effective_token.clone(), + manager_url.clone(), + platform_str.clone(), + local_tracking, + )?; + + // Check if the deployment is already active — nothing to do. + let current_deployment = client + .get_deployment() + .id(&deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + if let Some(public_endpoints) = public_endpoints.as_ref() { + let release_id = current_deployment + .desired_release_id + .as_deref() + .or(current_deployment.current_release_id.as_deref()) + .ok_or_else(|| { + AlienError::new(ErrorData::ValidationError { + field: "public-endpoint".to_string(), + message: + "Cannot validate public endpoints because the deployment has no release" + .to_string(), + }) + })?; + let stack = fetch_release_stack_by_id(&client, release_id, platform).await?; + validate_public_endpoint_names(public_endpoints, &stack)?; + } + + if current_deployment.status == "running" + && public_endpoints.is_none() + && platform != Platform::Machines + { + eprintln!(); + output::success(&format!("Deployment '{}' is already active.", name)); + return Ok(()); + } else if current_deployment.status == "running" { + output::info( + "Deployment is already active; updating local operator public endpoint config.", + ); + } + + match init.deployment_model { + DeploymentModel::Pull => { + run_pull_model( + &client, + &args, + &manager_url, + &effective_token, + &deployment_id, + &name, + &stack_settings, + platform, + embedded_config, + public_endpoints.as_ref(), + ) + .await?; + } + DeploymentModel::Push => match platform { + Platform::Machines => { + push_initial_setup( + &client, + &deployment_id, + platform, + base_platform, + ClientConfig::Machines, + install_management_config, + &manager_url, + &effective_token, + None, + None, + ) + .await?; + + let join_token = create_machines_join_token( + &resolved.base_url, + &effective_token, + &deployment_id, + ) + .await?; + let cli_name = embedded_config + .and_then(|config| config.name.as_deref()) + .unwrap_or("alien-deploy"); + let install_script_url = + embedded_config.and_then(|config| config.install_script_url.as_deref()); + println!( + "{}", + machines_join_command(cli_name, install_script_url, &join_token) + ); + return Ok(()); + } + Platform::Test => { + output::info("Test platform — no deployment action needed."); + } + Platform::Aws + | Platform::Gcp + | Platform::Azure + | Platform::Kubernetes + | Platform::Local => { + // Build progress callback + let progress = + std::sync::Arc::new(std::sync::Mutex::new(output::DeployProgress::new())); + let progress_clone = progress.clone(); + let on_progress: alien_deployment::runner::ProgressCallback = + Box::new(move |step_progress| { + let mut p = progress_clone.lock().unwrap_or_else(|e| e.into_inner()); + p.update(step_progress); + }); + + run_push_model( + &client, + &deployment_id, + platform, + base_platform, + &manager_url, + &effective_token, + install_management_config, + &args.network, + Some(on_progress), + ) + .await?; + + // Clear the live progress display + let mut p = progress.lock().unwrap_or_else(|e| e.into_inner()); + p.finish(); + } + }, + } + + eprintln!(); + output::success(&format!("Deployment '{}' is active.", name)); + + Ok(()) +} + +fn should_print_deploy_progress(platform: Platform) -> bool { + platform != Platform::Machines +} diff --git a/crates/alien-deploy-cli/src/commands/up/pull.rs b/crates/alien-deploy-cli/src/commands/up/pull.rs new file mode 100644 index 000000000..047b9c05e --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/pull.rs @@ -0,0 +1,969 @@ +use super::*; + +use crate::commands::operator::generate_encryption_key_public; + +pub(super) struct ManagerInstallContext { + pub(super) manager_url: String, + pub(super) management_config: Option, +} + +/// Discover the manager URL and platform-managed install context via the platform API. +/// +/// Calls GET /v1/resolve?platform=X to resolve the manager. +/// The token's scope (DG, project, etc.) provides the project context +/// to the server — no need to call whoami first. +pub(super) async fn discover_manager_install_context( + base_url: &str, + token: &str, + platform: &str, +) -> Result { + let http_client = { + use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; + + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", token)) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Invalid token format".to_string(), + })?, + ); + headers.insert(USER_AGENT, HeaderValue::from_static("alien-deploy-cli")); + + reqwest::Client::builder() + .default_headers(headers) + .build() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to build HTTP client".to_string(), + })? + }; + + let url = format!( + "{}/v1/resolve?platform={}", + base_url, + urlencoding::encode(platform), + ); + + let resp = http_client + .get(&url) + .send() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to call /v1/resolve on platform API".to_string(), + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!( + "Failed to resolve manager via platform API (HTTP {}): {}", + status, body + ), + })); + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct ResolveResponse { + manager_url: String, + install_context: Option, + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct ResolveInstallContext { + management_config: ManagementConfig, + } + + let resolved: ResolveResponse = + resp.json() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to parse /v1/resolve response".to_string(), + })?; + + Ok(ManagerInstallContext { + manager_url: resolved.manager_url, + management_config: resolved + .install_context + .map(|context| context.management_config), + }) +} + +pub fn create_manager_client(token: &str, manager_url: &str) -> Result { + let http_client = create_manager_http_client(token)?; + Ok(ServerClient::new_with_client(manager_url, http_client)) +} + +pub(crate) fn create_manager_http_client(token: &str) -> Result { + use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; + + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", token)) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Invalid token format".to_string(), + })?, + ); + headers.insert(USER_AGENT, HeaderValue::from_static("alien-deploy-cli")); + + reqwest::Client::builder() + .default_headers(headers) + .build() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to create HTTP client".to_string(), + }) +} + +pub(super) fn parse_deployment_status(raw_status: &str) -> Result { + match raw_status.to_ascii_lowercase().as_str() { + "pending" => Ok(DeploymentStatus::Pending), + "preflights-failed" => Ok(DeploymentStatus::PreflightsFailed), + "initial-setup" => Ok(DeploymentStatus::InitialSetup), + "initial-setup-failed" => Ok(DeploymentStatus::InitialSetupFailed), + "provisioning" => Ok(DeploymentStatus::Provisioning), + "waiting-for-machines" => Ok(DeploymentStatus::WaitingForMachines), + "provisioning-failed" => Ok(DeploymentStatus::ProvisioningFailed), + "running" => Ok(DeploymentStatus::Running), + "refresh-failed" => Ok(DeploymentStatus::RefreshFailed), + "update-pending" => Ok(DeploymentStatus::UpdatePending), + "updating" => Ok(DeploymentStatus::Updating), + "update-failed" => Ok(DeploymentStatus::UpdateFailed), + "delete-pending" => Ok(DeploymentStatus::DeletePending), + "deleting" => Ok(DeploymentStatus::Deleting), + "delete-failed" => Ok(DeploymentStatus::DeleteFailed), + "teardown-required" => Ok(DeploymentStatus::TeardownRequired), + "teardown-failed" => Ok(DeploymentStatus::TeardownFailed), + "deleted" => Ok(DeploymentStatus::Deleted), + "error" => Ok(DeploymentStatus::Error), + _ => Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Unknown deployment status returned by manager: {raw_status}"), + })), + } +} + +pub(super) fn deployment_status_str(status: DeploymentStatus) -> &'static str { + match status { + DeploymentStatus::Pending => "pending", + DeploymentStatus::PreflightsFailed => "preflights-failed", + DeploymentStatus::InitialSetup => "initial-setup", + DeploymentStatus::InitialSetupFailed => "initial-setup-failed", + DeploymentStatus::Provisioning => "provisioning", + DeploymentStatus::WaitingForMachines => "waiting-for-machines", + DeploymentStatus::ProvisioningFailed => "provisioning-failed", + DeploymentStatus::Running => "running", + DeploymentStatus::RefreshFailed => "refresh-failed", + DeploymentStatus::UpdatePending => "update-pending", + DeploymentStatus::Updating => "updating", + DeploymentStatus::UpdateFailed => "update-failed", + DeploymentStatus::DeletePending => "delete-pending", + DeploymentStatus::Deleting => "deleting", + DeploymentStatus::DeleteFailed => "delete-failed", + DeploymentStatus::TeardownRequired => "teardown-required", + DeploymentStatus::TeardownFailed => "teardown-failed", + DeploymentStatus::Deleted => "deleted", + DeploymentStatus::Error => "error", + } +} + +/// Result of initializing with the manager. +pub(super) struct InitResult { + pub(super) deployment_id: String, + pub(super) deployment_model: DeploymentModel, + /// Deployment-scoped token returned by the manager (when using a deployment group token). + /// If present, this should replace the original token for subsequent requests. + pub(super) deployment_token: Option, +} + +pub(super) async fn initialize_deployment( + client: &ServerClient, + _token: &str, + platform: Platform, + base_platform: Option, + name: &str, + stack_settings: &StackSettings, + input_values: HashMap, +) -> Result { + let body = alien_manager_api::types::InitializeRequest { + name: Some(name.to_string()), + platform: Some(sdk_platform(platform)), + base_platform: base_platform.map(sdk_platform), + initial_desired_release: alien_manager_api::types::InitialDesiredRelease::Active, + stack_settings: Some(sdk_stack_settings(stack_settings)?), + input_values: input_values.into_iter().collect(), + scope: None, + permission: None, + setup_method: None, + }; + + let response = match client.initialize().body(body).send().await { + Ok(response) => response, + Err(error) => { + // Read the error body so server-side rejections surface their own + // message; the manager-URL hint only applies when the manager was + // unreachable. + let error = alien_manager_api::convert_sdk_error_reading_body(error).await; + let context = if error.code == "COMMUNICATION_ERROR" { + ErrorData::ConfigurationError { + message: "Failed to initialize with manager. Is the manager running? Check that --manager-url is correct.".to_string(), + } + } else { + ErrorData::DeploymentFailed { + operation: "initialize".to_string(), + } + }; + return Err(error).context(context); + } + }; + + let init = response.into_inner(); + let deployment_model = manager_deployment_model(init.deployment_model)?; + Ok(InitResult { + deployment_id: init.deployment_id, + deployment_model, + deployment_token: init.token, + }) +} + +fn manager_deployment_model(deployment_model: T) -> Result { + let value = serde_json::to_value(deployment_model) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to serialize manager deployment model".to_string(), + })?; + serde_json::from_value(value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize manager deployment model".to_string(), + }) +} + +fn sdk_platform(platform: Platform) -> alien_manager_api::types::Platform { + match platform { + Platform::Aws => alien_manager_api::types::Platform::Aws, + Platform::Gcp => alien_manager_api::types::Platform::Gcp, + Platform::Azure => alien_manager_api::types::Platform::Azure, + Platform::Kubernetes => alien_manager_api::types::Platform::Kubernetes, + Platform::Machines => alien_manager_api::types::Platform::Machines, + Platform::Local => alien_manager_api::types::Platform::Local, + Platform::Test => alien_manager_api::types::Platform::Test, + } +} + +pub(super) fn sdk_stack_settings( + stack_settings: &StackSettings, +) -> Result { + let value = serde_json::to_value(stack_settings) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to serialize stack settings".to_string(), + })?; + serde_json::from_value(value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to convert stack settings for manager API".to_string(), + }) +} + +pub(super) async fn run_pull_model( + client: &ServerClient, + args: &UpArgs, + manager_url: &str, + token: &str, + deployment_id: &str, + deployment_name: &str, + stack_settings: &StackSettings, + platform: Platform, + embedded_config: Option<&DeployCliConfig>, + public_endpoints: Option<&PublicEndpointUrls>, +) -> Result<()> { + match platform { + Platform::Kubernetes => { + run_kubernetes_pull_model( + client, + args, + manager_url, + token, + deployment_id, + deployment_name, + stack_settings, + ) + .await + } + _ => { + let data_dir = local_operator_data_dir(args); + run_local_pull_model( + args, + manager_url, + token, + deployment_id, + deployment_name, + &platform.to_string(), + embedded_config, + public_endpoints, + data_dir.as_deref(), + ) + .await + } + } +} + +fn default_foreground_data_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".alien") + .join("operator-data") +} + +fn local_operator_data_dir(args: &UpArgs) -> Option { + args.data_dir.clone().or_else(|| { + if args.foreground { + Some(default_foreground_data_dir().to_string_lossy().to_string()) + } else { + Some(crate::commands::operator::default_service_data_dir()) + } + }) +} + +pub(super) fn local_tracking_metadata( + args: &UpArgs, + platform: Platform, +) -> Option { + if platform != Platform::Local { + return None; + } + + local_operator_data_dir(args).map(|data_dir| TrackedLocalDeployment { + data_dir, + service_managed: !args.foreground, + }) +} + +async fn run_local_pull_model( + args: &UpArgs, + manager_url: &str, + token: &str, + deployment_id: &str, + deployment_name: &str, + platform: &str, + embedded_config: Option<&DeployCliConfig>, + public_endpoints: Option<&PublicEndpointUrls>, + data_dir: Option<&str>, +) -> Result<()> { + let encryption_key = args + .encryption_key + .clone() + .unwrap_or_else(|| generate_encryption_key_public()); + + // Find or download the alien-operator binary + let binary_path = find_or_download_operator_binary(embedded_config).await?; + + output::info(&format!("Operator binary: {}", binary_path.display())); + + if args.foreground { + return run_operator_foreground( + &binary_path, + manager_url, + token, + deployment_id, + deployment_name, + platform, + &encryption_key, + data_dir, + public_endpoints, + args.enable_local_debug, + args.local_debug_shell_command.as_deref(), + ) + .await; + } + + output::info("Installing alien-operator as a system service..."); + + // Delegate to the operator install logic + let install_args = crate::commands::operator::InstallArgs { + binary: Some(binary_path), + sync_url: manager_url.to_string(), + sync_token: token.to_string(), + deployment_id: Some(deployment_id.to_string()), + operator_name: Some(deployment_name.to_string()), + platform: platform.to_string(), + data_dir: data_dir.map(ToOwned::to_owned), + encryption_key: args.encryption_key.clone(), + public_endpoints: public_endpoints.cloned(), + enable_local_debug: args.enable_local_debug, + local_debug_shell_command: args.local_debug_shell_command.clone(), + }; + + crate::commands::operator::install_service(install_args)?; + + output::success("alien-operator installed and running as a system service."); + output::info("The operator will sync with the manager and deploy updates automatically."); + output::info("Use 'alien-deploy operator status' to check the service."); + + Ok(()) +} + +/// Run the operator as a foreground child process (for testing). +async fn run_operator_foreground( + binary_path: &std::path::Path, + manager_url: &str, + token: &str, + deployment_id: &str, + operator_name: &str, + platform: &str, + encryption_key: &str, + data_dir_override: Option<&str>, + public_endpoints: Option<&PublicEndpointUrls>, + enable_local_debug: bool, + local_debug_shell_command: Option<&str>, +) -> Result<()> { + use std::io::Write; + + output::info("Running operator in foreground (Ctrl+C to stop)..."); + + let data_dir = if let Some(dir) = data_dir_override { + std::path::PathBuf::from(dir) + } else { + default_foreground_data_dir() + }; + + // The operator rejects `--sync-token`/`--encryption-key` because argv is + // visible in `ps` / `/proc//cmdline`. Write each secret to its own + // tempfile (0o600 on Unix) and pass the path via `--*-file`. The + // `NamedTempFile`s must outlive the child process — drop deletes them. + let mut sync_token_file = tempfile::NamedTempFile::new().into_alien_error().context( + ErrorData::ConfigurationError { + message: "Failed to create temp file for sync token".to_string(), + }, + )?; + sync_token_file + .write_all(token.as_bytes()) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to write sync token".to_string(), + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + sync_token_file.path(), + std::fs::Permissions::from_mode(0o600), + ); + } + + let mut encryption_key_file = tempfile::NamedTempFile::new().into_alien_error().context( + ErrorData::ConfigurationError { + message: "Failed to create temp file for encryption key".to_string(), + }, + )?; + encryption_key_file + .write_all(encryption_key.as_bytes()) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to write encryption key".to_string(), + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + encryption_key_file.path(), + std::fs::Permissions::from_mode(0o600), + ); + } + + let mut public_endpoints_file = match public_endpoints { + Some(public_endpoints) => { + let mut file = tempfile::NamedTempFile::new().into_alien_error().context( + ErrorData::ConfigurationError { + message: "Failed to create temp file for public endpoints".to_string(), + }, + )?; + serde_json::to_writer(&mut file, public_endpoints) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to write public endpoints".to_string(), + })?; + Some(file) + } + None => None, + }; + + let mut command = tokio::process::Command::new(binary_path); + command + .arg("--platform") + .arg(platform) + .arg("--sync-url") + .arg(manager_url) + .arg("--sync-token-file") + .arg(sync_token_file.path()) + .arg("--deployment-id") + .arg(deployment_id) + .arg("--operator-name") + .arg(operator_name) + .arg("--encryption-key-file") + .arg(encryption_key_file.path()) + .arg("--data-dir") + .arg(&data_dir) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + if let Some(file) = public_endpoints_file.as_ref() { + command.arg("--public-endpoints-file").arg(file.path()); + } + if enable_local_debug { + command.arg("--enable-local-debug"); + } + if let Some(shell_command) = local_debug_shell_command { + command + .arg("--local-debug-shell-command") + .arg(shell_command); + } + + let status = + command + .status() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to run operator: {}", binary_path.display()), + })?; + + // Tempfiles drop here, after the child exits. + drop(sync_token_file); + drop(encryption_key_file); + drop(public_endpoints_file.take()); + + if !status.success() { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Operator exited with status: {}", status), + })); + } + + Ok(()) +} + +async fn run_kubernetes_pull_model( + client: &ServerClient, + args: &UpArgs, + manager_url: &str, + token: &str, + deployment_id: &str, + deployment_name: &str, + stack_settings: &StackSettings, +) -> Result<()> { + output::info("Kubernetes platform detected — installing alien-operator with Helm."); + let stack = fetch_kubernetes_release_stack(client, deployment_id).await?; + let namespace = args + .namespace + .clone() + .unwrap_or_else(|| format!("alien-{}", sanitize_kubernetes_dns_label(deployment_name))); + let release = args + .helm_release + .clone() + .unwrap_or_else(|| "alien-operator".to_string()); + let operator_image = args + .operator_image + .clone() + .unwrap_or_else(|| "ghcr.io/alienplatform/alien-operator:latest".to_string()); + + let chart_dir = render_kubernetes_helm_chart(&stack, stack_settings, deployment_name)?; + let values_file = write_kubernetes_helm_values( + chart_dir.path(), + manager_url, + token, + deployment_id, + deployment_name, + stack_settings, + &operator_image, + )?; + + helm_upgrade_install( + chart_dir.path(), + &values_file, + &release, + &namespace, + args.kubeconfig.as_deref(), + args.kube_context.as_deref(), + ) + .await?; + + output::success(&format!( + "alien-operator Helm release '{}' is installed in namespace '{}'.", + release, namespace + )); + output::info(&format!("Deployment ID: {}", deployment_id)); + + Ok(()) +} + +async fn fetch_kubernetes_release_stack( + client: &ServerClient, + deployment_id: &str, +) -> Result { + let deployment = client + .get_deployment() + .id(deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + let release_id = deployment + .desired_release_id + .or(deployment.current_release_id) + .ok_or_else(|| { + AlienError::new(ErrorData::ConfigurationError { + message: "Deployment has no release to install as a Kubernetes Helm chart" + .to_string(), + }) + })?; + let release = client + .get_release() + .id(&release_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to fetch release '{release_id}' from manager"), + })? + .into_inner(); + let stack_value = release.stack.kubernetes.ok_or_else(|| { + AlienError::new(ErrorData::ConfigurationError { + message: format!("Release '{release_id}' does not contain a Kubernetes stack"), + }) + })?; + + serde_json::from_value(stack_value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to parse Kubernetes stack from release '{release_id}'"), + }) +} + +fn render_kubernetes_helm_chart( + stack: &Stack, + stack_settings: &StackSettings, + deployment_name: &str, +) -> Result { + let chart_dir = + tempfile::tempdir() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to create temporary Helm chart directory".to_string(), + })?; + let registry = alien_helm::HelmRegistry::built_in(); + let mut helm_settings = stack_settings.clone(); + // Helm charts install the Kubernetes operator, which always polls the manager. + helm_settings.deployment_model = DeploymentModel::Pull; + let chart = alien_helm::generate_helm_chart( + stack, + alien_helm::HelmOptions { + registry: ®istry, + stack_settings: helm_settings, + chart_name: sanitize_kubernetes_dns_label(deployment_name), + }, + ) + .map_err(|error| { + AlienError::new(ErrorData::ConfigurationError { + message: format!("Failed to generate Kubernetes Helm chart: {error}"), + }) + })?; + + for (relative_path, contents) in chart.files { + let path = chart_dir.path().join(relative_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "create directory".to_string(), + file_path: parent.display().to_string(), + reason: "Failed to create Helm chart output directory".to_string(), + }, + )?; + } + std::fs::write(&path, contents).into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "write".to_string(), + file_path: path.display().to_string(), + reason: "Failed to write generated Helm chart file".to_string(), + }, + )?; + } + + Ok(chart_dir) +} + +fn write_kubernetes_helm_values( + chart_dir: &Path, + manager_url: &str, + token: &str, + deployment_id: &str, + deployment_name: &str, + stack_settings: &StackSettings, + operator_image: &str, +) -> Result { + let (repository, tag) = split_image_tag(operator_image)?; + let mut helm_settings = stack_settings.clone(); + // Helm values are consumed by the Kubernetes operator, which always runs pull-model. + helm_settings.deployment_model = DeploymentModel::Pull; + let values = serde_json::json!({ + "management": { + "token": token, + "name": deployment_name, + "url": manager_url, + "deploymentId": deployment_id, + "updates": "auto", + "telemetry": "auto", + "healthChecks": "on", + }, + "runtime": { + "image": { + "repository": repository, + "tag": tag, + "pullPolicy": "IfNotPresent", + }, + "encryption": { + "key": generate_encryption_key_public(), + } + }, + "stackSettings": helm_settings, + "infrastructure": null, + }); + let values_path = chart_dir.join("alien-deploy-values.json"); + let contents = serde_json::to_string_pretty(&values) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to serialize Helm values".to_string(), + })?; + std::fs::write(&values_path, contents) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "write".to_string(), + file_path: values_path.display().to_string(), + reason: "Failed to write Helm values file".to_string(), + })?; + Ok(values_path) +} + +async fn helm_upgrade_install( + chart_dir: &Path, + values_file: &Path, + release: &str, + namespace: &str, + kubeconfig: Option<&str>, + kube_context: Option<&str>, +) -> Result<()> { + let mut cmd = tokio::process::Command::new("helm"); + cmd.arg("upgrade") + .arg("--install") + .arg(release) + .arg(chart_dir) + .arg("--namespace") + .arg(namespace) + .arg("--create-namespace") + .arg("-f") + .arg(values_file) + .arg("--wait") + .arg("--timeout") + .arg("300s"); + + if let Some(kubeconfig) = kubeconfig { + cmd.env("KUBECONFIG", kubeconfig); + } + if let Some(context) = kube_context { + cmd.arg("--kube-context").arg(context); + } + + let output = cmd + .output() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to execute helm. Ensure Helm is installed and available on PATH." + .to_string(), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Helm upgrade/install failed: {stderr}"), + })); + } + + Ok(()) +} + +pub(super) fn split_image_tag(image: &str) -> Result<(String, String)> { + if image.contains('@') { + return Err(AlienError::new(ErrorData::ValidationError { + field: "operator-image".to_string(), + message: "Kubernetes Helm installs require a tag-based operator image, not a digest" + .to_string(), + })); + } + let last_slash = image.rfind('/').unwrap_or(0); + let tag_separator = image[last_slash..].rfind(':').map(|idx| last_slash + idx); + let Some(separator) = tag_separator else { + return Ok((image.to_string(), "latest".to_string())); + }; + Ok(( + image[..separator].to_string(), + image[separator + 1..].to_string(), + )) +} + +pub(super) fn sanitize_kubernetes_dns_label(value: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + for ch in value.chars() { + let next = if ch.is_ascii_alphanumeric() { + last_dash = false; + ch.to_ascii_lowercase() + } else if !last_dash { + last_dash = true; + '-' + } else { + continue; + }; + out.push(next); + if out.len() == 63 { + break; + } + } + let out = out.trim_matches('-').to_string(); + if out.is_empty() { + "alien".to_string() + } else { + out + } +} + +/// Default releases URL for downloading binaries. +const DEFAULT_RELEASES_URL: &str = "https://releases.alien.dev"; + +/// Find the alien-operator binary locally, or download it from the releases URL. +async fn find_or_download_operator_binary( + embedded_config: Option<&DeployCliConfig>, +) -> Result { + // Try to find it locally first + if let Ok(path) = crate::commands::operator::which_operator_binary() { + return Ok(path); + } + + // Download to ~/.alien/bin/alien-operator + let home = dirs::home_dir().ok_or_else(|| { + AlienError::new(ErrorData::ConfigurationError { + message: "Could not determine home directory".to_string(), + }) + })?; + + let bin_dir = home.join(".alien").join("bin"); + std::fs::create_dir_all(&bin_dir) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "create".to_string(), + file_path: bin_dir.display().to_string(), + reason: "Failed to create ~/.alien/bin directory".to_string(), + })?; + + let binary_path = bin_dir.join("alien-operator"); + + let (os, arch) = detect_os_arch()?; + let url = if let Some(url) = embedded_config.and_then(|config| config.agent_binary_url.as_ref()) + { + url.clone() + } else { + let releases_url = std::env::var("ALIEN_RELEASES_URL") + .unwrap_or_else(|_| DEFAULT_RELEASES_URL.to_string()); + format!( + "{}/alien-operator/latest/{}-{}/alien-operator", + releases_url, os, arch + ) + }; + + output::info(&format!("Downloading alien-operator from {}...", url)); + + let response = + reqwest::get(&url) + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to download alien-operator from {}", url), + })?; + + if !response.status().is_success() { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!( + "Failed to download alien-operator: HTTP {}", + response.status() + ), + })); + } + + let bytes = + response + .bytes() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to read alien-operator download response".to_string(), + })?; + + std::fs::write(&binary_path, &bytes) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "write".to_string(), + file_path: binary_path.display().to_string(), + reason: "Failed to write alien-operator binary".to_string(), + })?; + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)) + .into_alien_error() + .context(ErrorData::FileOperationFailed { + operation: "chmod".to_string(), + file_path: binary_path.display().to_string(), + reason: "Failed to make alien-operator executable".to_string(), + })?; + } + + output::success("alien-operator downloaded successfully."); + + Ok(binary_path) +} + +fn detect_os_arch() -> Result<(&'static str, &'static str)> { + let os = if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Unsupported OS: {}", std::env::consts::OS), + })); + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Unsupported architecture: {}", std::env::consts::ARCH), + })); + }; + + Ok((os, arch)) +} diff --git a/crates/alien-deploy-cli/src/commands/up/push.rs b/crates/alien-deploy-cli/src/commands/up/push.rs new file mode 100644 index 000000000..9c5c4681b --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/push.rs @@ -0,0 +1,740 @@ +use super::*; + +pub(super) fn should_print_push_setup_neutral_completion(platform: Platform) -> bool { + platform != Platform::Machines +} + +pub(super) async fn run_push_model( + client: &ServerClient, + deployment_id: &str, + platform: Platform, + base_platform: Option, + manager_url: &str, + deployment_token: &str, + management_config: Option, + network_args: &NetworkArgs, + on_progress: Option, +) -> Result<()> { + let credential_platform = base_platform.unwrap_or(platform); + let client_config = ClientConfig::from_std_env(credential_platform) + .await + .context(ErrorData::ConfigurationError { + message: format!( + "Failed to load {} credentials from environment. Ensure the required environment variables are set.", + credential_platform + ), + })?; + + push_initial_setup( + client, + deployment_id, + platform, + base_platform, + client_config, + management_config, + manager_url, + deployment_token, + Some(network_args), + on_progress, + ) + .await +} + +pub(super) fn apply_external_bindings_from_stack_settings( + config: &mut DeploymentConfig, + stack_settings: &StackSettings, +) { + if let Some(ref external_bindings) = stack_settings.external_bindings { + config.external_bindings = external_bindings.clone(); + } +} + +/// Run the push-model initial setup flow for a deployment. +/// +/// Fetches deployment and release state from the manager, acquires a sync lock, +/// steps the deployment through InitialSetup until it reaches Provisioning (or a +/// terminal state), reconciles state back to the manager, and releases the lock. +/// +/// This is used by both `alien-deploy deploy` (push model) and `alien-test` (e2e setup). +pub async fn push_initial_setup( + client: &ServerClient, + deployment_id: &str, + platform: Platform, + base_platform: Option, + client_config: ClientConfig, + management_config: Option, + manager_base_url: &str, + deployment_token: &str, + network_args: Option<&NetworkArgs>, + on_progress: Option, +) -> Result<()> { + let setup_management_config = management_config.clone(); + + // Get deployment from manager + let deployment = client + .get_deployment() + .id(deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + // Reconstruct DeploymentState from flat API response + let status = parse_deployment_status(&deployment.status)?; + + let stack_state = deployment + .stack_state + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_state from manager".to_string(), + })?; + let environment_info = deployment + .environment_info + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize environment_info from manager".to_string(), + })?; + + // If there's a desired release, fetch the full release info. A failed fetch must fail the + // setup, not silently degrade to a no-release deploy: swallowing it would report success while + // having installed nothing the caller asked for. + let target_release = if let Some(ref release_id) = deployment.desired_release_id { + let resp = client + .get_release() + .id(release_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to fetch desired release {release_id} from manager"), + })?; + let rel = resp.into_inner(); + let platform_stack_value = release_stack_value_for_platform(rel.stack, platform) + .ok_or_else(|| { + AlienError::new(ErrorData::ConfigurationError { + message: format!( + "Release {} has no stack for platform {}", + release_id, + platform.as_str() + ), + }) + })?; + + // No stack rewriting — release already stores proxy URIs. + // Controllers use image URIs as-is. + let stack = serde_json::from_value(platform_stack_value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to parse release stack".to_string(), + })?; + + Some(ReleaseInfo { + release_id: Some(rel.id), + version: None, + description: None, + stack, + }) + } else { + None + }; + + let mut state = DeploymentState { + status, + platform, + current_release: None, + target_release, + stack_state, + error: None, + environment_info, + runtime_metadata: None, + retry_requested: deployment.retry_requested, + protocol_version: alien_core::DEPLOYMENT_PROTOCOL_VERSION, + }; + + // Always override environment_info with the target client_config. + // The manager may have already run the Pending step with management + // credentials, setting environment_info to the management project. + // push_initial_setup runs with *target* credentials, so re-collecting + // ensures the environment_info reflects the actual target project. + let environment_platform = base_platform.unwrap_or(platform); + // Fail fast rather than proceed with absent/stale environment info: a setup that silently drops + // the target environment would report success while the deployment's env_info is wrong. Wrap in + // DeploymentFailed (retryable/internal = inherit), not the hard-non-retryable ConfigurationError, + // so a transient cloud blip in collect_environment_info (live STS / project-metadata calls) stays + // retryable instead of becoming a permanent setup failure. + if should_collect_push_setup_environment_info(environment_platform) { + let env_info = + alien_deployment::collect_environment_info(environment_platform, &client_config) + .await + .context(ErrorData::DeploymentFailed { + operation: "target environment-info collection".to_string(), + })?; + state.environment_info = Some(env_info); + } else { + state.environment_info = None; + } + + // Reconstruct DeploymentConfig from stack_settings + let mut stack_settings: StackSettings = deployment + .stack_settings + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_settings from manager".to_string(), + })? + .unwrap_or_default(); + + // Override network settings if the customer provided CLI flags + if let Some(net_args) = network_args { + let network_platform = base_platform.unwrap_or(platform); + let network_override = network::parse_network_settings(net_args, network_platform.as_str()) + .map_err(|e| { + AlienError::new(ErrorData::ValidationError { + field: "network".to_string(), + message: e, + }) + })?; + if let Some(ns) = network_override { + stack_settings.network = Some(ns); + } + } + + // Build a minimal config JSON and deserialize to get proper defaults + let mut config: DeploymentConfig = serde_json::from_value(serde_json::json!({ + "stackSettings": serde_json::to_value(&stack_settings).unwrap_or_default(), + "managementConfig": serde_json::to_value(&management_config).unwrap_or_default(), + "environmentVariables": { + "variables": [], + "hash": "", + "createdAt": "" + } + })) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to construct deployment config".to_string(), + })?; + + // Set manager URL and deployment token so controllers can configure + // pull auth (RegistryCredentials, imagePullSecrets) for the manager's registry. + config.manager_url = Some(manager_base_url.to_string()); + config.deployment_token = Some(deployment_token.to_string()); + config.base_platform = base_platform; + + apply_external_bindings_from_stack_settings(&mut config, &stack_settings); + + // Acquire sync lock — retry until the specific deployment is locked by us. + // The manager's deployment loop may already hold the lock; we must wait for + // it to release before proceeding. 2 minutes (60 × 2s) is sufficient because + // the manager skips Pending/InitialSetup for push-mode deployments — if it + // holds the lock, it checks push-mode + Pending and releases immediately. + let session = format!("push-setup-{}", uuid::Uuid::new_v4()); + let acquired_deployment = acquire_setup_run_deployment( + client, + deployment_id, + &session, + stack_settings.deployment_model, + ) + .await + .context(ErrorData::DeploymentFailed { + operation: "acquire sync lock".to_string(), + })?; + + if let Some(acquired_config) = acquired_deployment.get("deploymentConfig").cloned() { + config = serde_json::from_value(acquired_config) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize deploymentConfig from acquired deployment" + .to_string(), + })?; + + if let Some(net_args) = network_args { + let network_platform = base_platform.unwrap_or(platform); + let network_override = + network::parse_network_settings(net_args, network_platform.as_str()).map_err( + |e| { + AlienError::new(ErrorData::ValidationError { + field: "network".to_string(), + message: e, + }) + }, + )?; + if let Some(ns) = network_override { + config.stack_settings.network = Some(ns); + } + } + + config.manager_url = Some(manager_base_url.to_string()); + config.deployment_token = Some(deployment_token.to_string()); + config.management_config = setup_management_config.clone(); + config.base_platform = base_platform.or(config.base_platform); + let acquired_stack_settings = config.stack_settings.clone(); + apply_external_bindings_from_stack_settings(&mut config, &acquired_stack_settings); + } + + // Re-fetch the deployment state now that we hold the lock. + // The manager may have advanced the state while we were waiting. + let deployment = client + .get_deployment() + .id(deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + let status = parse_deployment_status(&deployment.status)?; + + state.status = status; + state.stack_state = deployment + .stack_state + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_state from manager".to_string(), + })?; + state.runtime_metadata = deployment + .runtime_metadata + .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize runtime_metadata from manager".to_string(), + })?; + + tracing::info!( + has_runtime_metadata = state.runtime_metadata.is_some(), + "push_initial_setup: state after re-fetch (before step loop)" + ); + + // Run the shared step loop with per-step reconciliation via the manager API + let transport = ManagerApiTransport::new(client.clone(), session.clone()); + let policy = RunnerPolicy { + max_steps: 400, + // Push model: run initial setup only, then hand off to the manager. + // The CLI drives Pending → InitialSetup → Provisioning, then stops. + // The manager picks up from Provisioning and drives to Running. + operation: LoopOperation::InitialSetup, + delay_threshold: None, + }; + + let runner_result = shared_run_step_loop( + &mut state, + &mut config, + &client_config, + deployment_id, + &policy, + &transport, + None, + on_progress.as_ref(), + ) + .await; + + // Always reconcile + release, even on error. + final_reconcile(client, deployment_id, &session, &state).await; + release_deployment(client, deployment_id, &session).await; + + // Handle runner result after lock release + let result = runner_result.context(ErrorData::DeploymentFailed { + operation: "initial setup".to_string(), + })?; + + match result.loop_result.outcome { + LoopOutcome::Success => { + output::success("Deployment is running."); + Ok(()) + } + LoopOutcome::Failure => Err(AlienError::new(ErrorData::DeploymentFailed { + operation: format!( + "deployment failed at status {}", + deployment_status_str(result.loop_result.final_status) + ), + })), + LoopOutcome::Neutral => { + if should_print_push_setup_neutral_completion(platform) { + output::success( + "Setup complete. Your deployment is being provisioned and will be ready shortly.", + ); + } + Ok(()) + } + } +} + +pub(super) fn should_collect_push_setup_environment_info(platform: Platform) -> bool { + !matches!(platform, Platform::Machines) +} + +/// Run the push-model deletion flow for a deployment. +/// +/// Fetches deployment and release state from the manager, acquires a sync lock, +/// steps the deployment through DeletePending → Deleting → Deleted (or DeleteFailed), +/// reconciles state back to the manager, and releases the lock. +pub async fn push_deletion( + client: &ServerClient, + deployment_id: &str, + platform: Platform, + client_config: ClientConfig, +) -> Result<()> { + let deployment = client + .get_deployment() + .id(deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + let status = parse_deployment_status(&deployment.status)?; + + let stack_state = deployment + .stack_state + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_state from manager".to_string(), + })?; + let environment_info = deployment + .environment_info + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize environment_info from manager".to_string(), + })?; + let runtime_metadata = deployment + .runtime_metadata + .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize runtime_metadata from manager".to_string(), + })?; + + let current_release = if let Some(ref release_id) = deployment.current_release_id { + match client.get_release().id(release_id).send().await { + Ok(resp) => { + let rel = resp.into_inner(); + let platform_stack_value = release_stack_value_for_platform(rel.stack, platform); + platform_stack_value + .and_then(|v| serde_json::from_value(v).ok()) + .map(|stack| ReleaseInfo { + release_id: Some(rel.id), + version: None, + description: None, + stack, + }) + } + Err(_) => None, + } + } else { + None + }; + + let mut state = DeploymentState { + status, + platform, + current_release: current_release.clone(), + target_release: current_release, + stack_state, + error: None, + environment_info, + runtime_metadata, + retry_requested: deployment.retry_requested, + protocol_version: alien_core::DEPLOYMENT_PROTOCOL_VERSION, + }; + + let stack_settings: StackSettings = deployment + .stack_settings + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_settings from manager".to_string(), + })? + .unwrap_or_default(); + + let mut config: DeploymentConfig = serde_json::from_value(serde_json::json!({ + "stackSettings": serde_json::to_value(&stack_settings).unwrap_or_default(), + "environmentVariables": { + "variables": [], + "hash": "", + "createdAt": "" + } + })) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to construct deployment config".to_string(), + })?; + + apply_external_bindings_from_stack_settings(&mut config, &stack_settings); + let service_provider = runtime_service_provider(&client_config)?; + + if platform == Platform::Local + && !matches!( + state.status, + DeploymentStatus::TeardownRequired | DeploymentStatus::TeardownFailed + ) + { + run_runtime_deletion( + client, + deployment_id, + &mut state, + &mut config, + &client_config, + stack_settings.deployment_model, + service_provider.clone(), + ) + .await?; + + if state.status == DeploymentStatus::Deleted { + output::success("Deployment deleted successfully."); + return Ok(()); + } + } + + run_setup_deletion( + client, + deployment_id, + &mut state, + &mut config, + &client_config, + stack_settings.deployment_model, + service_provider, + ) + .await +} + +fn runtime_service_provider( + client_config: &ClientConfig, +) -> Result>> { + let ClientConfig::Local { state_directory } = client_config else { + return Ok(None); + }; + + let local_bindings = alien_local::LocalBindingsProvider::new(Path::new(state_directory)) + .context(ErrorData::ConfigurationError { + message: format!( + "Failed to create local runtime provider from '{}'", + state_directory + ), + })?; + + Ok(Some(Arc::new( + alien_infra::DefaultPlatformServiceProvider::with_local_bindings(local_bindings), + ))) +} + +async fn run_runtime_deletion( + client: &ServerClient, + deployment_id: &str, + state: &mut DeploymentState, + config: &mut DeploymentConfig, + client_config: &ClientConfig, + deployment_model: alien_core::DeploymentModel, + service_provider: Option>, +) -> Result<()> { + let session = format!("push-runtime-deletion-{}", uuid::Uuid::new_v4()); + acquire_runtime_delete_deployment(client, deployment_id, &session, deployment_model) + .await + .context(ErrorData::DeploymentFailed { + operation: "acquire runtime deletion lock".to_string(), + })?; + + // Re-fetch deployment under lock + let deployment = client + .get_deployment() + .id(deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + let status = parse_deployment_status(&deployment.status)?; + state.status = status; + state.stack_state = deployment + .stack_state + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_state from manager".to_string(), + })?; + state.runtime_metadata = deployment + .runtime_metadata + .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize runtime_metadata from manager".to_string(), + })?; + + let transport = ManagerApiTransport::new(client.clone(), session.clone()); + let policy = RunnerPolicy { + max_steps: 400, + operation: LoopOperation::Delete, + delay_threshold: None, + }; + + let runner_result = shared_run_step_loop( + state, + config, + client_config, + deployment_id, + &policy, + &transport, + service_provider, + None, + ) + .await; + + // Always reconcile + release, even on error + final_reconcile(client, deployment_id, &session, state).await; + release_deployment(client, deployment_id, &session).await; + + // Handle runner result after lock release + let result = runner_result.context(ErrorData::DeploymentFailed { + operation: "deletion".to_string(), + })?; + + match result.loop_result.outcome { + LoopOutcome::Success => Ok(()), + LoopOutcome::Failure => { + let operation = format!( + "deletion failed at status {}", + deployment_status_str(result.loop_result.final_status) + ); + if let Some(error) = state.error.clone() { + Err(error.context(ErrorData::DeploymentFailed { operation })) + } else { + Err(AlienError::new(ErrorData::DeploymentFailed { operation })) + } + } + LoopOutcome::Neutral => Ok(()), + } +} + +async fn run_setup_deletion( + client: &ServerClient, + deployment_id: &str, + state: &mut DeploymentState, + config: &mut DeploymentConfig, + client_config: &ClientConfig, + deployment_model: alien_core::DeploymentModel, + service_provider: Option>, +) -> Result<()> { + let session = format!("push-setup-deletion-{}", uuid::Uuid::new_v4()); + let acquire_outcome = + acquire_setup_delete_deployment(client, deployment_id, &session, deployment_model) + .await + .context(ErrorData::DeploymentFailed { + operation: "acquire setup teardown lock".to_string(), + })?; + + if matches!(acquire_outcome, SetupDeleteAcquireOutcome::AlreadyDeleted) { + output::success("Deployment deleted successfully."); + return Ok(()); + } + + // Re-fetch deployment under lock + let deployment = client + .get_deployment() + .id(deployment_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: "Failed to get deployment from manager".to_string(), + })? + .into_inner(); + + let status = parse_deployment_status(&deployment.status)?; + state.status = status; + state.stack_state = deployment + .stack_state + .map(serde_json::from_value) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize stack_state from manager".to_string(), + })?; + state.runtime_metadata = deployment + .runtime_metadata + .map(|rm| serde_json::to_value(rm).and_then(serde_json::from_value)) + .transpose() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to deserialize runtime_metadata from manager".to_string(), + })?; + + let transport = ManagerApiTransport::new(client.clone(), session.clone()); + let policy = RunnerPolicy { + max_steps: 400, + operation: LoopOperation::Delete, + delay_threshold: None, + }; + + let runner_result = alien_deployment::setup_teardown::run_setup_teardown_after_handoff( + state, + config, + client_config, + deployment_id, + &policy, + &transport, + service_provider, + ) + .await + .map(|setup_result| { + setup_result.unwrap_or_else(|| RunnerResult { + loop_result: LoopResult { + stop_reason: LoopStopReason::Synced, + outcome: LoopOutcome::Neutral, + final_status: state.status, + }, + steps_executed: 0, + }) + }); + + // Always reconcile + release, even on error + final_reconcile(client, deployment_id, &session, state).await; + release_deployment(client, deployment_id, &session).await; + + let result = runner_result.context(ErrorData::DeploymentFailed { + operation: "setup teardown".to_string(), + })?; + + match result.loop_result.outcome { + LoopOutcome::Success => { + output::success("Deployment deleted successfully."); + Ok(()) + } + LoopOutcome::Failure => { + let operation = format!( + "setup teardown failed at status {}", + deployment_status_str(result.loop_result.final_status) + ); + if let Some(error) = state.error.clone() { + Err(error.context(ErrorData::DeploymentFailed { operation })) + } else { + Err(AlienError::new(ErrorData::DeploymentFailed { operation })) + } + } + LoopOutcome::Neutral => Ok(()), + } +} diff --git a/crates/alien-deploy-cli/src/commands/up/resolve.rs b/crates/alien-deploy-cli/src/commands/up/resolve.rs new file mode 100644 index 000000000..0f05fd6e3 --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/resolve.rs @@ -0,0 +1,489 @@ +use super::*; + +/// Resolved deployment info before manager connection. +pub(super) struct ResolvedInfo { + pub(super) token: String, + /// Manager URL (from override, tracker, or to be discovered via platform API). + pub(super) manager_url: Option, + /// Platform API base URL used when manager URL must be discovered. + pub(super) base_url: String, + pub(super) platform: String, + pub(super) base_platform: Option, + pub(super) name: String, +} + +pub(super) fn requires_install_context(platform: Platform) -> bool { + matches!(platform, Platform::Aws | Platform::Gcp | Platform::Azure) +} + +pub(super) fn release_stack_value_for_platform( + stack: alien_manager_api::types::StackByPlatform, + platform: Platform, +) -> Option { + match platform { + Platform::Aws => stack.aws, + Platform::Gcp => stack.gcp, + Platform::Azure => stack.azure, + Platform::Kubernetes => stack.kubernetes, + Platform::Machines => stack.machines, + Platform::Local => stack.local, + Platform::Test => stack.test, + } +} + +pub(super) async fn fetch_release_stack_by_id( + client: &ServerClient, + release_id: &str, + platform: Platform, +) -> Result { + let release = client + .get_release() + .id(release_id) + .send() + .await + .into_sdk_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to fetch release '{release_id}' from manager"), + })? + .into_inner(); + let stack_value = + release_stack_value_for_platform(release.stack, platform).ok_or_else(|| { + AlienError::new(ErrorData::ConfigurationError { + message: format!( + "Release '{}' has no stack for platform {}", + release_id, + platform.as_str() + ), + }) + })?; + + serde_json::from_value(stack_value) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: format!("Failed to parse release stack from release '{release_id}'"), + }) +} + +pub(super) fn validate_public_endpoint_names( + public_endpoints: &PublicEndpointUrls, + stack: &Stack, +) -> Result<()> { + let valid_endpoints = public_endpoint_names(stack); + for (resource_id, endpoints) in public_endpoints { + for endpoint_name in endpoints.keys() { + let key = format!("{resource_id}.{endpoint_name}"); + if valid_endpoints.contains(&key) { + continue; + } + + let available = if valid_endpoints.is_empty() { + "none".to_string() + } else { + valid_endpoints + .iter() + .cloned() + .collect::>() + .join(", ") + }; + return Err(AlienError::new(ErrorData::ValidationError { + field: "public-endpoint".to_string(), + message: format!( + "Endpoint '{key}' is not declared by the stack. Available public endpoints: {available}" + ), + })); + } + } + Ok(()) +} + +fn public_endpoint_names(stack: &Stack) -> BTreeSet { + stack + .resources() + .flat_map(|(resource_id, entry)| { + if let Some(daemon) = entry.config.downcast_ref::() { + return daemon + .public_endpoints + .iter() + .map(|endpoint| format!("{resource_id}.{}", endpoint.name)) + .collect::>(); + } + if let Some(container) = entry.config.downcast_ref::() { + return container + .public_endpoints + .iter() + .map(|endpoint| format!("{resource_id}.{}", endpoint.name)) + .collect::>(); + } + if let Some(worker) = entry.config.downcast_ref::() { + return worker + .public_endpoints + .iter() + .map(|endpoint| format!("{resource_id}.{}", endpoint.name)) + .collect::>(); + } + Vec::new() + }) + .collect() +} + +pub(super) fn parse_base_platform( + platform: Platform, + base_platform: Option<&str>, +) -> Result> { + let Some(base_platform) = base_platform else { + return Ok(None); + }; + + let parsed = Platform::from_str(base_platform).map_err(|e| { + AlienError::new(ErrorData::ValidationError { + field: "base-platform".to_string(), + message: e, + }) + })?; + + if platform != Platform::Kubernetes { + return Err(AlienError::new(ErrorData::ValidationError { + field: "base-platform".to_string(), + message: "--base-platform is only supported with --platform kubernetes".to_string(), + })); + } + + match parsed { + Platform::Aws | Platform::Gcp | Platform::Azure => Ok(Some(parsed)), + Platform::Kubernetes | Platform::Machines | Platform::Local | Platform::Test => { + Err(AlienError::new(ErrorData::ValidationError { + field: "base-platform".to_string(), + message: "--base-platform must be one of: aws, gcp, azure".to_string(), + })) + } + } +} + +pub(super) fn resolve_deployment_info( + args: &UpArgs, + embedded_config: Option<&DeployCliConfig>, + deploy_config: Option<&DeployConfigFile>, +) -> Result { + // If name is provided, try to load from tracker + let requested_name = args + .name + .as_ref() + .or_else(|| deploy_config.and_then(|c| c.name.as_ref())); + if let Some(name) = requested_name { + let tracker = DeploymentTracker::new()?; + if let Some(tracked) = tracker.get(name) { + let token = + resolve_token(args, embedded_config).unwrap_or_else(|_| tracked.token.clone()); + let manager_url = args + .manager_url + .clone() + .or(Some(tracked.manager_url.clone())); + let platform = args + .platform + .clone() + .or_else(|| deploy_config.and_then(|c| c.platform.clone())) + .unwrap_or_else(|| tracked.platform.clone()); + let base_platform = args + .base_platform + .clone() + .or_else(|| deploy_config.and_then(|c| c.base_platform.clone())); + return Ok(ResolvedInfo { + token, + manager_url, + base_url: resolve_base_url(args, embedded_config), + platform, + base_platform, + name: name.clone(), + }); + } + } + + // CLI args override embedded config, which overrides nothing (required) + let token = resolve_token(args, embedded_config)?; + + // Manager URL: explicit override only. If not set, will be discovered via platform API. + let manager_url = args.manager_url.clone(); + + let platform = args + .platform + .clone() + .or_else(|| deploy_config.and_then(|c| c.platform.clone())) + .or_else(|| embedded_config.and_then(|c| c.default_platform.clone())) + .ok_or_else(|| { + AlienError::new(ErrorData::ValidationError { + field: "platform".to_string(), + message: + "--platform is required for new deployments. Choose from: aws, gcp, azure, kubernetes, machines, local." + .to_string(), + }) + })?; + let base_platform = args + .base_platform + .clone() + .or_else(|| deploy_config.and_then(|c| c.base_platform.clone())); + + let name = match args.name.clone() { + Some(n) => n, + None => match deploy_config.and_then(|c| c.name.clone()) { + Some(n) => n, + None if platform == "local" => hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "default".to_string()), + None => { + return Err(AlienError::new(ErrorData::ValidationError { + field: "name".to_string(), + message: "--name or config field `name` is required for non-local deployments." + .to_string(), + })); + } + }, + }; + + Ok(ResolvedInfo { + token, + manager_url, + base_url: resolve_base_url(args, embedded_config), + platform, + base_platform, + name, + }) +} + +pub(crate) fn resolve_optional_token( + token: Option, + token_file: Option<&PathBuf>, + embedded_config: Option<&DeployCliConfig>, +) -> Result> { + Ok(token + .map(Ok) + .or_else(|| token_file.map(|path| read_token_file(path))) + .transpose()? + .or_else(|| { + embedded_config + .and_then(|c| c.token_env_var.as_ref()) + .and_then(|env_var| std::env::var(env_var).ok()) + }) + .or_else(|| embedded_config.and_then(|c| c.token.clone()))) +} + +pub(super) fn resolve_token( + args: &UpArgs, + embedded_config: Option<&DeployCliConfig>, +) -> Result { + resolve_optional_token(args.token.clone(), args.token_file.as_ref(), embedded_config)? + .ok_or_else(|| { + let branded_hint = embedded_config + .and_then(|c| c.token_env_var.as_deref()) + .map(|env_var| format!(" or set ${env_var}")) + .unwrap_or_default(); + AlienError::new(ErrorData::ValidationError { + field: "token".to_string(), + message: format!( + "--token is required for new deployments{branded_hint}. Use the deployment token from the deploy page." + ), + }) + }) +} + +pub(crate) fn read_token_file(path: &Path) -> Result { + let token = std::fs::read_to_string(path).into_alien_error().context( + ErrorData::ConfigurationError { + message: format!("Failed to read token file {}", path.display()), + }, + )?; + let token = token.trim().to_string(); + if token.is_empty() { + return Err(AlienError::new(ErrorData::ValidationError { + field: "token-file".to_string(), + message: format!("Token file {} is empty", path.display()), + })); + } + Ok(token) +} + +pub(crate) fn resolve_base_url_option( + base_url: Option<&String>, + embedded_config: Option<&DeployCliConfig>, +) -> String { + base_url + .cloned() + .or_else(|| embedded_config.and_then(|c| c.api_base_url.clone())) + .unwrap_or_else(|| "https://api.alien.dev".to_string()) +} + +fn resolve_base_url(args: &UpArgs, embedded_config: Option<&DeployCliConfig>) -> String { + resolve_base_url_option(args.base_url.as_ref(), embedded_config) +} + +pub(crate) fn resolve_platform_option( + platform: Option<&String>, + embedded_config: Option<&DeployCliConfig>, + command: &str, +) -> Result { + platform + .cloned() + .or_else(|| embedded_config.and_then(|c| c.default_platform.clone())) + .ok_or_else(|| { + AlienError::new(ErrorData::ValidationError { + field: "platform".to_string(), + message: format!( + "--platform is required for {command} when --manager-url is not set and the binary has no embedded default platform." + ), + }) + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeploymentInfoResponse { + pub(super) setup_config: Option, + pub(super) readiness: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeploymentInfoSetupConfig { + pub(super) inputs: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeploymentReadiness { + pub(super) status: String, + pub(super) checks: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeploymentReadinessCheck { + pub(super) code: String, + pub(super) status: String, + pub(super) message: String, +} + +pub(super) async fn fetch_deployment_info( + base_url: &str, + token: &str, + platform: Platform, +) -> Result { + let http_client = { + use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; + + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", token)) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Invalid token format".to_string(), + })?, + ); + headers.insert(USER_AGENT, HeaderValue::from_static("alien-deploy-cli")); + + reqwest::Client::builder() + .default_headers(headers) + .build() + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to build HTTP client".to_string(), + })? + }; + + let mut url = reqwest::Url::parse(&format!( + "{}/v1/deployment-info", + base_url.trim_end_matches('/') + )) + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Invalid platform API base URL".to_string(), + })?; + url.query_pairs_mut() + .append_pair("platform", platform.as_str()); + let response = http_client + .get(url) + .send() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to fetch deployment info from platform API".to_string(), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!("Failed to fetch deployment info (HTTP {status}): {body}"), + })); + } + + response + .json() + .await + .into_alien_error() + .context(ErrorData::ConfigurationError { + message: "Failed to parse deployment info response".to_string(), + }) +} + +pub(super) fn deployer_inputs_from_info( + info: &DeploymentInfoResponse, + platform: Platform, +) -> Vec { + info.setup_config + .as_ref() + .and_then(|setup_config| setup_config.inputs.clone()) + .unwrap_or_default() + .into_iter() + .filter(|input| stack_input_matches_context(input, platform)) + .collect() +} + +pub(super) fn validate_deployment_readiness( + info: &DeploymentInfoResponse, + platform: Platform, +) -> Result<()> { + let Some(readiness) = &info.readiness else { + return Ok(()); + }; + if readiness.status == "notReady" { + let failures = readiness + .checks + .iter() + .filter(|check| check.status == "failed") + .map(|check| format!("{}: {}", check.code, check.message)) + .collect::>() + .join("; "); + return Err(AlienError::new(ErrorData::ConfigurationError { + message: format!( + "{} deployments are not ready: {failures}", + platform.as_str() + ), + })); + } + for check in readiness + .checks + .iter() + .filter(|check| check.status == "unknown") + { + output::warn(&format!( + "Readiness unknown ({}): {}", + check.code, check.message + )); + } + Ok(()) +} + +pub(crate) async fn resolve_manager_url_option( + manager_url: Option, + base_url: &str, + token: &str, + platform: &str, +) -> Result { + if let Some(manager_url) = manager_url { + return Ok(manager_url); + } + + discover_manager_install_context(base_url, token, platform) + .await + .map(|context| context.manager_url) +} diff --git a/crates/alien-deploy-cli/src/commands/up/tests.rs b/crates/alien-deploy-cli/src/commands/up/tests.rs new file mode 100644 index 000000000..b8d4040db --- /dev/null +++ b/crates/alien-deploy-cli/src/commands/up/tests.rs @@ -0,0 +1,714 @@ +use super::*; +use clap::Parser; +use std::io::Write; + +#[test] +fn cloud_push_platforms_require_install_context() { + assert!(requires_install_context(Platform::Aws)); + assert!(requires_install_context(Platform::Gcp)); + assert!(requires_install_context(Platform::Azure)); +} + +#[test] +fn pull_model_platforms_do_not_require_install_context() { + assert!(!requires_install_context(Platform::Kubernetes)); + assert!(!requires_install_context(Platform::Machines)); + assert!(!requires_install_context(Platform::Local)); + assert!(!requires_install_context(Platform::Test)); +} + +#[test] +fn deployment_readiness_not_ready_blocks_with_check_codes() { + let info = DeploymentInfoResponse { + setup_config: None, + readiness: Some(DeploymentReadiness { + status: "notReady".to_string(), + checks: vec![ + DeploymentReadinessCheck { + code: "MACHINE_BUNDLE_ARTIFACTS".to_string(), + status: "failed".to_string(), + message: "Machine bundle manifest is missing linux-x64.".to_string(), + }, + DeploymentReadinessCheck { + code: "MANAGER_ACQUIRES_PLATFORM".to_string(), + status: "passed".to_string(), + message: "A machines-capable manager is ready.".to_string(), + }, + ], + }), + }; + + let error = validate_deployment_readiness(&info, Platform::Machines) + .expect_err("notReady readiness must block the deploy"); + assert!(error.message.contains("machines deployments are not ready")); + assert!(error.message.contains("MACHINE_BUNDLE_ARTIFACTS")); + assert!(!error.message.contains("MANAGER_ACQUIRES_PLATFORM")); +} + +#[test] +fn deployment_readiness_unknown_checks_do_not_block() { + let info = DeploymentInfoResponse { + setup_config: None, + readiness: Some(DeploymentReadiness { + status: "unknown".to_string(), + checks: vec![DeploymentReadinessCheck { + code: "MACHINES_HORIZON_CONTROL_PLANE_REACHABLE".to_string(), + status: "unknown".to_string(), + message: "Horizon control plane reachability could not be confirmed.".to_string(), + }], + }), + }; + + validate_deployment_readiness(&info, Platform::Machines) + .expect("unknown readiness must not block the deploy"); +} + +#[test] +fn absent_readiness_does_not_block() { + let info = DeploymentInfoResponse { + setup_config: None, + readiness: None, + }; + + validate_deployment_readiness(&info, Platform::Machines) + .expect("absent readiness document must not block the deploy"); +} + +#[test] +fn machines_join_guidance_uses_install_script_when_available() { + assert_eq!( + machines_join_command( + "acmectl", + Some("https://packages.example.com/ws/prj/install.sh"), + "aj1_wrapped-token" + ), + "curl -fsSL 'https://packages.example.com/ws/prj/install.sh' | sudo bash -s -- join --token 'aj1_wrapped-token'" + ); +} + +#[test] +fn machines_join_guidance_shell_quotes_install_script_and_token() { + assert_eq!( + machines_join_command( + "alien-deploy", + Some("https://packages.example.com/ws/prj/install script.sh"), + "aj1_token'with-quote" + ), + "curl -fsSL 'https://packages.example.com/ws/prj/install script.sh' | sudo bash -s -- join --token 'aj1_token'\"'\"'with-quote'" + ); +} + +#[test] +fn machines_join_guidance_falls_back_to_installed_cli_without_install_script() { + assert_eq!( + machines_join_command("acmectl", None, "aj1_wrapped-token"), + "sudo acmectl join --token 'aj1_wrapped-token'" + ); +} + +#[test] +fn machines_join_token_response_preserves_wrapped_token() { + let token = normalize_machines_join_token_response(MachinesJoinTokenResponse { + join_token: " aj1_wrapped-token ".to_string(), + control_plane_url: Some("https://horizon.example.com".to_string()), + cluster_id: Some("cluster-123".to_string()), + }) + .expect("wrapped token should be accepted"); + + assert_eq!(token, "aj1_wrapped-token"); +} + +#[test] +fn machines_join_token_response_wraps_raw_token_with_context() { + let token = normalize_machines_join_token_response(MachinesJoinTokenResponse { + join_token: " hj_secret ".to_string(), + control_plane_url: Some(" https://horizon.example.com ".to_string()), + cluster_id: Some(" cluster-123 ".to_string()), + }) + .expect("raw token with context should be wrapped"); + + assert!(token.starts_with("aj1_")); + let payload = URL_SAFE_NO_PAD + .decode(token.trim_start_matches("aj1_")) + .expect("wrapped token should be base64url"); + let payload: serde_json::Value = + serde_json::from_slice(&payload).expect("wrapped token should be JSON"); + assert_eq!(payload["joinToken"], "hj_secret"); + assert_eq!(payload["controlPlaneUrl"], "https://horizon.example.com"); + assert_eq!(payload["clusterId"], "cluster-123"); +} + +#[test] +fn machines_join_token_response_rejects_raw_token_without_context() { + let error = normalize_machines_join_token_response(MachinesJoinTokenResponse { + join_token: "hj_secret".to_string(), + control_plane_url: None, + cluster_id: Some("cluster-123".to_string()), + }) + .expect_err("raw token should require context"); + + assert_eq!(error.code, "CONFIGURATION_ERROR"); + assert!(error.message.contains("without control plane context")); +} + +#[test] +fn machines_deploy_prints_only_join_command() { + assert!(!should_print_deploy_progress(Platform::Machines)); +} + +#[test] +fn machines_push_setup_suppresses_neutral_completion_message() { + assert!(!should_print_push_setup_neutral_completion( + Platform::Machines + )); + assert!(should_print_push_setup_neutral_completion(Platform::Aws)); +} + +#[test] +fn machines_push_setup_does_not_collect_cloud_environment() { + assert!(!should_collect_push_setup_environment_info( + Platform::Machines + )); + assert!(should_collect_push_setup_environment_info(Platform::Aws)); + assert!(should_collect_push_setup_environment_info( + Platform::Kubernetes + )); +} + +#[test] +fn non_machines_deploy_prints_progress() { + assert!(should_print_deploy_progress(Platform::Aws)); + assert!(should_print_deploy_progress(Platform::Local)); +} + +#[test] +fn load_stack_settings_omits_server_owned_deployment_model() { + let args = UpArgs::parse_from(["alien-deploy", "--platform", "machines"]); + let settings = + load_stack_settings(&args, Platform::Machines, None).expect("settings should load"); + + let wire = serde_json::to_value(settings).expect("settings should serialize"); + assert_eq!(wire.get("deploymentModel"), None); +} + +#[test] +fn load_stack_settings_requests_pull_for_local() { + let args = UpArgs::parse_from(["alien-deploy", "--platform", "local"]); + let settings = load_stack_settings(&args, Platform::Local, None).expect("settings should load"); + + assert_eq!(settings.deployment_model, DeploymentModel::Pull); + let wire = serde_json::to_value(sdk_stack_settings(&settings).expect("sdk settings")) + .expect("settings should serialize"); + assert_eq!( + wire.get("deploymentModel"), + Some(&serde_json::json!("pull")) + ); +} + +#[test] +fn sdk_stack_settings_serializes_explicit_deployment_model() { + let settings = StackSettings { + deployment_model: DeploymentModel::Pull, + ..StackSettings::default() + }; + + let wire = serde_json::to_value( + sdk_stack_settings(&settings).expect("settings should convert to SDK type"), + ) + .expect("settings should serialize"); + + assert_eq!( + wire.get("deploymentModel"), + Some(&serde_json::json!("pull")) + ); +} + +#[test] +fn local_tracking_uses_service_data_dir_by_default() { + let args = UpArgs::parse_from(["alien-deploy", "--platform", "local"]); + let local = local_tracking_metadata(&args, Platform::Local) + .expect("local deployments should be tracked with local metadata"); + + assert_eq!( + local.data_dir, + crate::commands::operator::default_service_data_dir() + ); + assert!(local.service_managed); +} + +#[test] +fn local_tracking_uses_foreground_data_dir() { + let args = UpArgs::parse_from([ + "alien-deploy", + "--platform", + "local", + "--foreground", + "--data-dir", + "/tmp/alien-foreground-state", + ]); + let local = local_tracking_metadata(&args, Platform::Local) + .expect("local deployments should be tracked with local metadata"); + + assert_eq!(local.data_dir, "/tmp/alien-foreground-state"); + assert!(!local.service_managed); +} + +#[test] +fn stack_settings_external_bindings_are_copied_to_deployment_config() { + let mut external_bindings = alien_core::ExternalBindings::new(); + external_bindings.insert( + "storage", + alien_core::ExternalBinding::Storage(alien_core::StorageBinding::s3("test-bucket")), + ); + let stack_settings = StackSettings { + external_bindings: Some(external_bindings), + ..StackSettings::default() + }; + let mut config = DeploymentConfig::builder() + .stack_settings(stack_settings.clone()) + .environment_variables(alien_core::EnvironmentVariablesSnapshot { + variables: vec![], + hash: String::new(), + created_at: String::new(), + }) + .external_bindings(alien_core::ExternalBindings::default()) + .allow_frozen_changes(false) + .build(); + + assert!(!config.external_bindings.has("storage")); + apply_external_bindings_from_stack_settings(&mut config, &stack_settings); + + assert!(config.external_bindings.has("storage")); +} + +#[test] +fn deploy_config_file_accepts_public_endpoints() { + let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); + writeln!( + file, + r#" + name = "local-gateway" + platform = "local" + + [publicEndpoints.gateway] + api = "https://gateway.example.test" + "# + ) + .expect("config should be written"); + + let args = UpArgs::parse_from([ + "alien-deploy", + "--config", + file.path().to_str().expect("temp path should be UTF-8"), + ]); + + let config = load_deploy_config(&args) + .expect("config should load") + .expect("config should exist"); + + assert_eq!(config.name.as_deref(), Some("local-gateway")); + assert_eq!( + config + .public_endpoints + .as_ref() + .and_then(|resources| resources.get("gateway")) + .and_then(|endpoints| endpoints.get("api")) + .map(String::as_str), + Some("https://gateway.example.test") + ); +} + +#[test] +fn public_endpoint_flag_overrides_config_public_endpoint() { + let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); + writeln!( + file, + r#" +platform = "local" + +[publicEndpoints.gateway] +api = "https://old.example.test" +"# + ) + .expect("config should be written"); + + let args = UpArgs::parse_from([ + "alien-deploy", + "--config", + file.path().to_str().expect("temp path should be UTF-8"), + "--public-endpoint", + "gateway.api=https://new.example.test", + ]); + let config = load_deploy_config(&args) + .expect("config should load") + .expect("config should exist"); + let public_endpoints = load_public_endpoints(&args, Platform::Local, Some(&config)) + .expect("public endpoints should load") + .expect("public endpoints should exist"); + + assert_eq!( + public_endpoints + .get("gateway") + .and_then(|endpoints| endpoints.get("api")) + .map(String::as_str), + Some("https://new.example.test") + ); +} + +#[test] +fn public_endpoint_flag_rejects_cloud_platforms() { + let args = UpArgs::parse_from([ + "alien-deploy", + "--platform", + "aws", + "--public-endpoint", + "gateway.api=https://gateway.example.test", + ]); + + let error = + load_public_endpoints(&args, Platform::Aws, None).expect_err("aws should be rejected"); + assert_eq!(error.code, "VALIDATION_ERROR"); +} + +#[test] +fn public_endpoint_flag_accepts_machines_platform() { + let args = UpArgs::parse_from([ + "alien-deploy", + "--platform", + "machines", + "--public-endpoint", + "gateway.api=https://gateway.example.test", + ]); + + let public_endpoints = load_public_endpoints(&args, Platform::Machines, None) + .expect("machines should accept external public endpoints") + .expect("public endpoints should exist"); + + assert_eq!( + public_endpoints + .get("gateway") + .and_then(|endpoints| endpoints.get("api")) + .map(String::as_str), + Some("https://gateway.example.test") + ); +} + +#[test] +fn public_endpoint_names_must_be_declared() { + let daemon = alien_core::Daemon::new("gateway".to_string()) + .code(alien_core::DaemonCode::Image { + image: "gateway:latest".to_string(), + }) + .permissions("default".to_string()) + .public_endpoint(alien_core::PublicEndpoint { + name: "api".to_string(), + port: 8080, + protocol: alien_core::ExposeProtocol::Http, + host_label: None, + wildcard_subdomains: false, + }) + .build(); + let stack = Stack::new("test".to_string()) + .add(daemon, alien_core::ResourceLifecycle::Live) + .build(); + + let valid = HashMap::from([( + "gateway".to_string(), + HashMap::from([( + "api".to_string(), + "https://gateway.example.test".to_string(), + )]), + )]); + validate_public_endpoint_names(&valid, &stack).expect("gateway exposes a public endpoint"); + + let invalid = HashMap::from([( + "gateway".to_string(), + HashMap::from([( + "missing".to_string(), + "https://missing.example.test".to_string(), + )]), + )]); + let error = + validate_public_endpoint_names(&invalid, &stack).expect_err("missing endpoint should fail"); + assert_eq!(error.code, "VALIDATION_ERROR"); +} + +#[test] +fn deploy_config_file_accepts_compute_pool_selection() { + let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); + writeln!( + file, + r#" +name = "cloud-runtime" +platform = "aws" + +[compute.pools.general] +mode = "autoscale" +min = 2 +max = 5 +machine = "m8i.xlarge" +"# + ) + .expect("config should be written"); + + let args = UpArgs::parse_from([ + "alien-deploy", + "--config", + file.path().to_str().expect("temp path should be UTF-8"), + ]); + + let config = load_deploy_config(&args) + .expect("config should load") + .expect("config should exist"); + let settings = load_stack_settings(&args, Platform::Aws, Some(&config)) + .expect("stack settings should load"); + let selection = settings + .compute + .as_ref() + .and_then(|compute| compute.pools.get("general")) + .expect("general compute pool should be configured"); + + assert_eq!(selection.machine(), Some("m8i.xlarge")); + assert_eq!(selection.min_size(), 2); + assert_eq!(selection.max_size(), 5); +} + +#[test] +fn parses_cloud_base_platform_for_kubernetes() { + assert_eq!( + parse_base_platform(Platform::Kubernetes, Some("aws")).unwrap(), + Some(Platform::Aws) + ); +} + +#[test] +fn rejects_base_platform_without_kubernetes_runtime() { + assert!(parse_base_platform(Platform::Aws, Some("gcp")).is_err()); +} + +#[test] +fn rejects_non_cloud_base_platform_for_kubernetes() { + assert!(parse_base_platform(Platform::Kubernetes, Some("local")).is_err()); +} + +#[test] +fn release_stack_for_kubernetes_uses_runtime_platform_not_base_platform() { + let stack = alien_manager_api::types::StackByPlatform { + aws: Some(serde_json::json!({ "id": "aws-stack" })), + gcp: Some(serde_json::json!({ "id": "gcp-stack" })), + azure: Some(serde_json::json!({ "id": "azure-stack" })), + kubernetes: Some(serde_json::json!({ "id": "kubernetes-stack" })), + machines: None, + local: None, + test: None, + }; + + let selected = release_stack_value_for_platform(stack, Platform::Kubernetes).unwrap(); + assert_eq!(selected["id"], "kubernetes-stack"); +} + +#[test] +fn split_image_tag_defaults_missing_tag_to_latest() { + assert_eq!( + split_image_tag("ghcr.io/alienplatform/alien-operator").unwrap(), + ( + "ghcr.io/alienplatform/alien-operator".to_string(), + "latest".to_string() + ) + ); +} + +#[test] +fn split_image_tag_preserves_registry_port() { + assert_eq!( + split_image_tag("localhost:5000/alien-operator:v1").unwrap(), + ( + "localhost:5000/alien-operator".to_string(), + "v1".to_string() + ) + ); +} + +#[test] +fn sanitize_kubernetes_dns_label_falls_back_when_empty() { + assert_eq!(sanitize_kubernetes_dns_label("___"), "alien"); +} + +#[test] +fn resolve_token_reads_token_file() { + let mut token_file = tempfile::NamedTempFile::new().expect("token file"); + token_file + .write_all(b" ax_dg_file_token\n") + .expect("write token"); + + let cli = crate::Cli::try_parse_from([ + "alien-deploy", + "deploy", + "--token-file", + token_file.path().to_str().expect("utf8 path"), + "--platform", + "local", + ]) + .expect("parse deploy"); + let crate::Commands::Deploy(args) = cli.command else { + panic!("expected deploy variant"); + }; + + let token = resolve_token(&args, None).expect("token should resolve"); + assert_eq!(token, "ax_dg_file_token"); +} + +#[test] +fn resolve_token_rejects_empty_token_file() { + let token_file = tempfile::NamedTempFile::new().expect("token file"); + + let cli = crate::Cli::try_parse_from([ + "alien-deploy", + "deploy", + "--token-file", + token_file.path().to_str().expect("utf8 path"), + "--platform", + "local", + ]) + .expect("parse deploy"); + let crate::Commands::Deploy(args) = cli.command else { + panic!("expected deploy variant"); + }; + + let error = resolve_token(&args, None).expect_err("empty token file should fail"); + assert_eq!(error.code, "VALIDATION_ERROR"); +} + +fn stack_input(id: &str, kind: StackInputKind, required: bool) -> StackInputDefinition { + StackInputDefinition { + id: id.to_string(), + kind, + provided_by: vec![StackInputProvider::Deployer], + required, + label: id.to_string(), + description: "Test input".to_string(), + placeholder: None, + default: None, + platforms: None, + validation: None, + env: vec![], + } +} + +#[test] +fn deploy_config_file_accepts_stack_inputs() { + let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); + writeln!( + file, + r#" +platform = "local" + +[inputs] +region = "us-east-1" + +[secretInputs] +apiKey = "secret-value" +"# + ) + .expect("config should be written"); + + let args = UpArgs::parse_from([ + "alien-deploy", + "--config", + file.path().to_str().expect("temp path should be UTF-8"), + ]); + let config = load_deploy_config(&args) + .expect("config should load") + .expect("config should exist"); + let values = collect_deployer_input_values( + &[ + stack_input("region", StackInputKind::String, true), + stack_input("apiKey", StackInputKind::Secret, true), + ], + &[], + &[], + Some(&config), + ) + .expect("input values should parse"); + + assert_eq!(values.get("region"), Some(&serde_json::json!("us-east-1"))); + assert_eq!( + values.get("apiKey"), + Some(&serde_json::json!("secret-value")) + ); +} + +#[test] +fn stack_input_flags_override_config_values() { + let mut file = tempfile::NamedTempFile::new().expect("temp config should be created"); + writeln!( + file, + r#" +platform = "local" + +[inputs] +region = "old" +"# + ) + .expect("config should be written"); + + let args = UpArgs::parse_from([ + "alien-deploy", + "--config", + file.path().to_str().expect("temp path should be UTF-8"), + "--input", + "region=new", + ]); + let config = load_deploy_config(&args) + .expect("config should load") + .expect("config should exist"); + let values = collect_deployer_input_values( + &[stack_input("region", StackInputKind::String, true)], + &args.input_values, + &args.secret_input_values, + Some(&config), + ) + .expect("input values should parse"); + + assert_eq!(values.get("region"), Some(&serde_json::json!("new"))); +} + +#[test] +fn required_stack_inputs_fail_non_interactively() { + let error = collect_deployer_input_values( + &[stack_input("apiKey", StackInputKind::Secret, true)], + &[], + &[], + None, + ) + .expect_err("missing required input should fail"); + + assert_eq!(error.code, "VALIDATION_ERROR"); + assert!(error.message.contains("Missing deployer input")); +} + +#[test] +fn stack_input_values_are_typed() { + let values = collect_deployer_input_values( + &[ + stack_input("replicas", StackInputKind::Integer, true), + stack_input("enabled", StackInputKind::Boolean, true), + stack_input("hosts", StackInputKind::StringList, true), + ], + &[ + "replicas=3".to_string(), + "enabled=true".to_string(), + "hosts=a.example.com,b.example.com".to_string(), + ], + &[], + None, + ) + .expect("input values should parse"); + + assert_eq!(values.get("replicas"), Some(&serde_json::json!(3))); + assert_eq!(values.get("enabled"), Some(&serde_json::json!(true))); + assert_eq!( + values.get("hosts"), + Some(&serde_json::json!(["a.example.com", "b.example.com"])) + ); +} diff --git a/crates/alien-gcp-clients/src/gcp/AGENTS.md b/crates/alien-gcp-clients/src/gcp/AGENTS.md index 64d6e0470..1fb68d264 100644 --- a/crates/alien-gcp-clients/src/gcp/AGENTS.md +++ b/crates/alien-gcp-clients/src/gcp/AGENTS.md @@ -1,8 +1,8 @@ ## Adding a New GCP Service Client -1. Follow existing patterns: Look at `cloudrun.rs` for trait + client struct example +1. Follow existing patterns: Look at `cloudrun.rs` for trait + client struct example (`compute/` is the same pattern split into a directory module because of its size) 2. Validate against GCP API docs: Ensure request/response structs match exact field names, types, and required fields from Google Cloud API Reference 3. Implement core operations only: OK to skip optional fields/features, but all required fields must be present for compatibility 4. Map service errors: Handle standard GCP error format (`error.code`, `error.message`) and map to `RemoteResourceNotFound`, `AuthenticationError`, `RateLimitExceeded`, etc. 5. Use infrastructure: `.gcp_auth()` for auth, `.gcp_error_for_status()` for errors, use `longrunning.rs` for async operations -6. Add comprehensive tests: Create `tests/gcp_[servicename]_client_tests.rs` with GCP emulator support when available, follow existing test patterns +6. Add comprehensive tests: Create `tests/gcp_[servicename]_client_tests.rs` with GCP emulator support when available, follow existing test patterns (compute's tests live in the `tests/gcp_compute_client_tests/` directory module, split by topic because of their size) diff --git a/crates/alien-gcp-clients/src/gcp/compute.rs b/crates/alien-gcp-clients/src/gcp/compute.rs deleted file mode 100644 index fb298005e..000000000 --- a/crates/alien-gcp-clients/src/gcp/compute.rs +++ /dev/null @@ -1,5833 +0,0 @@ -//! GCP Compute Engine client for VPC, networking, load balancing, instances, and disk operations. -//! -//! This module provides APIs for managing: -//! - VPC networks, subnetworks, routers, and firewalls -//! - Load balancing: health checks, backend services, URL maps, proxies, forwarding rules, NEGs -//! - Instance management: instance templates, instance group managers, instances -//! - Persistent disks -//! -//! See: -//! - Networks: https://cloud.google.com/compute/docs/reference/rest/v1/networks -//! - Subnetworks: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks -//! - Routers: https://cloud.google.com/compute/docs/reference/rest/v1/routers -//! - Firewalls: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls -//! - Health Checks: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks -//! - Backend Services: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices -//! - URL Maps: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps -//! - Target HTTP Proxies: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies -//! - Global Addresses: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses -//! - Global Forwarding Rules: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules -//! - Network Endpoint Groups: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups -//! - Instance Templates: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates -//! - Instance Group Managers: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers -//! - Instances: https://cloud.google.com/compute/docs/reference/rest/v1/instances -//! - Disks: https://cloud.google.com/compute/docs/reference/rest/v1/disks - -use crate::gcp::api_client::{GcpClientBase, GcpServiceConfig}; -use crate::gcp::GcpClientConfig; -use alien_client_core::Result; -use bon::Builder; -use reqwest::{Client, Method}; -use serde::{Deserialize, Serialize}; -use std::fmt::Debug; - -#[cfg(feature = "test-utils")] -use mockall::automock; - -// ============================================================================================= -// Service Configuration -// ============================================================================================= - -/// Compute Engine service configuration -#[derive(Debug)] -pub struct ComputeServiceConfig; - -impl GcpServiceConfig for ComputeServiceConfig { - fn base_url(&self) -> &'static str { - "https://compute.googleapis.com/compute/v1" - } - - fn default_audience(&self) -> &'static str { - "https://compute.googleapis.com/" - } - - fn service_name(&self) -> &'static str { - "Compute Engine" - } - - fn service_key(&self) -> &'static str { - "compute" - } -} - -// ============================================================================================= -// API Trait -// ============================================================================================= - -#[cfg_attr(feature = "test-utils", automock)] -#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] -pub trait ComputeApi: Send + Sync + Debug { - // --- Zone Operations --- - - /// Lists zones in the project. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/zones/list - async fn list_zones(&self, filter: Option) -> Result; - - // --- Network Operations --- - - /// Gets a VPC network. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks/get - async fn get_network(&self, network_name: String) -> Result; - - /// Creates a VPC network. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks/insert - async fn insert_network(&self, network: Network) -> Result; - - /// Deletes a VPC network. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks/delete - async fn delete_network(&self, network_name: String) -> Result; - - // --- Subnetwork Operations --- - - /// Gets a subnetwork. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/get - async fn get_subnetwork(&self, region: String, subnetwork_name: String) -> Result; - - /// Creates a subnetwork. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/insert - async fn insert_subnetwork(&self, region: String, subnetwork: Subnetwork) -> Result; - - /// Deletes a subnetwork. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/delete - async fn delete_subnetwork(&self, region: String, subnetwork_name: String) - -> Result; - - // --- Router Operations --- - - /// Lists routers in a region. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/list - async fn list_routers(&self, region: String) -> Result; - - /// Gets a router. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/get - async fn get_router(&self, region: String, router_name: String) -> Result; - - /// Creates a router. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/insert - async fn insert_router(&self, region: String, router: Router) -> Result; - - /// Updates a router (PATCH). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/patch - async fn patch_router( - &self, - region: String, - router_name: String, - router: Router, - ) -> Result; - - /// Deletes a router. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/delete - async fn delete_router(&self, region: String, router_name: String) -> Result; - - // --- Firewall Operations --- - - /// Lists firewall rules. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/list - async fn list_firewalls(&self) -> Result; - - /// Gets a firewall rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/get - async fn get_firewall(&self, firewall_name: String) -> Result; - - /// Creates a firewall rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/insert - async fn insert_firewall(&self, firewall: Firewall) -> Result; - - /// Deletes a firewall rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/delete - async fn delete_firewall(&self, firewall_name: String) -> Result; - - // --- Operation Operations --- - - /// Gets the status of an operation (global). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations/get - async fn get_global_operation(&self, operation_name: String) -> Result; - - /// Gets the status of a regional operation. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionOperations/get - async fn get_region_operation( - &self, - region: String, - operation_name: String, - ) -> Result; - - /// Waits for a global operation to complete. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations/wait - async fn wait_global_operation(&self, operation_name: String) -> Result; - - /// Waits for a regional operation to complete. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionOperations/wait - async fn wait_region_operation( - &self, - region: String, - operation_name: String, - ) -> Result; - - /// Gets the status of a zonal operation. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations/get - async fn get_zone_operation(&self, zone: String, operation_name: String) -> Result; - - /// Waits for a zonal operation to complete. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations/wait - async fn wait_zone_operation(&self, zone: String, operation_name: String) -> Result; - - // --- Health Check Operations --- - - /// Gets a health check. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/get - async fn get_health_check(&self, health_check_name: String) -> Result; - - /// Creates a health check. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/insert - async fn insert_health_check(&self, health_check: HealthCheck) -> Result; - - /// Deletes a health check. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/delete - async fn delete_health_check(&self, health_check_name: String) -> Result; - - /// Patches a health check (PATCH update). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/patch - async fn patch_health_check( - &self, - health_check_name: String, - health_check: HealthCheck, - ) -> Result; - - // --- Backend Service Operations --- - - /// Gets a backend service. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/get - async fn get_backend_service(&self, backend_service_name: String) -> Result; - - /// Creates a backend service. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/insert - async fn insert_backend_service(&self, backend_service: BackendService) -> Result; - - /// Deletes a backend service. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/delete - async fn delete_backend_service(&self, backend_service_name: String) -> Result; - - /// Updates a backend service (PATCH). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/patch - async fn patch_backend_service( - &self, - backend_service_name: String, - backend_service: BackendService, - ) -> Result; - - // --- URL Map Operations --- - - /// Gets a URL map. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/get - async fn get_url_map(&self, url_map_name: String) -> Result; - - /// Creates a URL map. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/insert - async fn insert_url_map(&self, url_map: UrlMap) -> Result; - - /// Deletes a URL map. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/delete - async fn delete_url_map(&self, url_map_name: String) -> Result; - - // --- Target HTTP Proxy Operations --- - - /// Gets a target HTTP proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/get - async fn get_target_http_proxy( - &self, - target_http_proxy_name: String, - ) -> Result; - - /// Creates a target HTTP proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/insert - async fn insert_target_http_proxy( - &self, - target_http_proxy: TargetHttpProxy, - ) -> Result; - - /// Deletes a target HTTP proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/delete - async fn delete_target_http_proxy(&self, target_http_proxy_name: String) -> Result; - - // --- Target HTTPS Proxy Operations --- - - /// Gets a target HTTPS proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/get - async fn get_target_https_proxy( - &self, - target_https_proxy_name: String, - ) -> Result; - - /// Creates a target HTTPS proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/insert - async fn insert_target_https_proxy( - &self, - target_https_proxy: TargetHttpsProxy, - ) -> Result; - - /// Replaces the SSL certificates associated with a target HTTPS proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/setSslCertificates - async fn set_target_https_proxy_ssl_certificates( - &self, - target_https_proxy_name: String, - ssl_certificates: Vec, - ) -> Result; - - /// Deletes a target HTTPS proxy. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/delete - async fn delete_target_https_proxy(&self, target_https_proxy_name: String) - -> Result; - - // --- Target TCP Proxy Operations --- - - async fn insert_target_tcp_proxy(&self, target_tcp_proxy: TargetTcpProxy) -> Result; - async fn delete_target_tcp_proxy(&self, target_tcp_proxy_name: String) -> Result; - - // --- SSL Certificate Operations --- - - /// Gets an SSL certificate. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/get - async fn get_ssl_certificate(&self, ssl_certificate_name: String) -> Result; - - /// Creates an SSL certificate. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/insert - async fn insert_ssl_certificate(&self, ssl_certificate: SslCertificate) -> Result; - - /// Deletes an SSL certificate. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/delete - async fn delete_ssl_certificate(&self, ssl_certificate_name: String) -> Result; - - // --- Global Address Operations --- - - /// Gets a global address. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/get - async fn get_global_address(&self, address_name: String) -> Result
; - - /// Creates a global address. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/insert - async fn insert_global_address(&self, address: Address) -> Result; - - /// Deletes a global address. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/delete - async fn delete_global_address(&self, address_name: String) -> Result; - - // --- Global Forwarding Rule Operations --- - - /// Gets a global forwarding rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/get - async fn get_global_forwarding_rule( - &self, - forwarding_rule_name: String, - ) -> Result; - - /// Creates a global forwarding rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/insert - async fn insert_global_forwarding_rule( - &self, - forwarding_rule: ForwardingRule, - ) -> Result; - - /// Deletes a global forwarding rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/delete - async fn delete_global_forwarding_rule( - &self, - forwarding_rule_name: String, - ) -> Result; - - // --- Regional Address Operations --- - // Private Service Connect consumer endpoints are regional; the global address - // methods above don't cover the regional internal address they need. - - /// Gets a regional address. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/addresses/get - async fn get_address(&self, region: String, address_name: String) -> Result
; - - /// Creates a regional address. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/addresses/insert - async fn insert_address(&self, region: String, address: Address) -> Result; - - /// Deletes a regional address. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/addresses/delete - async fn delete_address(&self, region: String, address_name: String) -> Result; - - // --- Regional Forwarding Rule Operations --- - // Private Service Connect consumer endpoints are regional; the global - // forwarding-rule methods above don't cover them. - - /// Gets a regional forwarding rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules/get - async fn get_forwarding_rule( - &self, - region: String, - forwarding_rule_name: String, - ) -> Result; - - /// Creates a regional forwarding rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules/insert - async fn insert_forwarding_rule( - &self, - region: String, - forwarding_rule: ForwardingRule, - ) -> Result; - - /// Deletes a regional forwarding rule. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules/delete - async fn delete_forwarding_rule( - &self, - region: String, - forwarding_rule_name: String, - ) -> Result; - - // --- Network Endpoint Group (NEG) Operations --- - - /// Gets a network endpoint group. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/get - async fn get_network_endpoint_group( - &self, - zone: String, - neg_name: String, - ) -> Result; - - /// Creates a network endpoint group. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/insert - async fn insert_network_endpoint_group( - &self, - zone: String, - neg: NetworkEndpointGroup, - ) -> Result; - - /// Deletes a network endpoint group. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/delete - async fn delete_network_endpoint_group( - &self, - zone: String, - neg_name: String, - ) -> Result; - - /// Attaches network endpoints to a NEG. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/attachNetworkEndpoints - async fn attach_network_endpoints( - &self, - zone: String, - neg_name: String, - request: NetworkEndpointGroupsAttachEndpointsRequest, - ) -> Result; - - /// Detaches network endpoints from a NEG. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/detachNetworkEndpoints - async fn detach_network_endpoints( - &self, - zone: String, - neg_name: String, - request: NetworkEndpointGroupsDetachEndpointsRequest, - ) -> Result; - - // --- Regional Network Endpoint Group (NEG) Operations --- - - /// Gets a regional network endpoint group (for serverless workloads). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/get - async fn get_region_network_endpoint_group( - &self, - region: String, - neg_name: String, - ) -> Result; - - /// Creates a regional network endpoint group (for serverless workloads). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/insert - async fn insert_region_network_endpoint_group( - &self, - region: String, - neg: NetworkEndpointGroup, - ) -> Result; - - /// Deletes a regional network endpoint group. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/delete - async fn delete_region_network_endpoint_group( - &self, - region: String, - neg_name: String, - ) -> Result; - - // --- Instance Template Operations --- - - /// Gets an instance template. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/get - async fn get_instance_template( - &self, - instance_template_name: String, - ) -> Result; - - /// Creates an instance template. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/insert - async fn insert_instance_template( - &self, - instance_template: InstanceTemplate, - ) -> Result; - - /// Deletes an instance template. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/delete - async fn delete_instance_template(&self, instance_template_name: String) -> Result; - - // --- Instance Group Manager Operations --- - - /// Gets an instance group manager. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/get - async fn get_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - ) -> Result; - - /// Creates an instance group manager. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/insert - async fn insert_instance_group_manager( - &self, - zone: String, - instance_group_manager: InstanceGroupManager, - ) -> Result; - - /// Deletes an instance group manager. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/delete - async fn delete_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - ) -> Result; - - /// Resizes an instance group manager. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/resize - async fn resize_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - size: i32, - ) -> Result; - - /// Deletes selected managed instances and reduces the instance group manager target size. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/deleteInstances - async fn delete_instance_group_manager_instances( - &self, - zone: String, - instance_group_manager_name: String, - request: InstanceGroupManagersDeleteInstancesRequest, - ) -> Result; - - /// Lists managed instances in an instance group manager. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/listManagedInstances - async fn list_managed_instances( - &self, - zone: String, - instance_group_manager_name: String, - ) -> Result; - - /// Patches an instance group manager using merge-patch semantics. - /// Used for rolling updates: set instanceTemplate + updatePolicy to trigger PROACTIVE replacement. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/patch - async fn patch_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - patch: InstanceGroupManager, - ) -> Result; - - // --- Instance Operations --- - - /// Gets an instance. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/get - async fn get_instance(&self, zone: String, instance_name: String) -> Result; - - /// Deletes an instance. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/delete - async fn delete_instance(&self, zone: String, instance_name: String) -> Result; - - /// Attaches a disk to an instance. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/attachDisk - async fn attach_disk( - &self, - zone: String, - instance_name: String, - attached_disk: AttachedDisk, - ) -> Result; - - /// Detaches a disk from an instance. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/detachDisk - async fn detach_disk( - &self, - zone: String, - instance_name: String, - device_name: String, - ) -> Result; - - // --- Disk Operations --- - - /// Gets a disk. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks/get - async fn get_disk(&self, zone: String, disk_name: String) -> Result; - - /// Creates a disk. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks/insert - async fn insert_disk(&self, zone: String, disk: Disk) -> Result; - - /// Deletes a disk. - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks/delete - async fn delete_disk(&self, zone: String, disk_name: String) -> Result; - - // --- Serial Port Operations --- - - /// Gets the serial port output of an instance (port 1 = main console). - /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/getSerialPortOutput - async fn get_serial_port_output( - &self, - zone: String, - instance_name: String, - ) -> Result; -} - -// ============================================================================================= -// Client Implementation -// ============================================================================================= - -/// Compute Engine client for managing VPC networks and related resources -#[derive(Debug)] -pub struct ComputeClient { - base: GcpClientBase, - project_id: String, -} - -impl ComputeClient { - pub fn new(client: Client, config: GcpClientConfig) -> Self { - let project_id = config.project_id.clone(); - Self { - base: GcpClientBase::new(client, config, Box::new(ComputeServiceConfig)), - project_id, - } - } - - pub fn project_id(&self) -> &str { - &self.project_id - } -} - -#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] -impl ComputeApi for ComputeClient { - // --- Zone Operations --- - - async fn list_zones(&self, filter: Option) -> Result { - let path = format!("projects/{}/zones", self.project_id); - let query_params = filter.map(|filter| vec![("filter", filter)]); - self.base - .execute_request( - Method::GET, - &path, - query_params, - Option::<()>::None, - "zones", - ) - .await - } - - // --- Network Operations --- - - async fn get_network(&self, network_name: String) -> Result { - let path = format!( - "projects/{}/global/networks/{}", - self.project_id, network_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &network_name) - .await - } - - async fn insert_network(&self, network: Network) -> Result { - let path = format!("projects/{}/global/networks", self.project_id); - let resource_name = network.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(network), &resource_name) - .await - } - - async fn delete_network(&self, network_name: String) -> Result { - let path = format!( - "projects/{}/global/networks/{}", - self.project_id, network_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &network_name, - ) - .await - } - - // --- Subnetwork Operations --- - - async fn get_subnetwork(&self, region: String, subnetwork_name: String) -> Result { - let path = format!( - "projects/{}/regions/{}/subnetworks/{}", - self.project_id, region, subnetwork_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &subnetwork_name, - ) - .await - } - - async fn insert_subnetwork(&self, region: String, subnetwork: Subnetwork) -> Result { - let path = format!( - "projects/{}/regions/{}/subnetworks", - self.project_id, region - ); - let resource_name = subnetwork.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(subnetwork), &resource_name) - .await - } - - async fn delete_subnetwork( - &self, - region: String, - subnetwork_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/subnetworks/{}", - self.project_id, region, subnetwork_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &subnetwork_name, - ) - .await - } - - // --- Router Operations --- - - async fn list_routers(&self, region: String) -> Result { - let path = format!("projects/{}/regions/{}/routers", self.project_id, region); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, "routers") - .await - } - - async fn get_router(&self, region: String, router_name: String) -> Result { - let path = format!( - "projects/{}/regions/{}/routers/{}", - self.project_id, region, router_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &router_name) - .await - } - - async fn insert_router(&self, region: String, router: Router) -> Result { - let path = format!("projects/{}/regions/{}/routers", self.project_id, region); - let resource_name = router.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(router), &resource_name) - .await - } - - async fn patch_router( - &self, - region: String, - router_name: String, - router: Router, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/routers/{}", - self.project_id, region, router_name - ); - self.base - .execute_request(Method::PATCH, &path, None, Some(router), &router_name) - .await - } - - async fn delete_router(&self, region: String, router_name: String) -> Result { - let path = format!( - "projects/{}/regions/{}/routers/{}", - self.project_id, region, router_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &router_name, - ) - .await - } - - // --- Firewall Operations --- - - async fn list_firewalls(&self) -> Result { - let path = format!("projects/{}/global/firewalls", self.project_id); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, "firewalls") - .await - } - - async fn get_firewall(&self, firewall_name: String) -> Result { - let path = format!( - "projects/{}/global/firewalls/{}", - self.project_id, firewall_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &firewall_name) - .await - } - - async fn insert_firewall(&self, firewall: Firewall) -> Result { - let path = format!("projects/{}/global/firewalls", self.project_id); - let resource_name = firewall.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(firewall), &resource_name) - .await - } - - async fn delete_firewall(&self, firewall_name: String) -> Result { - let path = format!( - "projects/{}/global/firewalls/{}", - self.project_id, firewall_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &firewall_name, - ) - .await - } - - // --- Operation Operations --- - - async fn get_global_operation(&self, operation_name: String) -> Result { - let path = format!( - "projects/{}/global/operations/{}", - self.project_id, operation_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &operation_name, - ) - .await - } - - async fn get_region_operation( - &self, - region: String, - operation_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/operations/{}", - self.project_id, region, operation_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &operation_name, - ) - .await - } - - async fn wait_global_operation(&self, operation_name: String) -> Result { - let path = format!( - "projects/{}/global/operations/{}/wait", - self.project_id, operation_name - ); - self.base - .execute_request( - Method::POST, - &path, - None, - Option::<()>::None, - &operation_name, - ) - .await - } - - async fn wait_region_operation( - &self, - region: String, - operation_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/operations/{}/wait", - self.project_id, region, operation_name - ); - self.base - .execute_request( - Method::POST, - &path, - None, - Option::<()>::None, - &operation_name, - ) - .await - } - - async fn get_zone_operation(&self, zone: String, operation_name: String) -> Result { - let path = format!( - "projects/{}/zones/{}/operations/{}", - self.project_id, zone, operation_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &operation_name, - ) - .await - } - - async fn wait_zone_operation(&self, zone: String, operation_name: String) -> Result { - let path = format!( - "projects/{}/zones/{}/operations/{}/wait", - self.project_id, zone, operation_name - ); - self.base - .execute_request( - Method::POST, - &path, - None, - Option::<()>::None, - &operation_name, - ) - .await - } - - // --- Health Check Operations --- - - async fn get_health_check(&self, health_check_name: String) -> Result { - let path = format!( - "projects/{}/global/healthChecks/{}", - self.project_id, health_check_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &health_check_name, - ) - .await - } - - async fn insert_health_check(&self, health_check: HealthCheck) -> Result { - let path = format!("projects/{}/global/healthChecks", self.project_id); - let resource_name = health_check.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(health_check), - &resource_name, - ) - .await - } - - async fn delete_health_check(&self, health_check_name: String) -> Result { - let path = format!( - "projects/{}/global/healthChecks/{}", - self.project_id, health_check_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &health_check_name, - ) - .await - } - - async fn patch_health_check( - &self, - health_check_name: String, - health_check: HealthCheck, - ) -> Result { - let path = format!( - "projects/{}/global/healthChecks/{}", - self.project_id, health_check_name - ); - self.base - .execute_request( - Method::PATCH, - &path, - None, - Some(health_check), - &health_check_name, - ) - .await - } - - // --- Backend Service Operations --- - - async fn get_backend_service(&self, backend_service_name: String) -> Result { - let path = format!( - "projects/{}/global/backendServices/{}", - self.project_id, backend_service_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &backend_service_name, - ) - .await - } - - async fn insert_backend_service(&self, backend_service: BackendService) -> Result { - let path = format!("projects/{}/global/backendServices", self.project_id); - let resource_name = backend_service.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(backend_service), - &resource_name, - ) - .await - } - - async fn delete_backend_service(&self, backend_service_name: String) -> Result { - let path = format!( - "projects/{}/global/backendServices/{}", - self.project_id, backend_service_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &backend_service_name, - ) - .await - } - - async fn patch_backend_service( - &self, - backend_service_name: String, - backend_service: BackendService, - ) -> Result { - let path = format!( - "projects/{}/global/backendServices/{}", - self.project_id, backend_service_name - ); - self.base - .execute_request( - Method::PATCH, - &path, - None, - Some(backend_service), - &backend_service_name, - ) - .await - } - - // --- URL Map Operations --- - - async fn get_url_map(&self, url_map_name: String) -> Result { - let path = format!( - "projects/{}/global/urlMaps/{}", - self.project_id, url_map_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &url_map_name) - .await - } - - async fn insert_url_map(&self, url_map: UrlMap) -> Result { - let path = format!("projects/{}/global/urlMaps", self.project_id); - let resource_name = url_map.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(url_map), &resource_name) - .await - } - - async fn delete_url_map(&self, url_map_name: String) -> Result { - let path = format!( - "projects/{}/global/urlMaps/{}", - self.project_id, url_map_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &url_map_name, - ) - .await - } - - // --- Target HTTP Proxy Operations --- - - async fn get_target_http_proxy( - &self, - target_http_proxy_name: String, - ) -> Result { - let path = format!( - "projects/{}/global/targetHttpProxies/{}", - self.project_id, target_http_proxy_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &target_http_proxy_name, - ) - .await - } - - async fn insert_target_http_proxy( - &self, - target_http_proxy: TargetHttpProxy, - ) -> Result { - let path = format!("projects/{}/global/targetHttpProxies", self.project_id); - let resource_name = target_http_proxy.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(target_http_proxy), - &resource_name, - ) - .await - } - - async fn delete_target_http_proxy(&self, target_http_proxy_name: String) -> Result { - let path = format!( - "projects/{}/global/targetHttpProxies/{}", - self.project_id, target_http_proxy_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &target_http_proxy_name, - ) - .await - } - - // --- Target HTTPS Proxy Operations --- - - async fn get_target_https_proxy( - &self, - target_https_proxy_name: String, - ) -> Result { - let path = format!( - "projects/{}/global/targetHttpsProxies/{}", - self.project_id, target_https_proxy_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &target_https_proxy_name, - ) - .await - } - - async fn insert_target_https_proxy( - &self, - target_https_proxy: TargetHttpsProxy, - ) -> Result { - let path = format!("projects/{}/global/targetHttpsProxies", self.project_id); - let name = target_https_proxy - .name - .clone() - .unwrap_or_else(|| "targetHttpsProxy".to_string()); - self.base - .execute_request(Method::POST, &path, None, Some(target_https_proxy), &name) - .await - } - - async fn set_target_https_proxy_ssl_certificates( - &self, - target_https_proxy_name: String, - ssl_certificates: Vec, - ) -> Result { - let path = format!( - "projects/{}/global/targetHttpsProxies/{}/setSslCertificates", - self.project_id, target_https_proxy_name - ); - let request = SetSslCertificatesRequest { ssl_certificates }; - self.base - .execute_request( - Method::POST, - &path, - None, - Some(request), - &target_https_proxy_name, - ) - .await - } - - async fn delete_target_https_proxy( - &self, - target_https_proxy_name: String, - ) -> Result { - let path = format!( - "projects/{}/global/targetHttpsProxies/{}", - self.project_id, target_https_proxy_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &target_https_proxy_name, - ) - .await - } - - async fn insert_target_tcp_proxy(&self, target_tcp_proxy: TargetTcpProxy) -> Result { - let path = format!("projects/{}/global/targetTcpProxies", self.project_id); - let name = target_tcp_proxy - .name - .clone() - .unwrap_or_else(|| "targetTcpProxy".to_string()); - self.base - .execute_request(Method::POST, &path, None, Some(target_tcp_proxy), &name) - .await - } - - async fn delete_target_tcp_proxy(&self, target_tcp_proxy_name: String) -> Result { - let path = format!( - "projects/{}/global/targetTcpProxies/{}", - self.project_id, target_tcp_proxy_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &target_tcp_proxy_name, - ) - .await - } - - // --- SSL Certificate Operations --- - - async fn get_ssl_certificate(&self, ssl_certificate_name: String) -> Result { - let path = format!( - "projects/{}/global/sslCertificates/{}", - self.project_id, ssl_certificate_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &ssl_certificate_name, - ) - .await - } - - async fn insert_ssl_certificate(&self, ssl_certificate: SslCertificate) -> Result { - let path = format!("projects/{}/global/sslCertificates", self.project_id); - let name = ssl_certificate - .name - .clone() - .unwrap_or_else(|| "sslCertificate".to_string()); - self.base - .execute_request(Method::POST, &path, None, Some(ssl_certificate), &name) - .await - } - - async fn delete_ssl_certificate(&self, ssl_certificate_name: String) -> Result { - let path = format!( - "projects/{}/global/sslCertificates/{}", - self.project_id, ssl_certificate_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &ssl_certificate_name, - ) - .await - } - - // --- Global Address Operations --- - - async fn get_global_address(&self, address_name: String) -> Result
{ - let path = format!( - "projects/{}/global/addresses/{}", - self.project_id, address_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &address_name) - .await - } - - async fn insert_global_address(&self, address: Address) -> Result { - let path = format!("projects/{}/global/addresses", self.project_id); - let resource_name = address.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(address), &resource_name) - .await - } - - async fn delete_global_address(&self, address_name: String) -> Result { - let path = format!( - "projects/{}/global/addresses/{}", - self.project_id, address_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &address_name, - ) - .await - } - - // --- Global Forwarding Rule Operations --- - - async fn get_global_forwarding_rule( - &self, - forwarding_rule_name: String, - ) -> Result { - let path = format!( - "projects/{}/global/forwardingRules/{}", - self.project_id, forwarding_rule_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &forwarding_rule_name, - ) - .await - } - - async fn insert_global_forwarding_rule( - &self, - forwarding_rule: ForwardingRule, - ) -> Result { - let path = format!("projects/{}/global/forwardingRules", self.project_id); - let resource_name = forwarding_rule.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(forwarding_rule), - &resource_name, - ) - .await - } - - async fn delete_global_forwarding_rule( - &self, - forwarding_rule_name: String, - ) -> Result { - let path = format!( - "projects/{}/global/forwardingRules/{}", - self.project_id, forwarding_rule_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &forwarding_rule_name, - ) - .await - } - - // --- Regional Address Operations --- - - async fn get_address(&self, region: String, address_name: String) -> Result
{ - let path = format!( - "projects/{}/regions/{}/addresses/{}", - self.project_id, region, address_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &address_name) - .await - } - - async fn insert_address(&self, region: String, address: Address) -> Result { - let path = format!("projects/{}/regions/{}/addresses", self.project_id, region); - let resource_name = address.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(address), &resource_name) - .await - } - - async fn delete_address(&self, region: String, address_name: String) -> Result { - let path = format!( - "projects/{}/regions/{}/addresses/{}", - self.project_id, region, address_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &address_name, - ) - .await - } - - // --- Regional Forwarding Rule Operations --- - - async fn get_forwarding_rule( - &self, - region: String, - forwarding_rule_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/forwardingRules/{}", - self.project_id, region, forwarding_rule_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &forwarding_rule_name, - ) - .await - } - - async fn insert_forwarding_rule( - &self, - region: String, - forwarding_rule: ForwardingRule, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/forwardingRules", - self.project_id, region - ); - let resource_name = forwarding_rule.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(forwarding_rule), - &resource_name, - ) - .await - } - - async fn delete_forwarding_rule( - &self, - region: String, - forwarding_rule_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/forwardingRules/{}", - self.project_id, region, forwarding_rule_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &forwarding_rule_name, - ) - .await - } - - // --- Network Endpoint Group (NEG) Operations --- - - async fn get_network_endpoint_group( - &self, - zone: String, - neg_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/networkEndpointGroups/{}", - self.project_id, zone, neg_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &neg_name) - .await - } - - async fn insert_network_endpoint_group( - &self, - zone: String, - neg: NetworkEndpointGroup, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/networkEndpointGroups", - self.project_id, zone - ); - let resource_name = neg.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(neg), &resource_name) - .await - } - - async fn delete_network_endpoint_group( - &self, - zone: String, - neg_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/networkEndpointGroups/{}", - self.project_id, zone, neg_name - ); - self.base - .execute_request(Method::DELETE, &path, None, Option::<()>::None, &neg_name) - .await - } - - async fn attach_network_endpoints( - &self, - zone: String, - neg_name: String, - request: NetworkEndpointGroupsAttachEndpointsRequest, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/networkEndpointGroups/{}/attachNetworkEndpoints", - self.project_id, zone, neg_name - ); - self.base - .execute_request(Method::POST, &path, None, Some(request), &neg_name) - .await - } - - async fn detach_network_endpoints( - &self, - zone: String, - neg_name: String, - request: NetworkEndpointGroupsDetachEndpointsRequest, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/networkEndpointGroups/{}/detachNetworkEndpoints", - self.project_id, zone, neg_name - ); - self.base - .execute_request(Method::POST, &path, None, Some(request), &neg_name) - .await - } - - // --- Regional Network Endpoint Group (NEG) Operations --- - - async fn get_region_network_endpoint_group( - &self, - region: String, - neg_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/networkEndpointGroups/{}", - self.project_id, region, neg_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &neg_name) - .await - } - - async fn insert_region_network_endpoint_group( - &self, - region: String, - neg: NetworkEndpointGroup, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/networkEndpointGroups", - self.project_id, region - ); - let resource_name = neg.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(neg), &resource_name) - .await - } - - async fn delete_region_network_endpoint_group( - &self, - region: String, - neg_name: String, - ) -> Result { - let path = format!( - "projects/{}/regions/{}/networkEndpointGroups/{}", - self.project_id, region, neg_name - ); - self.base - .execute_request(Method::DELETE, &path, None, Option::<()>::None, &neg_name) - .await - } - - // --- Instance Template Operations --- - - async fn get_instance_template( - &self, - instance_template_name: String, - ) -> Result { - let path = format!( - "projects/{}/global/instanceTemplates/{}", - self.project_id, instance_template_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &instance_template_name, - ) - .await - } - - async fn insert_instance_template( - &self, - instance_template: InstanceTemplate, - ) -> Result { - let path = format!("projects/{}/global/instanceTemplates", self.project_id); - let resource_name = instance_template.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(instance_template), - &resource_name, - ) - .await - } - - async fn delete_instance_template(&self, instance_template_name: String) -> Result { - let path = format!( - "projects/{}/global/instanceTemplates/{}", - self.project_id, instance_template_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &instance_template_name, - ) - .await - } - - // --- Instance Group Manager Operations --- - - async fn get_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers/{}", - self.project_id, zone, instance_group_manager_name - ); - self.base - .execute_request( - Method::GET, - &path, - None, - Option::<()>::None, - &instance_group_manager_name, - ) - .await - } - - async fn insert_instance_group_manager( - &self, - zone: String, - instance_group_manager: InstanceGroupManager, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers", - self.project_id, zone - ); - let resource_name = instance_group_manager.name.clone().unwrap_or_default(); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(instance_group_manager), - &resource_name, - ) - .await - } - - async fn delete_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers/{}", - self.project_id, zone, instance_group_manager_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &instance_group_manager_name, - ) - .await - } - - async fn resize_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - size: i32, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers/{}/resize", - self.project_id, zone, instance_group_manager_name - ); - let query_params = vec![("size", size.to_string())]; - self.base - .execute_request( - Method::POST, - &path, - Some(query_params), - Option::<()>::None, - &instance_group_manager_name, - ) - .await - } - - async fn delete_instance_group_manager_instances( - &self, - zone: String, - instance_group_manager_name: String, - request: InstanceGroupManagersDeleteInstancesRequest, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers/{}/deleteInstances", - self.project_id, zone, instance_group_manager_name - ); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(request), - &instance_group_manager_name, - ) - .await - } - - async fn list_managed_instances( - &self, - zone: String, - instance_group_manager_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers/{}/listManagedInstances", - self.project_id, zone, instance_group_manager_name - ); - self.base - .execute_request( - Method::POST, - &path, - None, - Option::<()>::None, - &instance_group_manager_name, - ) - .await - } - - async fn patch_instance_group_manager( - &self, - zone: String, - instance_group_manager_name: String, - patch: InstanceGroupManager, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instanceGroupManagers/{}", - self.project_id, zone, instance_group_manager_name - ); - self.base - .execute_request( - Method::PATCH, - &path, - None, - Some(patch), - &instance_group_manager_name, - ) - .await - } - - // --- Instance Operations --- - - async fn get_instance(&self, zone: String, instance_name: String) -> Result { - let path = format!( - "projects/{}/zones/{}/instances/{}", - self.project_id, zone, instance_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &instance_name) - .await - } - - async fn delete_instance(&self, zone: String, instance_name: String) -> Result { - let path = format!( - "projects/{}/zones/{}/instances/{}", - self.project_id, zone, instance_name - ); - self.base - .execute_request( - Method::DELETE, - &path, - None, - Option::<()>::None, - &instance_name, - ) - .await - } - - async fn attach_disk( - &self, - zone: String, - instance_name: String, - attached_disk: AttachedDisk, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instances/{}/attachDisk", - self.project_id, zone, instance_name - ); - self.base - .execute_request( - Method::POST, - &path, - None, - Some(attached_disk), - &instance_name, - ) - .await - } - - async fn detach_disk( - &self, - zone: String, - instance_name: String, - device_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instances/{}/detachDisk", - self.project_id, zone, instance_name - ); - let query = vec![("deviceName", device_name)]; - self.base - .execute_request( - Method::POST, - &path, - Some(query), - Option::<()>::None, - &instance_name, - ) - .await - } - - // --- Disk Operations --- - - async fn get_disk(&self, zone: String, disk_name: String) -> Result { - let path = format!( - "projects/{}/zones/{}/disks/{}", - self.project_id, zone, disk_name - ); - self.base - .execute_request(Method::GET, &path, None, Option::<()>::None, &disk_name) - .await - } - - async fn insert_disk(&self, zone: String, disk: Disk) -> Result { - let path = format!("projects/{}/zones/{}/disks", self.project_id, zone); - let resource_name = disk.name.clone().unwrap_or_default(); - self.base - .execute_request(Method::POST, &path, None, Some(disk), &resource_name) - .await - } - - async fn delete_disk(&self, zone: String, disk_name: String) -> Result { - let path = format!( - "projects/{}/zones/{}/disks/{}", - self.project_id, zone, disk_name - ); - self.base - .execute_request(Method::DELETE, &path, None, Option::<()>::None, &disk_name) - .await - } - - async fn get_serial_port_output( - &self, - zone: String, - instance_name: String, - ) -> Result { - let path = format!( - "projects/{}/zones/{}/instances/{}/serialPort", - self.project_id, zone, instance_name - ); - self.base - .execute_request( - Method::GET, - &path, - Some(vec![("port", "1".to_string())]), - Option::<()>::None, - &instance_name, - ) - .await - } -} - -// ============================================================================================= -// Data Structures - Operation -// ============================================================================================= - -/// Represents a Compute Engine operation. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Operation { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the operation. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Type of the operation (e.g., "insert", "delete"). - #[serde(skip_serializing_if = "Option::is_none")] - pub operation_type: Option, - - /// URL of the resource the operation modifies. - #[serde(skip_serializing_if = "Option::is_none")] - pub target_link: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// User who requested the operation. - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - - /// Status of the operation: PENDING, RUNNING, or DONE. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// Optional progress indicator (0-100). - #[serde(skip_serializing_if = "Option::is_none")] - pub progress: Option, - - /// Time the operation was started (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub start_time: Option, - - /// Time the operation was completed (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub end_time: Option, - - /// Time the operation was requested (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub insert_time: Option, - - /// URL of the zone where the operation resides (for zonal operations). - #[serde(skip_serializing_if = "Option::is_none")] - pub zone: Option, - - /// URL of the region where the operation resides (for regional operations). - #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option, - - /// Description of the operation. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// HTTP error status code returned if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub http_error_status_code: Option, - - /// HTTP error message returned if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub http_error_message: Option, - - /// Error information if the operation failed. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - - /// Type of resource (always "compute#operation"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -impl Operation { - /// Returns true if the operation has completed (status == DONE). - pub fn is_done(&self) -> bool { - matches!(self.status, Some(OperationStatus::Done)) - } - - /// Returns true if the operation completed with an error. - pub fn has_error(&self) -> bool { - self.error.is_some() && !self.error.as_ref().unwrap().errors.is_empty() - } -} - -/// Status of an operation. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum OperationStatus { - /// Operation is pending. - Pending, - /// Operation is running. - Running, - /// Operation is complete. - Done, -} - -/// Error information for a failed operation. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct OperationError { - /// Array of errors. - #[builder(default)] - #[serde(default)] - pub errors: Vec, -} - -/// Individual error item in an operation error. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct OperationErrorItem { - /// Error code. - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, - - /// Location in the request that caused the error. - #[serde(skip_serializing_if = "Option::is_none")] - pub location: Option, - - /// Human-readable error message. - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -// ============================================================================================= -// Data Structures - Zone -// ============================================================================================= - -/// Represents a Compute Engine zone resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/zones -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Zone { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the zone. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Region URL this zone belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option, - - /// Zone status, commonly "UP" for usable zones. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// Type of resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// List of Compute Engine zones. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ZoneList { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// List of zones. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec, - - /// Server-defined URL for this resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Token for next page of results. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_page_token: Option, - - /// Type of resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -// ============================================================================================= -// Data Structures - Network -// ============================================================================================= - -/// Represents a VPC network resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Network { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. Must be 1-63 characters, lowercase letters, numbers, or hyphens. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// When true, VMs in this network without external IPs can access Google APIs using Private Google Access. - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_create_subnetworks: Option, - - /// Server-defined list of subnetwork URLs for this VPC network. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub subnetworks: Vec, - - /// The network routing mode (REGIONAL or GLOBAL). - #[serde(skip_serializing_if = "Option::is_none")] - pub routing_config: Option, - - /// Maximum Transmission Unit in bytes. - #[serde(skip_serializing_if = "Option::is_none")] - pub mtu: Option, - - /// Firewall policy enforced on the network. - #[serde(skip_serializing_if = "Option::is_none")] - pub firewall_policy: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Gateway IPv4 address (output only, for legacy networks). - #[serde(skip_serializing_if = "Option::is_none")] - pub gateway_i_pv4: Option, - - /// Internal IPv6 range for this network. - #[serde(skip_serializing_if = "Option::is_none")] - pub internal_ipv6_range: Option, - - /// Type of resource (always "compute#network"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - - /// Network firewall policy enforcement order. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_firewall_policy_enforcement_order: Option, -} - -/// Routing configuration for a network. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkRoutingConfig { - /// The network-wide routing mode: REGIONAL or GLOBAL. - #[serde(skip_serializing_if = "Option::is_none")] - pub routing_mode: Option, -} - -/// Network routing mode. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum RoutingMode { - /// Regional routing: routes are only advertised to routers in the same region. - Regional, - /// Global routing: routes are advertised to all routers in the network. - Global, -} - -/// Network firewall policy enforcement order. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NetworkFirewallPolicyEnforcementOrder { - /// Evaluate firewall policy before VPC firewall rules. - BeforeClassicFirewall, - /// Evaluate firewall policy after VPC firewall rules. - AfterClassicFirewall, -} - -// ============================================================================================= -// Data Structures - Subnetwork -// ============================================================================================= - -/// Represents a subnetwork resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Subnetwork { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// URL of the network this subnetwork belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// IP CIDR range for this subnetwork (e.g., "10.0.0.0/24"). - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_cidr_range: Option, - - /// URL of the region this subnetwork belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option, - - /// Gateway address for default routes to IPs within this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub gateway_address: Option, - - /// Whether VMs in this subnetwork can access Google services without external IPs. - #[serde(skip_serializing_if = "Option::is_none")] - pub private_ip_google_access: Option, - - /// Purpose of the subnetwork (PRIVATE, INTERNAL_HTTPS_LOAD_BALANCER, etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option, - - /// Role of the subnetwork (ACTIVE or BACKUP for INTERNAL_HTTPS_LOAD_BALANCER). - #[serde(skip_serializing_if = "Option::is_none")] - pub role: Option, - - /// Secondary IP ranges for this subnetwork. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub secondary_ip_ranges: Vec, - - /// Fingerprint of this resource (for optimistic locking). - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Whether flow logs are enabled for this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_flow_logs: Option, - - /// Log configuration for this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub log_config: Option, - - /// Stack type for this subnetwork (IPV4_ONLY or IPV4_IPV6). - #[serde(skip_serializing_if = "Option::is_none")] - pub stack_type: Option, - - /// IPv6 access type for this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub ipv6_access_type: Option, - - /// IPv6 CIDR range for this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub ipv6_cidr_range: Option, - - /// External IPv6 prefix for this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub external_ipv6_prefix: Option, - - /// Internal IPv6 prefix for this subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub internal_ipv6_prefix: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#subnetwork"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - - /// Private IPv6 Google access type. - #[serde(skip_serializing_if = "Option::is_none")] - pub private_ipv6_google_access: Option, -} - -/// Purpose of a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SubnetworkPurpose { - /// Regular user-created subnetwork. - Private, - /// Reserved for Internal HTTP(S) Load Balancer. - InternalHttpsLoadBalancer, - /// Reserved for Regional Internal HTTP(S) Load Balancer. - RegionalManagedProxy, - /// Reserved for Global Internal HTTP(S) Load Balancer. - GlobalManagedProxy, - /// Reserved for Private Service Connect. - PrivateServiceConnect, - /// Reserved for Private NAT. - PrivateNat, -} - -/// Role of a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SubnetworkRole { - /// Active role. - Active, - /// Backup role. - Backup, -} - -/// Secondary IP range for a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct SubnetworkSecondaryRange { - /// Name of the secondary range. - #[serde(skip_serializing_if = "Option::is_none")] - pub range_name: Option, - - /// IP CIDR range for the secondary range. - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_cidr_range: Option, -} - -/// Log configuration for a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct SubnetworkLogConfig { - /// Whether to enable flow logs. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable: Option, - - /// Aggregation interval for flow logs. - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregation_interval: Option, - - /// Sampling rate for flow logs (0.0-1.0). - #[serde(skip_serializing_if = "Option::is_none")] - pub flow_sampling: Option, - - /// Metadata to include in flow logs. - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, - - /// Custom metadata fields to include. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub metadata_fields: Vec, - - /// Filter expression for flow logs. - #[serde(skip_serializing_if = "Option::is_none")] - pub filter_expr: Option, -} - -/// Aggregation interval for flow logs. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AggregationInterval { - /// 5 second interval. - Interval5Sec, - /// 30 second interval. - Interval30Sec, - /// 1 minute interval. - Interval1Min, - /// 5 minute interval. - Interval5Min, - /// 10 minute interval. - Interval10Min, - /// 15 minute interval. - Interval15Min, -} - -/// Metadata configuration for subnetwork logs. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SubnetworkLogConfigMetadata { - /// Exclude all metadata. - ExcludeAllMetadata, - /// Include all metadata. - IncludeAllMetadata, - /// Include custom metadata only. - CustomMetadata, -} - -/// Stack type for a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum StackType { - /// IPv4 only. - Ipv4Only, - /// Dual-stack (IPv4 and IPv6). - Ipv4Ipv6, -} - -/// IPv6 access type for a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum Ipv6AccessType { - /// External IPv6 access. - External, - /// Internal IPv6 access. - Internal, -} - -/// Private IPv6 Google access type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum PrivateIpv6GoogleAccess { - /// Disable private IPv6 Google access. - DisableGoogleAccess, - /// Enable outbound VM access to Google services via IPv6. - EnableOutboundVmAccessToGoogle, - /// Enable bidirectional access to Google services via IPv6. - EnableBidirectionalAccessToGoogle, -} - -// ============================================================================================= -// Data Structures - Router -// ============================================================================================= - -/// Represents a Cloud Router resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Router { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// URL of the region this router belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option, - - /// URL of the network this router belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// BGP information for this router. - #[serde(skip_serializing_if = "Option::is_none")] - pub bgp: Option, - - /// BGP peers for this router. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub bgp_peers: Vec, - - /// NAT configurations for this router. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub nats: Vec, - - /// Router interfaces. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub interfaces: Vec, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#router"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - - /// Encrypted interconnect router flag. - #[serde(skip_serializing_if = "Option::is_none")] - pub encrypted_interconnect_router: Option, -} - -/// BGP configuration for a router. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterBgp { - /// Local BGP Autonomous System Number (ASN). - #[serde(skip_serializing_if = "Option::is_none")] - pub asn: Option, - - /// Advertise mode for this BGP speaker. - #[serde(skip_serializing_if = "Option::is_none")] - pub advertise_mode: Option, - - /// Groups of prefixes to be advertised. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub advertised_groups: Vec, - - /// Individual prefixes to be advertised. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub advertised_ip_ranges: Vec, - - /// Keepalive interval in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub keepalive_interval: Option, -} - -/// BGP advertise mode. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AdvertiseMode { - /// Advertise default routes. - Default, - /// Advertise custom routes. - Custom, -} - -/// Groups of prefixes to advertise. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AdvertisedGroup { - /// Advertise all subnets. - AllSubnets, -} - -/// Individual IP range to advertise. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterAdvertisedIpRange { - /// IP range to advertise. - #[serde(skip_serializing_if = "Option::is_none")] - pub range: Option, - - /// Description of this IP range. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// BGP peer configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterBgpPeer { - /// Name of this BGP peer. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Name of the interface the BGP peer is associated with. - #[serde(skip_serializing_if = "Option::is_none")] - pub interface_name: Option, - - /// IP address of the peer. - #[serde(skip_serializing_if = "Option::is_none")] - pub peer_ip_address: Option, - - /// Peer BGP ASN. - #[serde(skip_serializing_if = "Option::is_none")] - pub peer_asn: Option, - - /// Advertise mode for this BGP peer. - #[serde(skip_serializing_if = "Option::is_none")] - pub advertise_mode: Option, - - /// Advertised groups for this peer. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub advertised_groups: Vec, - - /// Advertised IP ranges for this peer. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub advertised_ip_ranges: Vec, - - /// BGP peer status. - #[serde(skip_serializing_if = "Option::is_none")] - pub management_type: Option, - - /// Whether this peer is enabled. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable: Option, - - /// Advertised route priority. - #[serde(skip_serializing_if = "Option::is_none")] - pub advertised_route_priority: Option, -} - -/// Management type for a BGP peer. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ManagementType { - /// Peer is managed by the user. - ManagedByUser, - /// Peer is managed by an attachment. - ManagedByAttachment, -} - -/// Router interface configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterInterface { - /// Name of this interface. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// IP range for this interface (CIDR format). - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_range: Option, - - /// URL of the linked VPN tunnel. - #[serde(skip_serializing_if = "Option::is_none")] - pub linked_vpn_tunnel: Option, - - /// URL of the linked interconnect attachment. - #[serde(skip_serializing_if = "Option::is_none")] - pub linked_interconnect_attachment: Option, - - /// Management type for this interface. - #[serde(skip_serializing_if = "Option::is_none")] - pub management_type: Option, - - /// Subnetwork this interface is attached to. - #[serde(skip_serializing_if = "Option::is_none")] - pub subnetwork: Option, - - /// Private IP address for this interface. - #[serde(skip_serializing_if = "Option::is_none")] - pub private_ip_address: Option, - - /// Redundant interface for this router interface. - #[serde(skip_serializing_if = "Option::is_none")] - pub redundant_interface: Option, -} - -/// Cloud NAT configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterNat { - /// Name of this NAT configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Type of NAT (endpoint-independent or endpoint-dependent). - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// Source subnetwork IP ranges to NAT. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_subnetwork_ip_ranges_to_nat: Option, - - /// Subnetworks to NAT. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub subnetworks: Vec, - - /// NAT IP allocation option. - #[serde(skip_serializing_if = "Option::is_none")] - pub nat_ip_allocate_option: Option, - - /// NAT IPs to use. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub nat_ips: Vec, - - /// Drain NAT IPs. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub drain_nat_ips: Vec, - - /// Minimum ports per VM. - #[serde(skip_serializing_if = "Option::is_none")] - pub min_ports_per_vm: Option, - - /// Maximum ports per VM. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_ports_per_vm: Option, - - /// UDP idle timeout in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub udp_idle_timeout_sec: Option, - - /// ICMP idle timeout in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub icmp_idle_timeout_sec: Option, - - /// TCP established idle timeout in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub tcp_established_idle_timeout_sec: Option, - - /// TCP transitory idle timeout in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub tcp_transitory_idle_timeout_sec: Option, - - /// TCP time wait timeout in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub tcp_time_wait_timeout_sec: Option, - - /// Log configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub log_config: Option, - - /// Whether endpoint-independent mapping is enabled. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_endpoint_independent_mapping: Option, - - /// Whether dynamic port allocation is enabled. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_dynamic_port_allocation: Option, - - /// NAT rules. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub rules: Vec, - - /// Auto network tier for this NAT. - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_network_tier: Option, -} - -/// NAT type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NatType { - /// Public NAT. - Public, - /// Private NAT. - Private, -} - -/// Source subnetwork IP ranges to NAT. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SourceSubnetworkIpRangesToNat { - /// NAT all primary and secondary IP ranges of all subnetworks. - AllSubnetworksAllIpRanges, - /// NAT only primary IP ranges of all subnetworks. - AllSubnetworksAllPrimaryIpRanges, - /// NAT only specific subnetworks. - ListOfSubnetworks, -} - -/// NAT IP allocation option. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NatIpAllocateOption { - /// Allocate NAT IPs automatically. - AutoOnly, - /// Use manually specified NAT IPs. - ManualOnly, -} - -/// Subnetwork to NAT configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterNatSubnetworkToNat { - /// Name of the subnetwork. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Source IP ranges to NAT. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_ip_ranges_to_nat: Vec, - - /// Secondary IP range names to NAT. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub secondary_ip_range_names: Vec, -} - -/// Source IP ranges to NAT for a subnetwork. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SourceIpRangesToNat { - /// NAT all IP ranges. - AllIpRanges, - /// NAT primary IP range only. - PrimaryIpRange, - /// NAT only specified secondary IP ranges. - ListOfSecondaryIpRanges, -} - -/// NAT log configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterNatLogConfig { - /// Whether to enable logging. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable: Option, - - /// Log filter. - #[serde(skip_serializing_if = "Option::is_none")] - pub filter: Option, -} - -/// NAT log filter. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NatLogFilter { - /// Log all events. - All, - /// Log errors only. - ErrorsOnly, - /// Log translations only. - TranslationsOnly, -} - -/// NAT rule. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterNatRule { - /// Rule number. - #[serde(skip_serializing_if = "Option::is_none")] - pub rule_number: Option, - - /// Description of this rule. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Match condition. - #[serde(skip_serializing_if = "Option::is_none")] - pub r#match: Option, - - /// Action to take when the rule matches. - #[serde(skip_serializing_if = "Option::is_none")] - pub action: Option, -} - -/// NAT rule action. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterNatRuleAction { - /// Source NAT active IPs. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_nat_active_ips: Vec, - - /// Source NAT drain IPs. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_nat_drain_ips: Vec, - - /// Source NAT active ranges. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_nat_active_ranges: Vec, - - /// Source NAT drain ranges. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_nat_drain_ranges: Vec, -} - -/// Network tier. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NetworkTier { - /// Premium tier. - Premium, - /// Standard tier. - Standard, -} - -/// List of routers. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct RouterList { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// List of routers. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec, - - /// Server-defined URL for this resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Token for next page of results. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_page_token: Option, - - /// Type of resource (always "compute#routerList"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -// ============================================================================================= -// Data Structures - Firewall -// ============================================================================================= - -/// Represents a firewall rule resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Firewall { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// URL of the network this firewall applies to. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// Priority for this rule (0-65535, lower is higher priority). - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - - /// Direction of traffic (INGRESS or EGRESS). - #[serde(skip_serializing_if = "Option::is_none")] - pub direction: Option, - - /// Action (allow or deny). - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed: Vec, - - /// Denied traffic specifications. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub denied: Vec, - - /// Source IP ranges for INGRESS rules. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_ranges: Vec, - - /// Destination IP ranges for EGRESS rules. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub destination_ranges: Vec, - - /// Source tags for INGRESS rules. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_tags: Vec, - - /// Target tags for this rule. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_tags: Vec, - - /// Source service accounts for INGRESS rules. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_service_accounts: Vec, - - /// Target service accounts for this rule. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_service_accounts: Vec, - - /// Whether the rule is disabled. - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled: Option, - - /// Whether logging is enabled for this rule. - #[serde(skip_serializing_if = "Option::is_none")] - pub log_config: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#firewall"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Direction of a firewall rule. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum FirewallDirection { - /// Incoming traffic. - Ingress, - /// Outgoing traffic. - Egress, -} - -/// Allowed traffic specification. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct FirewallAllowed { - /// IP protocol (tcp, udp, icmp, esp, ah, sctp, ipip, all). - #[serde(rename = "IPProtocol", skip_serializing_if = "Option::is_none")] - pub ip_protocol: Option, - - /// Ports to allow (e.g., "80", "8080-8090"). - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ports: Vec, -} - -/// Denied traffic specification. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct FirewallDenied { - /// IP protocol (tcp, udp, icmp, esp, ah, sctp, ipip, all). - #[serde(rename = "IPProtocol", skip_serializing_if = "Option::is_none")] - pub ip_protocol: Option, - - /// Ports to deny. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ports: Vec, -} - -/// Firewall log configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct FirewallLogConfig { - /// Whether to enable logging. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable: Option, - - /// Metadata to include in logs. - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -/// Metadata configuration for firewall logs. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum FirewallLogConfigMetadata { - /// Exclude all metadata. - ExcludeAllMetadata, - /// Include all metadata. - IncludeAllMetadata, -} - -/// List of firewall rules. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct FirewallList { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// List of firewall rules. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec, - - /// Server-defined URL for this resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Token for next page of results. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_page_token: Option, - - /// Type of resource (always "compute#firewallList"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -// ============================================================================================= -// Data Structures - Health Check -// ============================================================================================= - -/// Represents a health check resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct HealthCheck { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// How often (in seconds) to send a health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub check_interval_sec: Option, - - /// How long (in seconds) to wait before claiming failure. - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout_sec: Option, - - /// Number of consecutive failures before marking unhealthy. - #[serde(skip_serializing_if = "Option::is_none")] - pub unhealthy_threshold: Option, - - /// Number of consecutive successes before marking healthy. - #[serde(skip_serializing_if = "Option::is_none")] - pub healthy_threshold: Option, - - /// Type of health check (TCP, HTTP, HTTPS, HTTP2, GRPC). - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// TCP health check configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub tcp_health_check: Option, - - /// HTTP health check configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub http_health_check: Option, - - /// HTTPS health check configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub https_health_check: Option, - - /// HTTP2 health check configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub http2_health_check: Option, - - /// GRPC health check configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub grpc_health_check: Option, - - /// Log configuration for this health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub log_config: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#healthCheck"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Health check type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum HealthCheckType { - /// TCP health check. - Tcp, - /// HTTP health check. - Http, - /// HTTPS health check. - Https, - /// HTTP/2 health check. - Http2, - /// gRPC health check. - Grpc, -} - -/// TCP health check configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct TcpHealthCheck { - /// Port number for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Port name for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_name: Option, - - /// Port specification type. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_specification: Option, - - /// Proxy header type. - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_header: Option, - - /// Request data to send. - #[serde(skip_serializing_if = "Option::is_none")] - pub request: Option, - - /// Expected response data. - #[serde(skip_serializing_if = "Option::is_none")] - pub response: Option, -} - -/// HTTP health check configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct HttpHealthCheck { - /// Port number for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Port name for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_name: Option, - - /// Port specification type. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_specification: Option, - - /// Host header for the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - - /// Request path for the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_path: Option, - - /// Proxy header type. - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_header: Option, - - /// Expected response data. - #[serde(skip_serializing_if = "Option::is_none")] - pub response: Option, -} - -/// HTTPS health check configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct HttpsHealthCheck { - /// Port number for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Port name for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_name: Option, - - /// Port specification type. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_specification: Option, - - /// Host header for the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - - /// Request path for the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_path: Option, - - /// Proxy header type. - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_header: Option, - - /// Expected response data. - #[serde(skip_serializing_if = "Option::is_none")] - pub response: Option, -} - -/// HTTP/2 health check configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Http2HealthCheck { - /// Port number for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Port name for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_name: Option, - - /// Port specification type. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_specification: Option, - - /// Host header for the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - - /// Request path for the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_path: Option, - - /// Proxy header type. - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_header: Option, - - /// Expected response data. - #[serde(skip_serializing_if = "Option::is_none")] - pub response: Option, -} - -/// gRPC health check configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct GrpcHealthCheck { - /// Port number for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Port name for health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_name: Option, - - /// Port specification type. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_specification: Option, - - /// gRPC service name. - #[serde(skip_serializing_if = "Option::is_none")] - pub grpc_service_name: Option, -} - -/// Port specification type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum PortSpecification { - /// Use a fixed port number. - UseFixedPort, - /// Use a named port. - UseNamedPort, - /// Use the serving port. - UseServingPort, -} - -/// Proxy header type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ProxyHeader { - /// No proxy header. - None, - /// PROXY_V1 header. - ProxyV1, -} - -/// Health check log configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct HealthCheckLogConfig { - /// Whether to enable logging. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable: Option, -} - -// ============================================================================================= -// Data Structures - Backend Service -// ============================================================================================= - -/// Represents a backend service resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct BackendService { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// List of backends. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub backends: Vec, - - /// Health check URLs for this backend service. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub health_checks: Vec, - - /// Timeout in seconds for backend responses. - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout_sec: Option, - - /// Port number used for communication with backends. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Protocol used to communicate with backends. - #[serde(skip_serializing_if = "Option::is_none")] - pub protocol: Option, - - /// Port name for backends. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_name: Option, - - /// Load balancing scheme. - #[serde(skip_serializing_if = "Option::is_none")] - pub load_balancing_scheme: Option, - - /// Session affinity configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub session_affinity: Option, - - /// Affinity cookie TTL in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub affinity_cookie_ttl_sec: Option, - - /// Connection draining configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub connection_draining: Option, - - /// Fingerprint of this resource (for optimistic locking). - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Whether to enable CDN for this backend service. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_c_d_n: Option, - - /// CDN policy configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub cdn_policy: Option, - - /// Log configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub log_config: Option, - - /// Security policy URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub security_policy: Option, - - /// Locality load balancing policy. - #[serde(skip_serializing_if = "Option::is_none")] - pub locality_lb_policy: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#backendService"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Backend configuration for a backend service. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Backend { - /// URL of the backend group (instance group or NEG). - #[serde(skip_serializing_if = "Option::is_none")] - pub group: Option, - - /// Balancing mode (UTILIZATION, RATE, CONNECTION). - #[serde(skip_serializing_if = "Option::is_none")] - pub balancing_mode: Option, - - /// Capacity scaler (0.0 to 1.0). - #[serde(skip_serializing_if = "Option::is_none")] - pub capacity_scaler: Option, - - /// Maximum connections for this backend. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_connections: Option, - - /// Maximum connections per instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_connections_per_instance: Option, - - /// Maximum connections per endpoint. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_connections_per_endpoint: Option, - - /// Maximum rate for this backend. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_rate: Option, - - /// Maximum rate per instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_rate_per_instance: Option, - - /// Maximum rate per endpoint. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_rate_per_endpoint: Option, - - /// Maximum CPU utilization for this backend. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_utilization: Option, - - /// Description of this backend. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Balancing mode for a backend. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum BalancingMode { - /// Balance by CPU utilization. - Utilization, - /// Balance by request rate. - Rate, - /// Balance by connection count. - Connection, -} - -/// Backend service protocol. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum BackendServiceProtocol { - /// HTTP protocol. - Http, - /// HTTPS protocol. - Https, - /// HTTP/2 protocol. - Http2, - /// TCP protocol. - Tcp, - /// SSL protocol. - Ssl, - /// gRPC protocol. - Grpc, - /// Unspecified protocol. - Unspecified, -} - -/// Load balancing scheme. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum LoadBalancingScheme { - /// External load balancing. - External, - /// Internal load balancing. - Internal, - /// Internal self-managed load balancing. - InternalSelfManaged, - /// Internal managed load balancing. - InternalManaged, - /// External managed load balancing. - ExternalManaged, -} - -/// Session affinity type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SessionAffinity { - /// No session affinity. - None, - /// Client IP affinity. - ClientIp, - /// Generated cookie affinity. - GeneratedCookie, - /// Client IP with proto affinity. - ClientIpProto, - /// Client IP and port affinity. - ClientIpPortProto, - /// HTTP cookie affinity. - HttpCookie, - /// Header field affinity. - HeaderField, -} - -/// Connection draining configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ConnectionDraining { - /// Time in seconds to wait for connections to drain. - #[serde(skip_serializing_if = "Option::is_none")] - pub draining_timeout_sec: Option, -} - -/// Backend service CDN policy. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct BackendServiceCdnPolicy { - /// Cache mode. - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_mode: Option, - - /// Signed URL cache max age in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub signed_url_cache_max_age_sec: Option, - - /// Default TTL in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub default_ttl: Option, - - /// Maximum TTL in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_ttl: Option, - - /// Client TTL in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub client_ttl: Option, - - /// Whether to serve stale content while revalidating. - #[serde(skip_serializing_if = "Option::is_none")] - pub serve_while_stale: Option, - - /// Negative caching policy. - #[serde(skip_serializing_if = "Option::is_none")] - pub negative_caching: Option, -} - -/// Cache mode. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum CacheMode { - /// Use origin headers. - UseOriginHeaders, - /// Force cache all. - ForceCacheAll, - /// Cache all static content. - CacheAllStatic, -} - -/// Backend service log configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct BackendServiceLogConfig { - /// Whether to enable logging. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable: Option, - - /// Sample rate (0.0 to 1.0). - #[serde(skip_serializing_if = "Option::is_none")] - pub sample_rate: Option, -} - -/// Locality load balancing policy. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum LocalityLbPolicy { - /// Round robin. - RoundRobin, - /// Least request. - LeastRequest, - /// Ring hash. - RingHash, - /// Random. - Random, - /// Original destination. - OriginalDestination, - /// Maglev. - Maglev, -} - -// ============================================================================================= -// Data Structures - URL Map -// ============================================================================================= - -/// Represents a URL map resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct UrlMap { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Default backend service URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub default_service: Option, - - /// Host rules for this URL map. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub host_rules: Vec, - - /// Path matchers for this URL map. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub path_matchers: Vec, - - /// Fingerprint of this resource (for optimistic locking). - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#urlMap"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Host rule for URL map. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct HostRule { - /// Description of this rule. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// List of hosts to match. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hosts: Vec, - - /// Name of the path matcher to use. - #[serde(skip_serializing_if = "Option::is_none")] - pub path_matcher: Option, -} - -/// Path matcher for URL map. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct PathMatcher { - /// Name of this path matcher. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Description of this path matcher. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Default backend service URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub default_service: Option, - - /// Path rules for this matcher. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub path_rules: Vec, -} - -/// Path rule for URL map. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct PathRule { - /// Paths to match. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub paths: Vec, - - /// Backend service URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub service: Option, -} - -// ============================================================================================= -// Data Structures - Target HTTP Proxy -// ============================================================================================= - -/// Represents a target HTTP proxy resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct TargetHttpProxy { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// URL of the URL map associated with this proxy. - #[serde(skip_serializing_if = "Option::is_none")] - pub url_map: Option, - - /// Fingerprint of this resource (for optimistic locking). - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Whether to proxy WebSocket requests. - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_bind: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#targetHttpProxy"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -// ============================================================================================= -// Data Structures - Target HTTPS Proxy -// ============================================================================================= - -/// Represents a target HTTPS proxy resource (with SSL certificate support). -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct TargetTcpProxy { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub service: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_header: Option, -} - -/// Global target HTTPS proxy. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct TargetHttpsProxy { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// URL of the URL map associated with this proxy. - #[serde(skip_serializing_if = "Option::is_none")] - pub url_map: Option, - - /// URLs of SSL certificates associated with this proxy. - /// At least one SSL certificate must be specified. - #[serde(skip_serializing_if = "Option::is_none")] - pub ssl_certificates: Option>, - - /// Fingerprint of this resource (for optimistic locking). - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Whether to proxy WebSocket requests. - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_bind: Option, - - /// Minimum TLS version (e.g., "TLS_1_2"). - #[serde(skip_serializing_if = "Option::is_none")] - pub ssl_policy: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#targetHttpsProxy"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - - /// QUIC protocol override (e.g., "NONE", "ENABLE", "DISABLE"). - #[serde(skip_serializing_if = "Option::is_none")] - pub quic_override: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct SetSslCertificatesRequest { - pub ssl_certificates: Vec, -} - -// ============================================================================================= -// Data Structures - SSL Certificate -// ============================================================================================= - -/// Self-managed SSL certificate details. -/// Used when SslCertificate.type = "SELF_MANAGED". -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates#SslCertificateSelfManagedSslCertificate -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct SslCertificateSelfManaged { - /// PEM-encoded X.509 certificate chain. - /// The chain must be no greater than 5 certificates long. - #[serde(skip_serializing_if = "Option::is_none")] - pub certificate: Option, - - /// PEM-encoded private key. Write-only; never returned in GET responses. - #[serde(skip_serializing_if = "Option::is_none")] - pub private_key: Option, -} - -/// Represents an SSL certificate resource for load balancers. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct SslCertificate { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Type of certificate ("SELF_MANAGED" or "MANAGED"). - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// Self-managed certificate details. - /// Must be populated when type = "SELF_MANAGED". - #[serde(skip_serializing_if = "Option::is_none")] - pub self_managed: Option, - - /// Domains covered by this certificate (output only). - #[serde(skip_serializing_if = "Option::is_none")] - pub subject_alternative_names: Option>, - - /// Expiration timestamp (RFC3339, output only). - #[serde(skip_serializing_if = "Option::is_none")] - pub expire_time: Option, - - /// Creation timestamp (RFC3339, output only). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#sslCertificate"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -// ============================================================================================= -// Data Structures - Global Address -// ============================================================================================= - -/// Represents a global address resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Address { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// The static IP address represented by this resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub address: Option, - - /// The type of address (EXTERNAL or INTERNAL). - #[serde(skip_serializing_if = "Option::is_none")] - pub address_type: Option, - - /// IP version (IPV4 or IPV6). - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_version: Option, - - /// Purpose of the address. - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option, - - /// Network tier for this address. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_tier: Option, - - /// Status of the address (RESERVED, IN_USE, etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// URL of the resource using this address. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub users: Vec, - - /// Prefix length for IPv6 addresses. - #[serde(skip_serializing_if = "Option::is_none")] - pub prefix_length: Option, - - /// Network URL for internal addresses. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// Subnetwork URL for internal addresses. - #[serde(skip_serializing_if = "Option::is_none")] - pub subnetwork: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#address"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Address type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AddressType { - /// External address. - External, - /// Internal address. - Internal, -} - -/// IP version. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum IpVersion { - /// IPv4. - Ipv4, - /// IPv6. - Ipv6, -} - -/// Address purpose. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AddressPurpose { - /// GCE endpoint. - GceEndpoint, - /// VPC peering. - VpcPeering, - /// Private service connect. - PrivateServiceConnect, - /// NAT auto. - NatAuto, - /// Shared loadbalancer VIP. - SharedLoadbalancerVip, - /// DNS resolver. - DnsResolver, -} - -/// Address status. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AddressStatus { - /// Address is reserved. - Reserved, - /// Address is reserved but being used. - Reserving, - /// Address is in use. - InUse, -} - -// ============================================================================================= -// Data Structures - Global Forwarding Rule -// ============================================================================================= - -/// Represents a forwarding rule resource (global or regional). -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ForwardingRule { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// IP address for this forwarding rule. - #[serde(rename = "IPAddress", skip_serializing_if = "Option::is_none")] - pub ip_address: Option, - - /// IP protocol for this forwarding rule. - #[serde(rename = "IPProtocol", skip_serializing_if = "Option::is_none")] - pub ip_protocol: Option, - - /// Port range for this forwarding rule. - #[serde(skip_serializing_if = "Option::is_none")] - pub port_range: Option, - - /// List of ports for this forwarding rule. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ports: Vec, - - /// URL of the target resource. For a Private Service Connect consumer - /// endpoint this is the producer's service-attachment URI. - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option, - - /// URL of the network this forwarding rule belongs to. Required for a - /// Private Service Connect consumer endpoint, which lives in the consumer VPC. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// URL of the subnetwork this forwarding rule draws its internal IP from. - /// Used by internal forwarding rules such as Private Service Connect endpoints. - #[serde(skip_serializing_if = "Option::is_none")] - pub subnetwork: Option, - - /// Load balancing scheme. Left unset for a Private Service Connect consumer - /// endpoint, which is not a load balancer. - #[serde(skip_serializing_if = "Option::is_none")] - pub load_balancing_scheme: Option, - - /// Network tier for this forwarding rule. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_tier: Option, - - /// Fingerprint of this resource (for optimistic locking). - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#forwardingRule"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Forwarding rule IP protocol. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ForwardingRuleProtocol { - /// TCP protocol. - Tcp, - /// UDP protocol. - Udp, - /// ESP protocol. - Esp, - /// AH protocol. - Ah, - /// SCTP protocol. - Sctp, - /// ICMP protocol. - Icmp, - /// L3 default protocol. - L3Default, -} - -// ============================================================================================= -// Data Structures - Network Endpoint Group (NEG) -// ============================================================================================= - -/// Represents a network endpoint group resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpointGroup { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Type of network endpoint group. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_endpoint_type: Option, - - /// Size of the network endpoint group. - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, - - /// URL of the network to which this NEG belongs. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// URL of the subnetwork to which this NEG belongs. - #[serde(skip_serializing_if = "Option::is_none")] - pub subnetwork: Option, - - /// URL of the zone where the NEG is located. - #[serde(skip_serializing_if = "Option::is_none")] - pub zone: Option, - - /// Default port for endpoints. - #[serde(skip_serializing_if = "Option::is_none")] - pub default_port: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#networkEndpointGroup"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - - /// Cloud Run service configuration for serverless NEG. - /// Only valid when network_endpoint_type is SERVERLESS. - /// Only one of cloud_run, app_engine, or cloud_function may be set. - #[serde(skip_serializing_if = "Option::is_none")] - pub cloud_run: Option, - - /// App Engine service configuration for serverless NEG. - /// Only valid when network_endpoint_type is SERVERLESS. - /// Only one of cloud_run, app_engine, or cloud_function may be set. - #[serde(skip_serializing_if = "Option::is_none")] - pub app_engine: Option, - - /// Cloud Function configuration for serverless NEG. - /// Only valid when network_endpoint_type is SERVERLESS. - /// Only one of cloud_run, app_engine, or cloud_function may be set. - #[serde(skip_serializing_if = "Option::is_none")] - pub cloud_function: Option, -} - -/// Cloud Run service configuration for a serverless NEG. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpointGroupCloudRun { - /// Cloud Run service name. - /// Example: "my-service" - #[serde(skip_serializing_if = "Option::is_none")] - pub service: Option, - - /// Cloud Run service tag (optional). - /// Example: "v1", "production" - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - - /// URL mask for routing to multiple Cloud Run services. - /// Example: ".domain.com/" allows routing based on URL patterns. - #[serde(skip_serializing_if = "Option::is_none")] - pub url_mask: Option, -} - -/// App Engine service configuration for a serverless NEG. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpointGroupAppEngine { - /// App Engine service name (optional). - /// The service name is case-sensitive and must be 1-63 characters long. - /// Example: "default", "my-service" - #[serde(skip_serializing_if = "Option::is_none")] - pub service: Option, - - /// App Engine version (optional). - /// The version name is case-sensitive and must be 1-100 characters long. - /// Example: "v1", "v2" - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - - /// URL mask for routing to multiple App Engine services. - /// Example: "-dot-appname.appspot.com/" - #[serde(skip_serializing_if = "Option::is_none")] - pub url_mask: Option, -} - -/// Cloud Function configuration for a serverless NEG. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpointGroupCloudFunction { - /// Cloud Function name. - /// The function name is case-sensitive and must be 1-63 characters long. - /// Example: "func1" - #[serde(skip_serializing_if = "Option::is_none")] - pub function: Option, - - /// URL mask for routing to multiple Cloud Functions. - /// Example: "/" allows routing based on URL patterns. - #[serde(skip_serializing_if = "Option::is_none")] - pub url_mask: Option, -} - -/// Network endpoint type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NetworkEndpointType { - /// GCE VM IP port endpoint. - GceVmIpPort, - /// Non-GCP private IP port endpoint. - NonGcpPrivateIpPort, - /// Internet IP port endpoint. - InternetIpPort, - /// Internet FQDN port endpoint. - InternetFqdnPort, - /// Serverless endpoint. - Serverless, - /// Private service connect endpoint. - PrivateServiceConnect, -} - -/// Request to attach network endpoints. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpointGroupsAttachEndpointsRequest { - /// Network endpoints to attach. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub network_endpoints: Vec, -} - -/// Request to detach network endpoints. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpointGroupsDetachEndpointsRequest { - /// Network endpoints to detach. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub network_endpoints: Vec, -} - -/// Network endpoint. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkEndpoint { - /// IP address of the endpoint. - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_address: Option, - - /// Port number for the endpoint. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Instance that the endpoint belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance: Option, - - /// FQDN of the endpoint. - #[serde(skip_serializing_if = "Option::is_none")] - pub fqdn: Option, -} - -// ============================================================================================= -// Data Structures - Instance Template -// ============================================================================================= - -/// Represents an instance template resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceTemplate { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Instance properties for instances created from this template. - #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#instanceTemplate"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Properties for instances created from a template. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceProperties { - /// Machine type for instances. - #[serde(skip_serializing_if = "Option::is_none")] - pub machine_type: Option, - - /// Description of instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Disks attached to instances. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub disks: Vec, - - /// Network interfaces for instances. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub network_interfaces: Vec, - - /// Metadata for instances. - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, - - /// Service accounts for instances. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub service_accounts: Vec, - - /// Tags for instances. - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option, - - /// Scheduling configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub scheduling: Option, - - /// Labels for instances. - #[builder(default)] - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - pub labels: std::collections::HashMap, - - /// Whether to allow stopping for update. - #[serde(skip_serializing_if = "Option::is_none")] - pub can_ip_forward: Option, - - /// Guest accelerators for instances. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub guest_accelerators: Vec, - - /// Shielded instance configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub shielded_instance_config: Option, - - /// Confidential instance configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub confidential_instance_config: Option, -} - -/// Attached disk configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct AttachedDisk { - /// Type of attachment (PERSISTENT, SCRATCH). - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// Mode of disk (READ_WRITE, READ_ONLY). - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, - - /// Source disk URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - - /// Device name. - #[serde(skip_serializing_if = "Option::is_none")] - pub device_name: Option, - - /// Boot disk indicator. - #[serde(skip_serializing_if = "Option::is_none")] - pub boot: Option, - - /// Initialize parameters for new disks. - #[serde(skip_serializing_if = "Option::is_none")] - pub initialize_params: Option, - - /// Whether to auto-delete the disk. - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_delete: Option, - - /// Index of the disk. - #[serde(skip_serializing_if = "Option::is_none")] - pub index: Option, - - /// Disk interface (SCSI, NVME). - #[serde(skip_serializing_if = "Option::is_none")] - pub interface: Option, -} - -/// Attached disk type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AttachedDiskType { - /// Persistent disk. - Persistent, - /// Scratch disk. - Scratch, -} - -/// Disk mode. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum DiskMode { - /// Read-write mode. - ReadWrite, - /// Read-only mode. - ReadOnly, -} - -/// Disk interface. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum DiskInterface { - /// SCSI interface. - Scsi, - /// NVMe interface. - Nvme, -} - -/// Parameters for initializing a new disk. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct AttachedDiskInitializeParams { - /// Name for the disk. - #[serde(skip_serializing_if = "Option::is_none")] - pub disk_name: Option, - - /// Source image URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_image: Option, - - /// Disk size in GB. - #[serde(skip_serializing_if = "Option::is_none")] - pub disk_size_gb: Option, - - /// Disk type URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub disk_type: Option, - - /// Source snapshot URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_snapshot: Option, - - /// Labels for the disk. - #[builder(default)] - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - pub labels: std::collections::HashMap, -} - -/// Network interface configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NetworkInterface { - /// Network URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub network: Option, - - /// Subnetwork URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub subnetwork: Option, - - /// Network IP address. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_i_p: Option, - - /// Name of the interface. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Access configurations for external IPs. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub access_configs: Vec, - - /// Alias IP ranges. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub alias_ip_ranges: Vec, - - /// Fingerprint for optimistic locking. - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Stack type for this interface. - #[serde(skip_serializing_if = "Option::is_none")] - pub stack_type: Option, - - /// Network interface card type. - #[serde(skip_serializing_if = "Option::is_none")] - pub nic_type: Option, -} - -/// Access configuration for external IP. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct AccessConfig { - /// Type of access config. - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// Name of the access config. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// External IP address. - #[serde(skip_serializing_if = "Option::is_none")] - pub nat_i_p: Option, - - /// Network tier. - #[serde(skip_serializing_if = "Option::is_none")] - pub network_tier: Option, -} - -/// Access config type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AccessConfigType { - /// One-to-one NAT. - OneToOneNat, - /// Direct IPv6 access. - DirectIpv6, -} - -/// Alias IP range. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct AliasIpRange { - /// IP CIDR range. - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_cidr_range: Option, - - /// Subnetwork range name. - #[serde(skip_serializing_if = "Option::is_none")] - pub subnetwork_range_name: Option, -} - -/// NIC type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum NicType { - /// Virtio NET. - VirtioNet, - /// gVNIC. - Gvnic, -} - -/// Metadata configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Metadata { - /// Fingerprint for optimistic locking. - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Metadata items. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec, - - /// Type of resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Metadata item. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct MetadataItem { - /// Key of the metadata item. - #[serde(skip_serializing_if = "Option::is_none")] - pub key: Option, - - /// Value of the metadata item. - #[serde(skip_serializing_if = "Option::is_none")] - pub value: Option, -} - -/// Service account configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ServiceAccount { - /// Email address of the service account. - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, - - /// OAuth scopes. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scopes: Vec, -} - -/// Tags configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Tags { - /// Fingerprint for optimistic locking. - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Tag items. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec, -} - -/// Scheduling configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Scheduling { - /// On host maintenance behavior. - #[serde(skip_serializing_if = "Option::is_none")] - pub on_host_maintenance: Option, - - /// Automatic restart enabled. - #[serde(skip_serializing_if = "Option::is_none")] - pub automatic_restart: Option, - - /// Whether this is a preemptible instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub preemptible: Option, - - /// Provisioning model. - #[serde(skip_serializing_if = "Option::is_none")] - pub provisioning_model: Option, -} - -/// On host maintenance behavior. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum OnHostMaintenance { - /// Migrate during maintenance. - Migrate, - /// Terminate during maintenance. - Terminate, -} - -/// Provisioning model. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ProvisioningModel { - /// Standard provisioning. - Standard, - /// Spot provisioning. - Spot, -} - -/// Accelerator configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct AcceleratorConfig { - /// Type of accelerator. - #[serde(skip_serializing_if = "Option::is_none")] - pub accelerator_type: Option, - - /// Number of accelerators. - #[serde(skip_serializing_if = "Option::is_none")] - pub accelerator_count: Option, -} - -/// Shielded instance configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ShieldedInstanceConfig { - /// Enable secure boot. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_secure_boot: Option, - - /// Enable vTPM. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_vtpm: Option, - - /// Enable integrity monitoring. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_integrity_monitoring: Option, -} - -/// Confidential instance configuration. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ConfidentialInstanceConfig { - /// Enable confidential compute. - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_confidential_compute: Option, -} - -// ============================================================================================= -// Data Structures - Instance Group Manager -// ============================================================================================= - -/// Represents an instance group manager resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManager { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// URL of the managed instance group. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_group: Option, - - /// URL of the instance template. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_template: Option, - - /// Target size of the managed instance group. - #[serde(skip_serializing_if = "Option::is_none")] - pub target_size: Option, - - /// Base instance name. - #[serde(skip_serializing_if = "Option::is_none")] - pub base_instance_name: Option, - - /// Current actions summary. - #[serde(skip_serializing_if = "Option::is_none")] - pub current_actions: Option, - - /// Status of the managed instance group. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// Target pools for this manager. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_pools: Vec, - - /// Named ports for this manager. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub named_ports: Vec, - - /// Fingerprint for optimistic locking. - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Zone URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub zone: Option, - - /// Auto healing policies. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub auto_healing_policies: Vec, - - /// Update policy. - #[serde(skip_serializing_if = "Option::is_none")] - pub update_policy: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#instanceGroupManager"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Summary of instance group manager actions. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerActionsSummary { - /// Number of instances currently being created. - #[serde(skip_serializing_if = "Option::is_none")] - pub creating: Option, - - /// Number of instances currently being deleted. - #[serde(skip_serializing_if = "Option::is_none")] - pub deleting: Option, - - /// Number of instances that exist and are running. - #[serde(skip_serializing_if = "Option::is_none")] - pub none: Option, - - /// Number of instances currently being recreated. - #[serde(skip_serializing_if = "Option::is_none")] - pub recreating: Option, - - /// Number of instances currently being refreshed. - #[serde(skip_serializing_if = "Option::is_none")] - pub refreshing: Option, - - /// Number of instances currently being restarted. - #[serde(skip_serializing_if = "Option::is_none")] - pub restarting: Option, - - /// Number of instances currently being verified. - #[serde(skip_serializing_if = "Option::is_none")] - pub verifying: Option, - - /// Number of instances currently being abandoned. - #[serde(skip_serializing_if = "Option::is_none")] - pub abandoning: Option, - - /// Number of instances in a creating without retries state. - #[serde(skip_serializing_if = "Option::is_none")] - pub creating_without_retries: Option, -} - -/// Status of an instance group manager. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerStatus { - /// Whether the group is stable. - #[serde(skip_serializing_if = "Option::is_none")] - pub is_stable: Option, - - /// Stateful status. - #[serde(skip_serializing_if = "Option::is_none")] - pub stateful: Option, - - /// Version target status. - #[serde(skip_serializing_if = "Option::is_none")] - pub version_target: Option, -} - -/// Stateful status for instance group manager. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerStatusStateful { - /// Whether there are stateful instances. - #[serde(skip_serializing_if = "Option::is_none")] - pub has_stateful_config: Option, - - /// Whether per-instance configs exist. - #[serde(skip_serializing_if = "Option::is_none")] - pub per_instance_configs: Option, -} - -/// Per-instance configs status. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerStatusStatefulPerInstanceConfigs { - /// Whether all configs are effective. - #[serde(skip_serializing_if = "Option::is_none")] - pub all_effective: Option, -} - -/// Version target status. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerStatusVersionTarget { - /// Whether the version target has been reached. - #[serde(skip_serializing_if = "Option::is_none")] - pub is_reached: Option, -} - -/// Named port for instance group. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct NamedPort { - /// Name of the port. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Port number. - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, -} - -/// Auto healing policy for instance group manager. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerAutoHealingPolicy { - /// Health check URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub health_check: Option, - - /// Initial delay in seconds. - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_delay_sec: Option, -} - -/// Update policy for instance group manager. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagerUpdatePolicy { - /// Type of update. - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// Minimal action for updates. - #[serde(skip_serializing_if = "Option::is_none")] - pub minimal_action: Option, - - /// Most disruptive action allowed. - #[serde(skip_serializing_if = "Option::is_none")] - pub most_disruptive_allowed_action: Option, - - /// Maximum surge instances (fixed or percent). - #[serde(skip_serializing_if = "Option::is_none")] - pub max_surge: Option, - - /// Maximum unavailable instances (fixed or percent). - #[serde(skip_serializing_if = "Option::is_none")] - pub max_unavailable: Option, - - /// Replacement method. - #[serde(skip_serializing_if = "Option::is_none")] - pub replacement_method: Option, -} - -/// Update policy type. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum UpdatePolicyType { - /// Opportunistic update. - Opportunistic, - /// Proactive update. - Proactive, -} - -/// Minimal action for updates. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum MinimalAction { - /// No action. - None, - /// Refresh instance. - Refresh, - /// Restart instance. - Restart, - /// Replace instance. - Replace, -} - -/// Fixed or percent value. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct FixedOrPercent { - /// Fixed value. - #[serde(skip_serializing_if = "Option::is_none")] - pub fixed: Option, - - /// Percentage value. - #[serde(skip_serializing_if = "Option::is_none")] - pub percent: Option, - - /// Calculated value (output only). - #[serde(skip_serializing_if = "Option::is_none")] - pub calculated: Option, -} - -/// Replacement method. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ReplacementMethod { - /// Substitute replacement. - Substitute, - /// Recreate replacement. - Recreate, -} - -/// Response for list managed instances. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagersListManagedInstancesResponse { - /// List of managed instances. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub managed_instances: Vec, - - /// Next page token. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_page_token: Option, -} - -/// Request to delete selected managed instances from an instance group manager. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct InstanceGroupManagersDeleteInstancesRequest { - /// Instance URLs to delete from the managed instance group. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub instances: Vec, - - /// Continue when valid instances are mixed with already-deleting or non-member instances. - #[serde(skip_serializing_if = "Option::is_none")] - pub skip_instances_on_validation_error: Option, -} - -/// Managed instance in an instance group. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstance { - /// URL of the instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance: Option, - - /// Instance status. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_status: Option, - - /// Current action. - #[serde(skip_serializing_if = "Option::is_none")] - pub current_action: Option, - - /// Last attempt status. - #[serde(skip_serializing_if = "Option::is_none")] - pub last_attempt: Option, - - /// Unique identifier. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Version of the instance template. - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - - /// Health check results for this instance (populated when a health check is attached to the MIG). - /// JSON field: instanceHealth - #[serde( - default, - rename = "instanceHealth", - skip_serializing_if = "Vec::is_empty" - )] - pub instance_health: Vec, -} - -/// Health state of a managed instance as reported by a health check. -/// Returned in `ManagedInstance.instanceHealth[]` by listManagedInstances. -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceHealth { - /// URL of the health check. - #[serde(skip_serializing_if = "Option::is_none")] - pub health_check: Option, - - /// Detailed health state of the instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub detailed_health_state: Option, -} - -/// Detailed health state values for a managed instance. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ManagedInstanceDetailedHealthState { - /// The instance is reachable and health check responded with HEALTHY. - Healthy, - /// The health check responded with UNHEALTHY. - Unhealthy, - /// The instance is being drained and will not accept new connections. - Draining, - /// The health check timed out. - Timeout, - /// The health state is unknown (e.g., health check not yet run). - Unknown, -} - -/// Status of a managed instance. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ManagedInstanceStatus { - /// Instance is running. - Running, - /// Instance is pending. - Pending, - /// Instance is provisioning. - Provisioning, - /// Instance is staging. - Staging, - /// Instance is stopped. - Stopped, - /// Instance is stopping. - Stopping, - /// Instance is suspended. - Suspended, - /// Instance is suspending. - Suspending, - /// Instance is terminated. - Terminated, -} - -/// Current action on a managed instance. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ManagedInstanceCurrentAction { - /// No action. - None, - /// Creating instance. - Creating, - /// Creating without retries. - CreatingWithoutRetries, - /// Recreating instance. - Recreating, - /// Deleting instance. - Deleting, - /// Abandoning instance. - Abandoning, - /// Restarting instance. - Restarting, - /// Refreshing instance. - Refreshing, - /// Verifying instance. - Verifying, -} - -/// Last attempt status for a managed instance. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceLastAttempt { - /// Errors from last attempt. - #[serde(skip_serializing_if = "Option::is_none")] - pub errors: Option, -} - -/// Errors from last attempt. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceLastAttemptErrors { - /// List of errors. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub errors: Vec, -} - -/// Individual error from last attempt. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceLastAttemptErrorsErrors { - /// Error code. - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, - - /// Error message. - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, - - /// Structured error details, including quota information when present. - #[builder(default)] - #[serde( - default, - rename = "errorDetails", - skip_serializing_if = "Vec::is_empty" - )] - pub error_details: Vec, -} - -/// Structured details for a managed instance last-attempt error. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceLastAttemptErrorDetail { - /// Quota details for quota-related errors. - #[serde(skip_serializing_if = "Option::is_none")] - pub quota_info: Option, - /// Localized error message. - #[serde(skip_serializing_if = "Option::is_none")] - pub localized_message: Option, -} - -/// Quota details for a managed instance last-attempt error. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceLastAttemptQuotaInfo { - /// Compute Engine quota metric name. - #[serde(skip_serializing_if = "Option::is_none")] - pub metric_name: Option, - /// Compute Engine quota limit name. - #[serde(skip_serializing_if = "Option::is_none")] - pub limit_name: Option, - /// Current effective quota limit. - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - /// Future quota limit being rolled out. - #[serde(skip_serializing_if = "Option::is_none")] - pub future_limit: Option, -} - -/// Localized message for a managed instance last-attempt error. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceLastAttemptLocalizedMessage { - /// Message locale. - #[serde(skip_serializing_if = "Option::is_none")] - pub locale: Option, - /// Localized message text. - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -/// Version information for a managed instance. -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct ManagedInstanceVersion { - /// Instance template URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_template: Option, - - /// Version name. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -// ============================================================================================= -// Data Structures - Instance -// ============================================================================================= - -/// Represents a compute instance resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Instance { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Machine type URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub machine_type: Option, - - /// Status of the instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// Zone URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub zone: Option, - - /// Disks attached to this instance. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub disks: Vec, - - /// Network interfaces for this instance. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub network_interfaces: Vec, - - /// Metadata for this instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, - - /// Service accounts for this instance. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub service_accounts: Vec, - - /// Tags for this instance. - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option, - - /// Scheduling configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub scheduling: Option, - - /// Labels for this instance. - #[builder(default)] - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - pub labels: std::collections::HashMap, - - /// Whether IP forwarding is allowed. - #[serde(skip_serializing_if = "Option::is_none")] - pub can_ip_forward: Option, - - /// CPU platform. - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_platform: Option, - - /// Guest accelerators for this instance. - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub guest_accelerators: Vec, - - /// Shielded instance configuration. - #[serde(skip_serializing_if = "Option::is_none")] - pub shielded_instance_config: Option, - - /// Fingerprint for optimistic locking. - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Last start timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub last_start_timestamp: Option, - - /// Last stop timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub last_stop_timestamp: Option, - - /// Type of resource (always "compute#instance"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Instance status. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum InstanceStatus { - /// Instance is running. - Running, - /// Instance is provisioning. - Provisioning, - /// Instance is staging. - Staging, - /// Instance is stopped. - Stopped, - /// Instance is stopping. - Stopping, - /// Instance is suspended. - Suspended, - /// Instance is suspending. - Suspending, - /// Instance is terminated. - Terminated, - /// Instance is pending. - Pending, -} - -// ============================================================================================= -// Data Structures - Disk -// ============================================================================================= - -/// Represents a persistent disk resource. -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks -#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] -#[serde(rename_all = "camelCase")] -pub struct Disk { - /// Unique identifier; defined by the server. - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - - /// Name of the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// Optional description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Server-defined URL for the resource. - #[serde(skip_serializing_if = "Option::is_none")] - pub self_link: Option, - - /// Size of the disk in GB. - #[serde(skip_serializing_if = "Option::is_none")] - pub size_gb: Option, - - /// Zone URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub zone: Option, - - /// Status of the disk. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - - /// Source snapshot URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_snapshot: Option, - - /// Source snapshot ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_snapshot_id: Option, - - /// Source image URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_image: Option, - - /// Source image ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_image_id: Option, - - /// Disk type URL. - #[serde(skip_serializing_if = "Option::is_none")] - pub r#type: Option, - - /// Users of this disk (instance URLs). - #[builder(default)] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub users: Vec, - - /// Labels for this disk. - #[builder(default)] - #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] - pub labels: std::collections::HashMap, - - /// Label fingerprint for optimistic locking. - #[serde(skip_serializing_if = "Option::is_none")] - pub label_fingerprint: Option, - - /// Physical block size in bytes. - #[serde(skip_serializing_if = "Option::is_none")] - pub physical_block_size_bytes: Option, - - /// Provisioned IOPS. - #[serde(skip_serializing_if = "Option::is_none")] - pub provisioned_iops: Option, - - /// Provisioned throughput. - #[serde(skip_serializing_if = "Option::is_none")] - pub provisioned_throughput: Option, - - /// Last attach timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub last_attach_timestamp: Option, - - /// Last detach timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub last_detach_timestamp: Option, - - /// Creation timestamp (RFC3339). - #[serde(skip_serializing_if = "Option::is_none")] - pub creation_timestamp: Option, - - /// Type of resource (always "compute#disk"). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, -} - -/// Disk status. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum DiskStatus { - /// Disk is being created. - Creating, - /// Disk is restoring from snapshot. - Restoring, - /// Disk creation failed. - Failed, - /// Disk is ready. - Ready, - /// Disk is being deleted. - Deleting, -} - -// ============================================================================================= -// Data Structures - Serial Port Output -// ============================================================================================= - -/// Serial port output from a GCP compute instance (port 1 = main console). -/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/getSerialPortOutput -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -#[serde(rename_all = "camelCase")] -pub struct SerialPortOutput { - /// The contents of the serial port output. - pub contents: Option, - /// The starting byte position of the output that was returned. - pub start: Option, - /// The byte position of the next byte to read (pagination cursor). - pub next: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - const SERVICE_ATTACHMENT: &str = - "https://www.googleapis.com/compute/v1/projects/p-producer/regions/us-east1/serviceAttachments/sql-sa"; - const STACK_SUBNET: &str = - "https://www.googleapis.com/compute/v1/projects/p-consumer/regions/us-east1/subnetworks/stack-subnet"; - const STACK_NETWORK: &str = - "https://www.googleapis.com/compute/v1/projects/p-consumer/global/networks/stack-vpc"; - - /// The forwarding rule half of a Private Service Connect consumer endpoint: - /// it targets the producer's service attachment over an internal IP, in the - /// consumer's network/subnet, and is *not* a load balancer. - fn psc_consumer_forwarding_rule() -> ForwardingRule { - ForwardingRule { - name: Some("stack-psc-endpoint".into()), - target: Some(SERVICE_ATTACHMENT.into()), - ip_address: Some("10.0.0.42".into()), - network: Some(STACK_NETWORK.into()), - subnetwork: Some(STACK_SUBNET.into()), - // PSC consumer endpoints are not load balancers: scheme stays unset. - load_balancing_scheme: None, - ..Default::default() - } - } - - /// The address half of a PSC consumer endpoint: a regional INTERNAL IP - /// reserved from the consumer's subnet. - fn psc_consumer_address() -> Address { - Address { - name: Some("stack-psc-ip".into()), - address_type: Some(AddressType::Internal), - address: Some("10.0.0.42".into()), - subnetwork: Some(STACK_SUBNET.into()), - ..Default::default() - } - } - - #[test] - fn psc_forwarding_rule_serializes_for_consumer_endpoint() { - let json = serde_json::to_value(psc_consumer_forwarding_rule()) - .expect("forwarding rule should serialize"); - - assert_eq!(json["name"], "stack-psc-endpoint"); - // The target is the producer service attachment — this is what makes it PSC. - assert_eq!(json["target"], SERVICE_ATTACHMENT); - // Internal reachability: a fixed internal IP in the consumer subnet. - assert_eq!(json["IPAddress"], "10.0.0.42"); - assert_eq!(json["network"], STACK_NETWORK); - assert_eq!(json["subnetwork"], STACK_SUBNET); - // A PSC consumer endpoint must NOT carry a load-balancing scheme. - assert!( - json.get("loadBalancingScheme").is_none(), - "PSC consumer endpoint must not set loadBalancingScheme, got {json:?}" - ); - // No global-only target-proxy ports leak in. - assert!(json.get("portRange").is_none()); - // GCP rejects IPProtocol on a service-attachment-target (PSC) rule outright, so it - // must be omitted entirely. - assert!( - json.get("IPProtocol").is_none(), - "PSC consumer endpoint must not set IPProtocol, got {json:?}" - ); - } - - #[test] - fn psc_address_serializes_as_regional_internal() { - let json = serde_json::to_value(psc_consumer_address()).expect("address should serialize"); - - assert_eq!(json["name"], "stack-psc-ip"); - // Must be INTERNAL — an external address can't back a PSC endpoint. - assert_eq!(json["addressType"], "INTERNAL"); - assert_eq!(json["address"], "10.0.0.42"); - // The internal IP is drawn from the consumer subnet. - assert_eq!(json["subnetwork"], STACK_SUBNET); - // No external-only fields should appear. - assert!(json.get("networkTier").is_none()); - } - - #[test] - fn forwarding_rule_round_trips_through_get_response() { - // A GET on the rule returns the same identity fields we sent on insert. - let rule: ForwardingRule = - serde_json::from_value(serde_json::to_value(psc_consumer_forwarding_rule()).unwrap()) - .expect("forwarding rule should deserialize"); - - assert_eq!(rule.name.as_deref(), Some("stack-psc-endpoint")); - assert_eq!(rule.target.as_deref(), Some(SERVICE_ATTACHMENT)); - assert_eq!(rule.subnetwork.as_deref(), Some(STACK_SUBNET)); - assert!(rule.load_balancing_scheme.is_none()); - } - - #[tokio::test] - async fn target_tcp_proxy_insert_and_delete_use_compute_rest_contract() { - use alien_core::{GcpCredentials, GcpServiceOverrides}; - use std::{ - collections::HashMap, - io::{Read, Write}, - net::TcpListener, - sync::{Arc, Mutex}, - }; - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let endpoint = format!("http://{}", listener.local_addr().expect("local address")); - let observed = Arc::new(Mutex::new(Vec::new())); - let captured = observed.clone(); - let server = std::thread::spawn(move || { - for _ in 0..2 { - let (mut stream, _) = listener.accept().expect("accept request"); - let mut bytes = Vec::new(); - let mut buffer = [0_u8; 4096]; - let (header_end, content_length) = loop { - let count = stream.read(&mut buffer).expect("read request"); - assert!(count > 0, "request ended before headers"); - bytes.extend_from_slice(&buffer[..count]); - if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { - let header_end = end + 4; - let headers = String::from_utf8_lossy(&bytes[..header_end]); - let length: usize = headers - .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length: ") - .and_then(|value| value.parse().ok()) - }) - .unwrap_or(0); - break (header_end, length); - } - }; - while bytes.len() < header_end + content_length { - let count = stream.read(&mut buffer).expect("read body"); - assert!(count > 0, "request ended before body"); - bytes.extend_from_slice(&buffer[..count]); - } - captured.lock().expect("capture lock").push( - String::from_utf8(bytes[..header_end + content_length].to_vec()) - .expect("request utf8"), - ); - let body = r#"{"name":"operation-1","status":"DONE"}"#; - write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), body).expect("write response"); - } - }); - let client = ComputeClient::new( - Client::new(), - GcpClientConfig { - project_id: "example-project".to_string(), - region: "us-central1".to_string(), - credentials: GcpCredentials::AccessToken { - token: "test-token".to_string(), - }, - service_overrides: Some(GcpServiceOverrides { - endpoints: HashMap::from([("compute".to_string(), endpoint)]), - }), - project_number: None, - }, - ); - client - .insert_target_tcp_proxy( - TargetTcpProxy::builder() - .name("example-proxy".to_string()) - .description("TCP proxy".to_string()) - .service( - "projects/example-project/global/backendServices/example-backend" - .to_string(), - ) - .proxy_header("NONE".to_string()) - .build(), - ) - .await - .expect("insert should succeed"); - client - .delete_target_tcp_proxy("example-proxy".to_string()) - .await - .expect("delete should succeed"); - server.join().expect("server should finish"); - let requests = observed.lock().expect("capture lock"); - let (insert_headers, insert_body) = - requests[0].split_once("\r\n\r\n").expect("insert request"); - assert!(insert_headers - .starts_with("POST /projects/example-project/global/targetTcpProxies HTTP/1.1")); - assert!(insert_headers - .lines() - .any(|line| line.eq_ignore_ascii_case("authorization: Bearer test-token"))); - assert_eq!( - serde_json::from_str::(insert_body).expect("insert JSON"), - serde_json::json!({"name":"example-proxy","description":"TCP proxy","service":"projects/example-project/global/backendServices/example-backend","proxyHeader":"NONE"}) - ); - let (delete_headers, delete_body) = - requests[1].split_once("\r\n\r\n").expect("delete request"); - assert!(delete_headers.starts_with( - "DELETE /projects/example-project/global/targetTcpProxies/example-proxy HTTP/1.1" - )); - assert!(delete_headers - .lines() - .any(|line| line.eq_ignore_ascii_case("authorization: Bearer test-token"))); - assert!(delete_body.is_empty()); - } -} diff --git a/crates/alien-gcp-clients/src/gcp/compute/api.rs b/crates/alien-gcp-clients/src/gcp/compute/api.rs new file mode 100644 index 000000000..311959217 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/api.rs @@ -0,0 +1,540 @@ +use super::types::*; +use alien_client_core::Result; +use std::fmt::Debug; + +#[cfg(feature = "test-utils")] +use mockall::automock; + +// ============================================================================================= +// API Trait +// ============================================================================================= + +#[cfg_attr(feature = "test-utils", automock)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait ComputeApi: Send + Sync + Debug { + // --- Zone Operations --- + + /// Lists zones in the project. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/zones/list + async fn list_zones(&self, filter: Option) -> Result; + + // --- Network Operations --- + + /// Gets a VPC network. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks/get + async fn get_network(&self, network_name: String) -> Result; + + /// Creates a VPC network. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks/insert + async fn insert_network(&self, network: Network) -> Result; + + /// Deletes a VPC network. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks/delete + async fn delete_network(&self, network_name: String) -> Result; + + // --- Subnetwork Operations --- + + /// Gets a subnetwork. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/get + async fn get_subnetwork(&self, region: String, subnetwork_name: String) -> Result; + + /// Creates a subnetwork. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/insert + async fn insert_subnetwork(&self, region: String, subnetwork: Subnetwork) -> Result; + + /// Deletes a subnetwork. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks/delete + async fn delete_subnetwork(&self, region: String, subnetwork_name: String) + -> Result; + + // --- Router Operations --- + + /// Lists routers in a region. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/list + async fn list_routers(&self, region: String) -> Result; + + /// Gets a router. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/get + async fn get_router(&self, region: String, router_name: String) -> Result; + + /// Creates a router. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/insert + async fn insert_router(&self, region: String, router: Router) -> Result; + + /// Updates a router (PATCH). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/patch + async fn patch_router( + &self, + region: String, + router_name: String, + router: Router, + ) -> Result; + + /// Deletes a router. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers/delete + async fn delete_router(&self, region: String, router_name: String) -> Result; + + // --- Firewall Operations --- + + /// Lists firewall rules. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/list + async fn list_firewalls(&self) -> Result; + + /// Gets a firewall rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/get + async fn get_firewall(&self, firewall_name: String) -> Result; + + /// Creates a firewall rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/insert + async fn insert_firewall(&self, firewall: Firewall) -> Result; + + /// Deletes a firewall rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/delete + async fn delete_firewall(&self, firewall_name: String) -> Result; + + // --- Operation Operations --- + + /// Gets the status of an operation (global). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations/get + async fn get_global_operation(&self, operation_name: String) -> Result; + + /// Gets the status of a regional operation. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionOperations/get + async fn get_region_operation( + &self, + region: String, + operation_name: String, + ) -> Result; + + /// Waits for a global operation to complete. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations/wait + async fn wait_global_operation(&self, operation_name: String) -> Result; + + /// Waits for a regional operation to complete. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionOperations/wait + async fn wait_region_operation( + &self, + region: String, + operation_name: String, + ) -> Result; + + /// Gets the status of a zonal operation. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations/get + async fn get_zone_operation(&self, zone: String, operation_name: String) -> Result; + + /// Waits for a zonal operation to complete. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations/wait + async fn wait_zone_operation(&self, zone: String, operation_name: String) -> Result; + + // --- Health Check Operations --- + + /// Gets a health check. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/get + async fn get_health_check(&self, health_check_name: String) -> Result; + + /// Creates a health check. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/insert + async fn insert_health_check(&self, health_check: HealthCheck) -> Result; + + /// Deletes a health check. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/delete + async fn delete_health_check(&self, health_check_name: String) -> Result; + + /// Patches a health check (PATCH update). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks/patch + async fn patch_health_check( + &self, + health_check_name: String, + health_check: HealthCheck, + ) -> Result; + + // --- Backend Service Operations --- + + /// Gets a backend service. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/get + async fn get_backend_service(&self, backend_service_name: String) -> Result; + + /// Creates a backend service. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/insert + async fn insert_backend_service(&self, backend_service: BackendService) -> Result; + + /// Deletes a backend service. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/delete + async fn delete_backend_service(&self, backend_service_name: String) -> Result; + + /// Updates a backend service (PATCH). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices/patch + async fn patch_backend_service( + &self, + backend_service_name: String, + backend_service: BackendService, + ) -> Result; + + // --- URL Map Operations --- + + /// Gets a URL map. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/get + async fn get_url_map(&self, url_map_name: String) -> Result; + + /// Creates a URL map. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/insert + async fn insert_url_map(&self, url_map: UrlMap) -> Result; + + /// Deletes a URL map. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps/delete + async fn delete_url_map(&self, url_map_name: String) -> Result; + + // --- Target HTTP Proxy Operations --- + + /// Gets a target HTTP proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/get + async fn get_target_http_proxy( + &self, + target_http_proxy_name: String, + ) -> Result; + + /// Creates a target HTTP proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/insert + async fn insert_target_http_proxy( + &self, + target_http_proxy: TargetHttpProxy, + ) -> Result; + + /// Deletes a target HTTP proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies/delete + async fn delete_target_http_proxy(&self, target_http_proxy_name: String) -> Result; + + // --- Target HTTPS Proxy Operations --- + + /// Gets a target HTTPS proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/get + async fn get_target_https_proxy( + &self, + target_https_proxy_name: String, + ) -> Result; + + /// Creates a target HTTPS proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/insert + async fn insert_target_https_proxy( + &self, + target_https_proxy: TargetHttpsProxy, + ) -> Result; + + /// Replaces the SSL certificates associated with a target HTTPS proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/setSslCertificates + async fn set_target_https_proxy_ssl_certificates( + &self, + target_https_proxy_name: String, + ssl_certificates: Vec, + ) -> Result; + + /// Deletes a target HTTPS proxy. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies/delete + async fn delete_target_https_proxy(&self, target_https_proxy_name: String) + -> Result; + + // --- Target TCP Proxy Operations --- + + async fn insert_target_tcp_proxy(&self, target_tcp_proxy: TargetTcpProxy) -> Result; + async fn delete_target_tcp_proxy(&self, target_tcp_proxy_name: String) -> Result; + + // --- SSL Certificate Operations --- + + /// Gets an SSL certificate. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/get + async fn get_ssl_certificate(&self, ssl_certificate_name: String) -> Result; + + /// Creates an SSL certificate. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/insert + async fn insert_ssl_certificate(&self, ssl_certificate: SslCertificate) -> Result; + + /// Deletes an SSL certificate. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates/delete + async fn delete_ssl_certificate(&self, ssl_certificate_name: String) -> Result; + + // --- Global Address Operations --- + + /// Gets a global address. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/get + async fn get_global_address(&self, address_name: String) -> Result
; + + /// Creates a global address. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/insert + async fn insert_global_address(&self, address: Address) -> Result; + + /// Deletes a global address. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses/delete + async fn delete_global_address(&self, address_name: String) -> Result; + + // --- Global Forwarding Rule Operations --- + + /// Gets a global forwarding rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/get + async fn get_global_forwarding_rule( + &self, + forwarding_rule_name: String, + ) -> Result; + + /// Creates a global forwarding rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/insert + async fn insert_global_forwarding_rule( + &self, + forwarding_rule: ForwardingRule, + ) -> Result; + + /// Deletes a global forwarding rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules/delete + async fn delete_global_forwarding_rule( + &self, + forwarding_rule_name: String, + ) -> Result; + + // --- Regional Address Operations --- + // Private Service Connect consumer endpoints are regional; the global address + // methods above don't cover the regional internal address they need. + + /// Gets a regional address. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/addresses/get + async fn get_address(&self, region: String, address_name: String) -> Result
; + + /// Creates a regional address. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/addresses/insert + async fn insert_address(&self, region: String, address: Address) -> Result; + + /// Deletes a regional address. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/addresses/delete + async fn delete_address(&self, region: String, address_name: String) -> Result; + + // --- Regional Forwarding Rule Operations --- + // Private Service Connect consumer endpoints are regional; the global + // forwarding-rule methods above don't cover them. + + /// Gets a regional forwarding rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules/get + async fn get_forwarding_rule( + &self, + region: String, + forwarding_rule_name: String, + ) -> Result; + + /// Creates a regional forwarding rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules/insert + async fn insert_forwarding_rule( + &self, + region: String, + forwarding_rule: ForwardingRule, + ) -> Result; + + /// Deletes a regional forwarding rule. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules/delete + async fn delete_forwarding_rule( + &self, + region: String, + forwarding_rule_name: String, + ) -> Result; + + // --- Network Endpoint Group (NEG) Operations --- + + /// Gets a network endpoint group. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/get + async fn get_network_endpoint_group( + &self, + zone: String, + neg_name: String, + ) -> Result; + + /// Creates a network endpoint group. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/insert + async fn insert_network_endpoint_group( + &self, + zone: String, + neg: NetworkEndpointGroup, + ) -> Result; + + /// Deletes a network endpoint group. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/delete + async fn delete_network_endpoint_group( + &self, + zone: String, + neg_name: String, + ) -> Result; + + /// Attaches network endpoints to a NEG. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/attachNetworkEndpoints + async fn attach_network_endpoints( + &self, + zone: String, + neg_name: String, + request: NetworkEndpointGroupsAttachEndpointsRequest, + ) -> Result; + + /// Detaches network endpoints from a NEG. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups/detachNetworkEndpoints + async fn detach_network_endpoints( + &self, + zone: String, + neg_name: String, + request: NetworkEndpointGroupsDetachEndpointsRequest, + ) -> Result; + + // --- Regional Network Endpoint Group (NEG) Operations --- + + /// Gets a regional network endpoint group (for serverless workloads). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/get + async fn get_region_network_endpoint_group( + &self, + region: String, + neg_name: String, + ) -> Result; + + /// Creates a regional network endpoint group (for serverless workloads). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/insert + async fn insert_region_network_endpoint_group( + &self, + region: String, + neg: NetworkEndpointGroup, + ) -> Result; + + /// Deletes a regional network endpoint group. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/regionNetworkEndpointGroups/delete + async fn delete_region_network_endpoint_group( + &self, + region: String, + neg_name: String, + ) -> Result; + + // --- Instance Template Operations --- + + /// Gets an instance template. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/get + async fn get_instance_template( + &self, + instance_template_name: String, + ) -> Result; + + /// Creates an instance template. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/insert + async fn insert_instance_template( + &self, + instance_template: InstanceTemplate, + ) -> Result; + + /// Deletes an instance template. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates/delete + async fn delete_instance_template(&self, instance_template_name: String) -> Result; + + // --- Instance Group Manager Operations --- + + /// Gets an instance group manager. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/get + async fn get_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + ) -> Result; + + /// Creates an instance group manager. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/insert + async fn insert_instance_group_manager( + &self, + zone: String, + instance_group_manager: InstanceGroupManager, + ) -> Result; + + /// Deletes an instance group manager. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/delete + async fn delete_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + ) -> Result; + + /// Resizes an instance group manager. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/resize + async fn resize_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + size: i32, + ) -> Result; + + /// Deletes selected managed instances and reduces the instance group manager target size. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/deleteInstances + async fn delete_instance_group_manager_instances( + &self, + zone: String, + instance_group_manager_name: String, + request: InstanceGroupManagersDeleteInstancesRequest, + ) -> Result; + + /// Lists managed instances in an instance group manager. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/listManagedInstances + async fn list_managed_instances( + &self, + zone: String, + instance_group_manager_name: String, + ) -> Result; + + /// Patches an instance group manager using merge-patch semantics. + /// Used for rolling updates: set instanceTemplate + updatePolicy to trigger PROACTIVE replacement. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers/patch + async fn patch_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + patch: InstanceGroupManager, + ) -> Result; + + // --- Instance Operations --- + + /// Gets an instance. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/get + async fn get_instance(&self, zone: String, instance_name: String) -> Result; + + /// Deletes an instance. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/delete + async fn delete_instance(&self, zone: String, instance_name: String) -> Result; + + /// Attaches a disk to an instance. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/attachDisk + async fn attach_disk( + &self, + zone: String, + instance_name: String, + attached_disk: AttachedDisk, + ) -> Result; + + /// Detaches a disk from an instance. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/detachDisk + async fn detach_disk( + &self, + zone: String, + instance_name: String, + device_name: String, + ) -> Result; + + // --- Disk Operations --- + + /// Gets a disk. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks/get + async fn get_disk(&self, zone: String, disk_name: String) -> Result; + + /// Creates a disk. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks/insert + async fn insert_disk(&self, zone: String, disk: Disk) -> Result; + + /// Deletes a disk. + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks/delete + async fn delete_disk(&self, zone: String, disk_name: String) -> Result; + + // --- Serial Port Operations --- + + /// Gets the serial port output of an instance (port 1 = main console). + /// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/getSerialPortOutput + async fn get_serial_port_output( + &self, + zone: String, + instance_name: String, + ) -> Result; +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/client.rs b/crates/alien-gcp-clients/src/gcp/compute/client.rs new file mode 100644 index 000000000..77a49f42a --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/client.rs @@ -0,0 +1,1350 @@ +use super::types::*; +use super::{ComputeApi, ComputeServiceConfig}; +use crate::gcp::api_client::GcpClientBase; +use crate::gcp::GcpClientConfig; +use alien_client_core::Result; +use reqwest::{Client, Method}; + +// ============================================================================================= +// Client Implementation +// ============================================================================================= + +/// Compute Engine client for managing VPC networks and related resources +#[derive(Debug)] +pub struct ComputeClient { + base: GcpClientBase, + project_id: String, +} + +impl ComputeClient { + pub fn new(client: Client, config: GcpClientConfig) -> Self { + let project_id = config.project_id.clone(); + Self { + base: GcpClientBase::new(client, config, Box::new(ComputeServiceConfig)), + project_id, + } + } + + pub fn project_id(&self) -> &str { + &self.project_id + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl ComputeApi for ComputeClient { + // --- Zone Operations --- + + async fn list_zones(&self, filter: Option) -> Result { + let path = format!("projects/{}/zones", self.project_id); + let query_params = filter.map(|filter| vec![("filter", filter)]); + self.base + .execute_request( + Method::GET, + &path, + query_params, + Option::<()>::None, + "zones", + ) + .await + } + + // --- Network Operations --- + + async fn get_network(&self, network_name: String) -> Result { + let path = format!( + "projects/{}/global/networks/{}", + self.project_id, network_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &network_name) + .await + } + + async fn insert_network(&self, network: Network) -> Result { + let path = format!("projects/{}/global/networks", self.project_id); + let resource_name = network.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(network), &resource_name) + .await + } + + async fn delete_network(&self, network_name: String) -> Result { + let path = format!( + "projects/{}/global/networks/{}", + self.project_id, network_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &network_name, + ) + .await + } + + // --- Subnetwork Operations --- + + async fn get_subnetwork(&self, region: String, subnetwork_name: String) -> Result { + let path = format!( + "projects/{}/regions/{}/subnetworks/{}", + self.project_id, region, subnetwork_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &subnetwork_name, + ) + .await + } + + async fn insert_subnetwork(&self, region: String, subnetwork: Subnetwork) -> Result { + let path = format!( + "projects/{}/regions/{}/subnetworks", + self.project_id, region + ); + let resource_name = subnetwork.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(subnetwork), &resource_name) + .await + } + + async fn delete_subnetwork( + &self, + region: String, + subnetwork_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/subnetworks/{}", + self.project_id, region, subnetwork_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &subnetwork_name, + ) + .await + } + + // --- Router Operations --- + + async fn list_routers(&self, region: String) -> Result { + let path = format!("projects/{}/regions/{}/routers", self.project_id, region); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, "routers") + .await + } + + async fn get_router(&self, region: String, router_name: String) -> Result { + let path = format!( + "projects/{}/regions/{}/routers/{}", + self.project_id, region, router_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &router_name) + .await + } + + async fn insert_router(&self, region: String, router: Router) -> Result { + let path = format!("projects/{}/regions/{}/routers", self.project_id, region); + let resource_name = router.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(router), &resource_name) + .await + } + + async fn patch_router( + &self, + region: String, + router_name: String, + router: Router, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/routers/{}", + self.project_id, region, router_name + ); + self.base + .execute_request(Method::PATCH, &path, None, Some(router), &router_name) + .await + } + + async fn delete_router(&self, region: String, router_name: String) -> Result { + let path = format!( + "projects/{}/regions/{}/routers/{}", + self.project_id, region, router_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &router_name, + ) + .await + } + + // --- Firewall Operations --- + + async fn list_firewalls(&self) -> Result { + let path = format!("projects/{}/global/firewalls", self.project_id); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, "firewalls") + .await + } + + async fn get_firewall(&self, firewall_name: String) -> Result { + let path = format!( + "projects/{}/global/firewalls/{}", + self.project_id, firewall_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &firewall_name) + .await + } + + async fn insert_firewall(&self, firewall: Firewall) -> Result { + let path = format!("projects/{}/global/firewalls", self.project_id); + let resource_name = firewall.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(firewall), &resource_name) + .await + } + + async fn delete_firewall(&self, firewall_name: String) -> Result { + let path = format!( + "projects/{}/global/firewalls/{}", + self.project_id, firewall_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &firewall_name, + ) + .await + } + + // --- Operation Operations --- + + async fn get_global_operation(&self, operation_name: String) -> Result { + let path = format!( + "projects/{}/global/operations/{}", + self.project_id, operation_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &operation_name, + ) + .await + } + + async fn get_region_operation( + &self, + region: String, + operation_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/operations/{}", + self.project_id, region, operation_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &operation_name, + ) + .await + } + + async fn wait_global_operation(&self, operation_name: String) -> Result { + let path = format!( + "projects/{}/global/operations/{}/wait", + self.project_id, operation_name + ); + self.base + .execute_request( + Method::POST, + &path, + None, + Option::<()>::None, + &operation_name, + ) + .await + } + + async fn wait_region_operation( + &self, + region: String, + operation_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/operations/{}/wait", + self.project_id, region, operation_name + ); + self.base + .execute_request( + Method::POST, + &path, + None, + Option::<()>::None, + &operation_name, + ) + .await + } + + async fn get_zone_operation(&self, zone: String, operation_name: String) -> Result { + let path = format!( + "projects/{}/zones/{}/operations/{}", + self.project_id, zone, operation_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &operation_name, + ) + .await + } + + async fn wait_zone_operation(&self, zone: String, operation_name: String) -> Result { + let path = format!( + "projects/{}/zones/{}/operations/{}/wait", + self.project_id, zone, operation_name + ); + self.base + .execute_request( + Method::POST, + &path, + None, + Option::<()>::None, + &operation_name, + ) + .await + } + + // --- Health Check Operations --- + + async fn get_health_check(&self, health_check_name: String) -> Result { + let path = format!( + "projects/{}/global/healthChecks/{}", + self.project_id, health_check_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &health_check_name, + ) + .await + } + + async fn insert_health_check(&self, health_check: HealthCheck) -> Result { + let path = format!("projects/{}/global/healthChecks", self.project_id); + let resource_name = health_check.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(health_check), + &resource_name, + ) + .await + } + + async fn delete_health_check(&self, health_check_name: String) -> Result { + let path = format!( + "projects/{}/global/healthChecks/{}", + self.project_id, health_check_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &health_check_name, + ) + .await + } + + async fn patch_health_check( + &self, + health_check_name: String, + health_check: HealthCheck, + ) -> Result { + let path = format!( + "projects/{}/global/healthChecks/{}", + self.project_id, health_check_name + ); + self.base + .execute_request( + Method::PATCH, + &path, + None, + Some(health_check), + &health_check_name, + ) + .await + } + + // --- Backend Service Operations --- + + async fn get_backend_service(&self, backend_service_name: String) -> Result { + let path = format!( + "projects/{}/global/backendServices/{}", + self.project_id, backend_service_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &backend_service_name, + ) + .await + } + + async fn insert_backend_service(&self, backend_service: BackendService) -> Result { + let path = format!("projects/{}/global/backendServices", self.project_id); + let resource_name = backend_service.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(backend_service), + &resource_name, + ) + .await + } + + async fn delete_backend_service(&self, backend_service_name: String) -> Result { + let path = format!( + "projects/{}/global/backendServices/{}", + self.project_id, backend_service_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &backend_service_name, + ) + .await + } + + async fn patch_backend_service( + &self, + backend_service_name: String, + backend_service: BackendService, + ) -> Result { + let path = format!( + "projects/{}/global/backendServices/{}", + self.project_id, backend_service_name + ); + self.base + .execute_request( + Method::PATCH, + &path, + None, + Some(backend_service), + &backend_service_name, + ) + .await + } + + // --- URL Map Operations --- + + async fn get_url_map(&self, url_map_name: String) -> Result { + let path = format!( + "projects/{}/global/urlMaps/{}", + self.project_id, url_map_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &url_map_name) + .await + } + + async fn insert_url_map(&self, url_map: UrlMap) -> Result { + let path = format!("projects/{}/global/urlMaps", self.project_id); + let resource_name = url_map.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(url_map), &resource_name) + .await + } + + async fn delete_url_map(&self, url_map_name: String) -> Result { + let path = format!( + "projects/{}/global/urlMaps/{}", + self.project_id, url_map_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &url_map_name, + ) + .await + } + + // --- Target HTTP Proxy Operations --- + + async fn get_target_http_proxy( + &self, + target_http_proxy_name: String, + ) -> Result { + let path = format!( + "projects/{}/global/targetHttpProxies/{}", + self.project_id, target_http_proxy_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &target_http_proxy_name, + ) + .await + } + + async fn insert_target_http_proxy( + &self, + target_http_proxy: TargetHttpProxy, + ) -> Result { + let path = format!("projects/{}/global/targetHttpProxies", self.project_id); + let resource_name = target_http_proxy.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(target_http_proxy), + &resource_name, + ) + .await + } + + async fn delete_target_http_proxy(&self, target_http_proxy_name: String) -> Result { + let path = format!( + "projects/{}/global/targetHttpProxies/{}", + self.project_id, target_http_proxy_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &target_http_proxy_name, + ) + .await + } + + // --- Target HTTPS Proxy Operations --- + + async fn get_target_https_proxy( + &self, + target_https_proxy_name: String, + ) -> Result { + let path = format!( + "projects/{}/global/targetHttpsProxies/{}", + self.project_id, target_https_proxy_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &target_https_proxy_name, + ) + .await + } + + async fn insert_target_https_proxy( + &self, + target_https_proxy: TargetHttpsProxy, + ) -> Result { + let path = format!("projects/{}/global/targetHttpsProxies", self.project_id); + let name = target_https_proxy + .name + .clone() + .unwrap_or_else(|| "targetHttpsProxy".to_string()); + self.base + .execute_request(Method::POST, &path, None, Some(target_https_proxy), &name) + .await + } + + async fn set_target_https_proxy_ssl_certificates( + &self, + target_https_proxy_name: String, + ssl_certificates: Vec, + ) -> Result { + let path = format!( + "projects/{}/global/targetHttpsProxies/{}/setSslCertificates", + self.project_id, target_https_proxy_name + ); + let request = SetSslCertificatesRequest { ssl_certificates }; + self.base + .execute_request( + Method::POST, + &path, + None, + Some(request), + &target_https_proxy_name, + ) + .await + } + + async fn delete_target_https_proxy( + &self, + target_https_proxy_name: String, + ) -> Result { + let path = format!( + "projects/{}/global/targetHttpsProxies/{}", + self.project_id, target_https_proxy_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &target_https_proxy_name, + ) + .await + } + + async fn insert_target_tcp_proxy(&self, target_tcp_proxy: TargetTcpProxy) -> Result { + let path = format!("projects/{}/global/targetTcpProxies", self.project_id); + let name = target_tcp_proxy + .name + .clone() + .unwrap_or_else(|| "targetTcpProxy".to_string()); + self.base + .execute_request(Method::POST, &path, None, Some(target_tcp_proxy), &name) + .await + } + + async fn delete_target_tcp_proxy(&self, target_tcp_proxy_name: String) -> Result { + let path = format!( + "projects/{}/global/targetTcpProxies/{}", + self.project_id, target_tcp_proxy_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &target_tcp_proxy_name, + ) + .await + } + + // --- SSL Certificate Operations --- + + async fn get_ssl_certificate(&self, ssl_certificate_name: String) -> Result { + let path = format!( + "projects/{}/global/sslCertificates/{}", + self.project_id, ssl_certificate_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &ssl_certificate_name, + ) + .await + } + + async fn insert_ssl_certificate(&self, ssl_certificate: SslCertificate) -> Result { + let path = format!("projects/{}/global/sslCertificates", self.project_id); + let name = ssl_certificate + .name + .clone() + .unwrap_or_else(|| "sslCertificate".to_string()); + self.base + .execute_request(Method::POST, &path, None, Some(ssl_certificate), &name) + .await + } + + async fn delete_ssl_certificate(&self, ssl_certificate_name: String) -> Result { + let path = format!( + "projects/{}/global/sslCertificates/{}", + self.project_id, ssl_certificate_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &ssl_certificate_name, + ) + .await + } + + // --- Global Address Operations --- + + async fn get_global_address(&self, address_name: String) -> Result
{ + let path = format!( + "projects/{}/global/addresses/{}", + self.project_id, address_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &address_name) + .await + } + + async fn insert_global_address(&self, address: Address) -> Result { + let path = format!("projects/{}/global/addresses", self.project_id); + let resource_name = address.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(address), &resource_name) + .await + } + + async fn delete_global_address(&self, address_name: String) -> Result { + let path = format!( + "projects/{}/global/addresses/{}", + self.project_id, address_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &address_name, + ) + .await + } + + // --- Global Forwarding Rule Operations --- + + async fn get_global_forwarding_rule( + &self, + forwarding_rule_name: String, + ) -> Result { + let path = format!( + "projects/{}/global/forwardingRules/{}", + self.project_id, forwarding_rule_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &forwarding_rule_name, + ) + .await + } + + async fn insert_global_forwarding_rule( + &self, + forwarding_rule: ForwardingRule, + ) -> Result { + let path = format!("projects/{}/global/forwardingRules", self.project_id); + let resource_name = forwarding_rule.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(forwarding_rule), + &resource_name, + ) + .await + } + + async fn delete_global_forwarding_rule( + &self, + forwarding_rule_name: String, + ) -> Result { + let path = format!( + "projects/{}/global/forwardingRules/{}", + self.project_id, forwarding_rule_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &forwarding_rule_name, + ) + .await + } + + // --- Regional Address Operations --- + + async fn get_address(&self, region: String, address_name: String) -> Result
{ + let path = format!( + "projects/{}/regions/{}/addresses/{}", + self.project_id, region, address_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &address_name) + .await + } + + async fn insert_address(&self, region: String, address: Address) -> Result { + let path = format!("projects/{}/regions/{}/addresses", self.project_id, region); + let resource_name = address.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(address), &resource_name) + .await + } + + async fn delete_address(&self, region: String, address_name: String) -> Result { + let path = format!( + "projects/{}/regions/{}/addresses/{}", + self.project_id, region, address_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &address_name, + ) + .await + } + + // --- Regional Forwarding Rule Operations --- + + async fn get_forwarding_rule( + &self, + region: String, + forwarding_rule_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/forwardingRules/{}", + self.project_id, region, forwarding_rule_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &forwarding_rule_name, + ) + .await + } + + async fn insert_forwarding_rule( + &self, + region: String, + forwarding_rule: ForwardingRule, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/forwardingRules", + self.project_id, region + ); + let resource_name = forwarding_rule.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(forwarding_rule), + &resource_name, + ) + .await + } + + async fn delete_forwarding_rule( + &self, + region: String, + forwarding_rule_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/forwardingRules/{}", + self.project_id, region, forwarding_rule_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &forwarding_rule_name, + ) + .await + } + + // --- Network Endpoint Group (NEG) Operations --- + + async fn get_network_endpoint_group( + &self, + zone: String, + neg_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/networkEndpointGroups/{}", + self.project_id, zone, neg_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &neg_name) + .await + } + + async fn insert_network_endpoint_group( + &self, + zone: String, + neg: NetworkEndpointGroup, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/networkEndpointGroups", + self.project_id, zone + ); + let resource_name = neg.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(neg), &resource_name) + .await + } + + async fn delete_network_endpoint_group( + &self, + zone: String, + neg_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/networkEndpointGroups/{}", + self.project_id, zone, neg_name + ); + self.base + .execute_request(Method::DELETE, &path, None, Option::<()>::None, &neg_name) + .await + } + + async fn attach_network_endpoints( + &self, + zone: String, + neg_name: String, + request: NetworkEndpointGroupsAttachEndpointsRequest, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/networkEndpointGroups/{}/attachNetworkEndpoints", + self.project_id, zone, neg_name + ); + self.base + .execute_request(Method::POST, &path, None, Some(request), &neg_name) + .await + } + + async fn detach_network_endpoints( + &self, + zone: String, + neg_name: String, + request: NetworkEndpointGroupsDetachEndpointsRequest, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/networkEndpointGroups/{}/detachNetworkEndpoints", + self.project_id, zone, neg_name + ); + self.base + .execute_request(Method::POST, &path, None, Some(request), &neg_name) + .await + } + + // --- Regional Network Endpoint Group (NEG) Operations --- + + async fn get_region_network_endpoint_group( + &self, + region: String, + neg_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/networkEndpointGroups/{}", + self.project_id, region, neg_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &neg_name) + .await + } + + async fn insert_region_network_endpoint_group( + &self, + region: String, + neg: NetworkEndpointGroup, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/networkEndpointGroups", + self.project_id, region + ); + let resource_name = neg.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(neg), &resource_name) + .await + } + + async fn delete_region_network_endpoint_group( + &self, + region: String, + neg_name: String, + ) -> Result { + let path = format!( + "projects/{}/regions/{}/networkEndpointGroups/{}", + self.project_id, region, neg_name + ); + self.base + .execute_request(Method::DELETE, &path, None, Option::<()>::None, &neg_name) + .await + } + + // --- Instance Template Operations --- + + async fn get_instance_template( + &self, + instance_template_name: String, + ) -> Result { + let path = format!( + "projects/{}/global/instanceTemplates/{}", + self.project_id, instance_template_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &instance_template_name, + ) + .await + } + + async fn insert_instance_template( + &self, + instance_template: InstanceTemplate, + ) -> Result { + let path = format!("projects/{}/global/instanceTemplates", self.project_id); + let resource_name = instance_template.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(instance_template), + &resource_name, + ) + .await + } + + async fn delete_instance_template(&self, instance_template_name: String) -> Result { + let path = format!( + "projects/{}/global/instanceTemplates/{}", + self.project_id, instance_template_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &instance_template_name, + ) + .await + } + + // --- Instance Group Manager Operations --- + + async fn get_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers/{}", + self.project_id, zone, instance_group_manager_name + ); + self.base + .execute_request( + Method::GET, + &path, + None, + Option::<()>::None, + &instance_group_manager_name, + ) + .await + } + + async fn insert_instance_group_manager( + &self, + zone: String, + instance_group_manager: InstanceGroupManager, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers", + self.project_id, zone + ); + let resource_name = instance_group_manager.name.clone().unwrap_or_default(); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(instance_group_manager), + &resource_name, + ) + .await + } + + async fn delete_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers/{}", + self.project_id, zone, instance_group_manager_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &instance_group_manager_name, + ) + .await + } + + async fn resize_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + size: i32, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers/{}/resize", + self.project_id, zone, instance_group_manager_name + ); + let query_params = vec![("size", size.to_string())]; + self.base + .execute_request( + Method::POST, + &path, + Some(query_params), + Option::<()>::None, + &instance_group_manager_name, + ) + .await + } + + async fn delete_instance_group_manager_instances( + &self, + zone: String, + instance_group_manager_name: String, + request: InstanceGroupManagersDeleteInstancesRequest, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers/{}/deleteInstances", + self.project_id, zone, instance_group_manager_name + ); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(request), + &instance_group_manager_name, + ) + .await + } + + async fn list_managed_instances( + &self, + zone: String, + instance_group_manager_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers/{}/listManagedInstances", + self.project_id, zone, instance_group_manager_name + ); + self.base + .execute_request( + Method::POST, + &path, + None, + Option::<()>::None, + &instance_group_manager_name, + ) + .await + } + + async fn patch_instance_group_manager( + &self, + zone: String, + instance_group_manager_name: String, + patch: InstanceGroupManager, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instanceGroupManagers/{}", + self.project_id, zone, instance_group_manager_name + ); + self.base + .execute_request( + Method::PATCH, + &path, + None, + Some(patch), + &instance_group_manager_name, + ) + .await + } + + // --- Instance Operations --- + + async fn get_instance(&self, zone: String, instance_name: String) -> Result { + let path = format!( + "projects/{}/zones/{}/instances/{}", + self.project_id, zone, instance_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &instance_name) + .await + } + + async fn delete_instance(&self, zone: String, instance_name: String) -> Result { + let path = format!( + "projects/{}/zones/{}/instances/{}", + self.project_id, zone, instance_name + ); + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &instance_name, + ) + .await + } + + async fn attach_disk( + &self, + zone: String, + instance_name: String, + attached_disk: AttachedDisk, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instances/{}/attachDisk", + self.project_id, zone, instance_name + ); + self.base + .execute_request( + Method::POST, + &path, + None, + Some(attached_disk), + &instance_name, + ) + .await + } + + async fn detach_disk( + &self, + zone: String, + instance_name: String, + device_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instances/{}/detachDisk", + self.project_id, zone, instance_name + ); + let query = vec![("deviceName", device_name)]; + self.base + .execute_request( + Method::POST, + &path, + Some(query), + Option::<()>::None, + &instance_name, + ) + .await + } + + // --- Disk Operations --- + + async fn get_disk(&self, zone: String, disk_name: String) -> Result { + let path = format!( + "projects/{}/zones/{}/disks/{}", + self.project_id, zone, disk_name + ); + self.base + .execute_request(Method::GET, &path, None, Option::<()>::None, &disk_name) + .await + } + + async fn insert_disk(&self, zone: String, disk: Disk) -> Result { + let path = format!("projects/{}/zones/{}/disks", self.project_id, zone); + let resource_name = disk.name.clone().unwrap_or_default(); + self.base + .execute_request(Method::POST, &path, None, Some(disk), &resource_name) + .await + } + + async fn delete_disk(&self, zone: String, disk_name: String) -> Result { + let path = format!( + "projects/{}/zones/{}/disks/{}", + self.project_id, zone, disk_name + ); + self.base + .execute_request(Method::DELETE, &path, None, Option::<()>::None, &disk_name) + .await + } + + async fn get_serial_port_output( + &self, + zone: String, + instance_name: String, + ) -> Result { + let path = format!( + "projects/{}/zones/{}/instances/{}/serialPort", + self.project_id, zone, instance_name + ); + self.base + .execute_request( + Method::GET, + &path, + Some(vec![("port", "1".to_string())]), + Option::<()>::None, + &instance_name, + ) + .await + } +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/mod.rs b/crates/alien-gcp-clients/src/gcp/compute/mod.rs new file mode 100644 index 000000000..e0d2be02c --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/mod.rs @@ -0,0 +1,63 @@ +//! GCP Compute Engine client for VPC, networking, load balancing, instances, and disk operations. +//! +//! This module provides APIs for managing: +//! - VPC networks, subnetworks, routers, and firewalls +//! - Load balancing: health checks, backend services, URL maps, proxies, forwarding rules, NEGs +//! - Instance management: instance templates, instance group managers, instances +//! - Persistent disks +//! +//! See: +//! - Networks: https://cloud.google.com/compute/docs/reference/rest/v1/networks +//! - Subnetworks: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks +//! - Routers: https://cloud.google.com/compute/docs/reference/rest/v1/routers +//! - Firewalls: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls +//! - Health Checks: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks +//! - Backend Services: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices +//! - URL Maps: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps +//! - Target HTTP Proxies: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies +//! - Global Addresses: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses +//! - Global Forwarding Rules: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules +//! - Network Endpoint Groups: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups +//! - Instance Templates: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates +//! - Instance Group Managers: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers +//! - Instances: https://cloud.google.com/compute/docs/reference/rest/v1/instances +//! - Disks: https://cloud.google.com/compute/docs/reference/rest/v1/disks + +use crate::gcp::api_client::GcpServiceConfig; + +mod api; +mod client; +mod types; + +#[cfg(test)] +mod tests; + +pub use api::*; +pub use client::*; +pub use types::*; + +// ============================================================================================= +// Service Configuration +// ============================================================================================= + +/// Compute Engine service configuration +#[derive(Debug)] +pub struct ComputeServiceConfig; + +impl GcpServiceConfig for ComputeServiceConfig { + fn base_url(&self) -> &'static str { + "https://compute.googleapis.com/compute/v1" + } + + fn default_audience(&self) -> &'static str { + "https://compute.googleapis.com/" + } + + fn service_name(&self) -> &'static str { + "Compute Engine" + } + + fn service_key(&self) -> &'static str { + "compute" + } +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/tests.rs b/crates/alien-gcp-clients/src/gcp/compute/tests.rs new file mode 100644 index 000000000..d3142dbea --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/tests.rs @@ -0,0 +1,194 @@ +use super::*; +use crate::gcp::GcpClientConfig; +use alien_core::{GcpCredentials, GcpServiceOverrides}; +use reqwest::Client; +use std::{ + collections::HashMap, + io::{Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, +}; + +const SERVICE_ATTACHMENT: &str = + "https://www.googleapis.com/compute/v1/projects/p-producer/regions/us-east1/serviceAttachments/sql-sa"; +const STACK_SUBNET: &str = + "https://www.googleapis.com/compute/v1/projects/p-consumer/regions/us-east1/subnetworks/stack-subnet"; +const STACK_NETWORK: &str = + "https://www.googleapis.com/compute/v1/projects/p-consumer/global/networks/stack-vpc"; + +/// The forwarding rule half of a Private Service Connect consumer endpoint: +/// it targets the producer's service attachment over an internal IP, in the +/// consumer's network/subnet, and is *not* a load balancer. +fn psc_consumer_forwarding_rule() -> ForwardingRule { + ForwardingRule { + name: Some("stack-psc-endpoint".into()), + target: Some(SERVICE_ATTACHMENT.into()), + ip_address: Some("10.0.0.42".into()), + network: Some(STACK_NETWORK.into()), + subnetwork: Some(STACK_SUBNET.into()), + // PSC consumer endpoints are not load balancers: scheme stays unset. + load_balancing_scheme: None, + ..Default::default() + } +} + +/// The address half of a PSC consumer endpoint: a regional INTERNAL IP +/// reserved from the consumer's subnet. +fn psc_consumer_address() -> Address { + Address { + name: Some("stack-psc-ip".into()), + address_type: Some(AddressType::Internal), + address: Some("10.0.0.42".into()), + subnetwork: Some(STACK_SUBNET.into()), + ..Default::default() + } +} + +#[test] +fn psc_forwarding_rule_serializes_for_consumer_endpoint() { + let json = serde_json::to_value(psc_consumer_forwarding_rule()) + .expect("forwarding rule should serialize"); + + assert_eq!(json["name"], "stack-psc-endpoint"); + // The target is the producer service attachment — this is what makes it PSC. + assert_eq!(json["target"], SERVICE_ATTACHMENT); + // Internal reachability: a fixed internal IP in the consumer subnet. + assert_eq!(json["IPAddress"], "10.0.0.42"); + assert_eq!(json["network"], STACK_NETWORK); + assert_eq!(json["subnetwork"], STACK_SUBNET); + // A PSC consumer endpoint must NOT carry a load-balancing scheme. + assert!( + json.get("loadBalancingScheme").is_none(), + "PSC consumer endpoint must not set loadBalancingScheme, got {json:?}" + ); + // No global-only target-proxy ports leak in. + assert!(json.get("portRange").is_none()); + // GCP rejects IPProtocol on a service-attachment-target (PSC) rule outright, so it + // must be omitted entirely. + assert!( + json.get("IPProtocol").is_none(), + "PSC consumer endpoint must not set IPProtocol, got {json:?}" + ); +} + +#[test] +fn psc_address_serializes_as_regional_internal() { + let json = serde_json::to_value(psc_consumer_address()).expect("address should serialize"); + + assert_eq!(json["name"], "stack-psc-ip"); + // Must be INTERNAL — an external address can't back a PSC endpoint. + assert_eq!(json["addressType"], "INTERNAL"); + assert_eq!(json["address"], "10.0.0.42"); + // The internal IP is drawn from the consumer subnet. + assert_eq!(json["subnetwork"], STACK_SUBNET); + // No external-only fields should appear. + assert!(json.get("networkTier").is_none()); +} + +#[test] +fn forwarding_rule_round_trips_through_get_response() { + // A GET on the rule returns the same identity fields we sent on insert. + let rule: ForwardingRule = + serde_json::from_value(serde_json::to_value(psc_consumer_forwarding_rule()).unwrap()) + .expect("forwarding rule should deserialize"); + + assert_eq!(rule.name.as_deref(), Some("stack-psc-endpoint")); + assert_eq!(rule.target.as_deref(), Some(SERVICE_ATTACHMENT)); + assert_eq!(rule.subnetwork.as_deref(), Some(STACK_SUBNET)); + assert!(rule.load_balancing_scheme.is_none()); +} + +#[tokio::test] +async fn target_tcp_proxy_insert_and_delete_use_compute_rest_contract() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let endpoint = format!("http://{}", listener.local_addr().expect("local address")); + let observed = Arc::new(Mutex::new(Vec::new())); + let captured = observed.clone(); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let count = stream.read(&mut buffer).expect("read request"); + assert!(count > 0, "request ended before headers"); + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + let header_end = end + 4; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let length: usize = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|value| value.parse().ok()) + }) + .unwrap_or(0); + break (header_end, length); + } + }; + while bytes.len() < header_end + content_length { + let count = stream.read(&mut buffer).expect("read body"); + assert!(count > 0, "request ended before body"); + bytes.extend_from_slice(&buffer[..count]); + } + captured.lock().expect("capture lock").push( + String::from_utf8(bytes[..header_end + content_length].to_vec()) + .expect("request utf8"), + ); + let body = r#"{"name":"operation-1","status":"DONE"}"#; + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), body).expect("write response"); + } + }); + let client = ComputeClient::new( + Client::new(), + GcpClientConfig { + project_id: "example-project".to_string(), + region: "us-central1".to_string(), + credentials: GcpCredentials::AccessToken { + token: "test-token".to_string(), + }, + service_overrides: Some(GcpServiceOverrides { + endpoints: HashMap::from([("compute".to_string(), endpoint)]), + }), + project_number: None, + }, + ); + client + .insert_target_tcp_proxy( + TargetTcpProxy::builder() + .name("example-proxy".to_string()) + .description("TCP proxy".to_string()) + .service( + "projects/example-project/global/backendServices/example-backend".to_string(), + ) + .proxy_header("NONE".to_string()) + .build(), + ) + .await + .expect("insert should succeed"); + client + .delete_target_tcp_proxy("example-proxy".to_string()) + .await + .expect("delete should succeed"); + server.join().expect("server should finish"); + let requests = observed.lock().expect("capture lock"); + let (insert_headers, insert_body) = requests[0].split_once("\r\n\r\n").expect("insert request"); + assert!(insert_headers + .starts_with("POST /projects/example-project/global/targetTcpProxies HTTP/1.1")); + assert!(insert_headers + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer test-token"))); + assert_eq!( + serde_json::from_str::(insert_body).expect("insert JSON"), + serde_json::json!({"name":"example-proxy","description":"TCP proxy","service":"projects/example-project/global/backendServices/example-backend","proxyHeader":"NONE"}) + ); + let (delete_headers, delete_body) = requests[1].split_once("\r\n\r\n").expect("delete request"); + assert!(delete_headers.starts_with( + "DELETE /projects/example-project/global/targetTcpProxies/example-proxy HTTP/1.1" + )); + assert!(delete_headers + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer test-token"))); + assert!(delete_body.is_empty()); +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/types/instance.rs b/crates/alien-gcp-clients/src/gcp/compute/types/instance.rs new file mode 100644 index 000000000..1820f185d --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/types/instance.rs @@ -0,0 +1,1224 @@ +use super::network::{NetworkTier, StackType}; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// ============================================================================================= +// Data Structures - Instance Template +// ============================================================================================= + +/// Represents an instance template resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceTemplates +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceTemplate { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Instance properties for instances created from this template. + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#instanceTemplate"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Properties for instances created from a template. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceProperties { + /// Machine type for instances. + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_type: Option, + + /// Description of instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Disks attached to instances. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disks: Vec, + + /// Network interfaces for instances. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub network_interfaces: Vec, + + /// Metadata for instances. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + + /// Service accounts for instances. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub service_accounts: Vec, + + /// Tags for instances. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, + + /// Scheduling configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduling: Option, + + /// Labels for instances. + #[builder(default)] + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub labels: std::collections::HashMap, + + /// Whether to allow stopping for update. + #[serde(skip_serializing_if = "Option::is_none")] + pub can_ip_forward: Option, + + /// Guest accelerators for instances. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub guest_accelerators: Vec, + + /// Shielded instance configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub shielded_instance_config: Option, + + /// Confidential instance configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub confidential_instance_config: Option, +} + +/// Attached disk configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct AttachedDisk { + /// Type of attachment (PERSISTENT, SCRATCH). + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// Mode of disk (READ_WRITE, READ_ONLY). + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + + /// Source disk URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + + /// Device name. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_name: Option, + + /// Boot disk indicator. + #[serde(skip_serializing_if = "Option::is_none")] + pub boot: Option, + + /// Initialize parameters for new disks. + #[serde(skip_serializing_if = "Option::is_none")] + pub initialize_params: Option, + + /// Whether to auto-delete the disk. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_delete: Option, + + /// Index of the disk. + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, + + /// Disk interface (SCSI, NVME). + #[serde(skip_serializing_if = "Option::is_none")] + pub interface: Option, +} + +/// Attached disk type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AttachedDiskType { + /// Persistent disk. + Persistent, + /// Scratch disk. + Scratch, +} + +/// Disk mode. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DiskMode { + /// Read-write mode. + ReadWrite, + /// Read-only mode. + ReadOnly, +} + +/// Disk interface. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DiskInterface { + /// SCSI interface. + Scsi, + /// NVMe interface. + Nvme, +} + +/// Parameters for initializing a new disk. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct AttachedDiskInitializeParams { + /// Name for the disk. + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_name: Option, + + /// Source image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_image: Option, + + /// Disk size in GB. + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_size_gb: Option, + + /// Disk type URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_type: Option, + + /// Source snapshot URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_snapshot: Option, + + /// Labels for the disk. + #[builder(default)] + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub labels: std::collections::HashMap, +} + +/// Network interface configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkInterface { + /// Network URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// Subnetwork URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub subnetwork: Option, + + /// Network IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_i_p: Option, + + /// Name of the interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Access configurations for external IPs. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub access_configs: Vec, + + /// Alias IP ranges. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alias_ip_ranges: Vec, + + /// Fingerprint for optimistic locking. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Stack type for this interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub stack_type: Option, + + /// Network interface card type. + #[serde(skip_serializing_if = "Option::is_none")] + pub nic_type: Option, +} + +/// Access configuration for external IP. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct AccessConfig { + /// Type of access config. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// Name of the access config. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// External IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub nat_i_p: Option, + + /// Network tier. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_tier: Option, +} + +/// Access config type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AccessConfigType { + /// One-to-one NAT. + OneToOneNat, + /// Direct IPv6 access. + DirectIpv6, +} + +/// Alias IP range. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct AliasIpRange { + /// IP CIDR range. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_cidr_range: Option, + + /// Subnetwork range name. + #[serde(skip_serializing_if = "Option::is_none")] + pub subnetwork_range_name: Option, +} + +/// NIC type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NicType { + /// Virtio NET. + VirtioNet, + /// gVNIC. + Gvnic, +} + +/// Metadata configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + /// Fingerprint for optimistic locking. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Metadata items. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + + /// Type of resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Metadata item. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct MetadataItem { + /// Key of the metadata item. + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + + /// Value of the metadata item. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +/// Service account configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ServiceAccount { + /// Email address of the service account. + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + + /// OAuth scopes. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scopes: Vec, +} + +/// Tags configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Tags { + /// Fingerprint for optimistic locking. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Tag items. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, +} + +/// Scheduling configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Scheduling { + /// On host maintenance behavior. + #[serde(skip_serializing_if = "Option::is_none")] + pub on_host_maintenance: Option, + + /// Automatic restart enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub automatic_restart: Option, + + /// Whether this is a preemptible instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub preemptible: Option, + + /// Provisioning model. + #[serde(skip_serializing_if = "Option::is_none")] + pub provisioning_model: Option, +} + +/// On host maintenance behavior. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum OnHostMaintenance { + /// Migrate during maintenance. + Migrate, + /// Terminate during maintenance. + Terminate, +} + +/// Provisioning model. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ProvisioningModel { + /// Standard provisioning. + Standard, + /// Spot provisioning. + Spot, +} + +/// Accelerator configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct AcceleratorConfig { + /// Type of accelerator. + #[serde(skip_serializing_if = "Option::is_none")] + pub accelerator_type: Option, + + /// Number of accelerators. + #[serde(skip_serializing_if = "Option::is_none")] + pub accelerator_count: Option, +} + +/// Shielded instance configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ShieldedInstanceConfig { + /// Enable secure boot. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_secure_boot: Option, + + /// Enable vTPM. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_vtpm: Option, + + /// Enable integrity monitoring. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_integrity_monitoring: Option, +} + +/// Confidential instance configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ConfidentialInstanceConfig { + /// Enable confidential compute. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_confidential_compute: Option, +} + +// ============================================================================================= +// Data Structures - Instance Group Manager +// ============================================================================================= + +/// Represents an instance group manager resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instanceGroupManagers +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManager { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// URL of the managed instance group. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_group: Option, + + /// URL of the instance template. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_template: Option, + + /// Target size of the managed instance group. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_size: Option, + + /// Base instance name. + #[serde(skip_serializing_if = "Option::is_none")] + pub base_instance_name: Option, + + /// Current actions summary. + #[serde(skip_serializing_if = "Option::is_none")] + pub current_actions: Option, + + /// Status of the managed instance group. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Target pools for this manager. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_pools: Vec, + + /// Named ports for this manager. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub named_ports: Vec, + + /// Fingerprint for optimistic locking. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Zone URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub zone: Option, + + /// Auto healing policies. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub auto_healing_policies: Vec, + + /// Update policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub update_policy: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#instanceGroupManager"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Summary of instance group manager actions. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerActionsSummary { + /// Number of instances currently being created. + #[serde(skip_serializing_if = "Option::is_none")] + pub creating: Option, + + /// Number of instances currently being deleted. + #[serde(skip_serializing_if = "Option::is_none")] + pub deleting: Option, + + /// Number of instances that exist and are running. + #[serde(skip_serializing_if = "Option::is_none")] + pub none: Option, + + /// Number of instances currently being recreated. + #[serde(skip_serializing_if = "Option::is_none")] + pub recreating: Option, + + /// Number of instances currently being refreshed. + #[serde(skip_serializing_if = "Option::is_none")] + pub refreshing: Option, + + /// Number of instances currently being restarted. + #[serde(skip_serializing_if = "Option::is_none")] + pub restarting: Option, + + /// Number of instances currently being verified. + #[serde(skip_serializing_if = "Option::is_none")] + pub verifying: Option, + + /// Number of instances currently being abandoned. + #[serde(skip_serializing_if = "Option::is_none")] + pub abandoning: Option, + + /// Number of instances in a creating without retries state. + #[serde(skip_serializing_if = "Option::is_none")] + pub creating_without_retries: Option, +} + +/// Status of an instance group manager. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerStatus { + /// Whether the group is stable. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_stable: Option, + + /// Stateful status. + #[serde(skip_serializing_if = "Option::is_none")] + pub stateful: Option, + + /// Version target status. + #[serde(skip_serializing_if = "Option::is_none")] + pub version_target: Option, +} + +/// Stateful status for instance group manager. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerStatusStateful { + /// Whether there are stateful instances. + #[serde(skip_serializing_if = "Option::is_none")] + pub has_stateful_config: Option, + + /// Whether per-instance configs exist. + #[serde(skip_serializing_if = "Option::is_none")] + pub per_instance_configs: Option, +} + +/// Per-instance configs status. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerStatusStatefulPerInstanceConfigs { + /// Whether all configs are effective. + #[serde(skip_serializing_if = "Option::is_none")] + pub all_effective: Option, +} + +/// Version target status. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerStatusVersionTarget { + /// Whether the version target has been reached. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_reached: Option, +} + +/// Named port for instance group. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NamedPort { + /// Name of the port. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Port number. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +/// Auto healing policy for instance group manager. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerAutoHealingPolicy { + /// Health check URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub health_check: Option, + + /// Initial delay in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_delay_sec: Option, +} + +/// Update policy for instance group manager. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagerUpdatePolicy { + /// Type of update. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// Minimal action for updates. + #[serde(skip_serializing_if = "Option::is_none")] + pub minimal_action: Option, + + /// Most disruptive action allowed. + #[serde(skip_serializing_if = "Option::is_none")] + pub most_disruptive_allowed_action: Option, + + /// Maximum surge instances (fixed or percent). + #[serde(skip_serializing_if = "Option::is_none")] + pub max_surge: Option, + + /// Maximum unavailable instances (fixed or percent). + #[serde(skip_serializing_if = "Option::is_none")] + pub max_unavailable: Option, + + /// Replacement method. + #[serde(skip_serializing_if = "Option::is_none")] + pub replacement_method: Option, +} + +/// Update policy type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum UpdatePolicyType { + /// Opportunistic update. + Opportunistic, + /// Proactive update. + Proactive, +} + +/// Minimal action for updates. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MinimalAction { + /// No action. + None, + /// Refresh instance. + Refresh, + /// Restart instance. + Restart, + /// Replace instance. + Replace, +} + +/// Fixed or percent value. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct FixedOrPercent { + /// Fixed value. + #[serde(skip_serializing_if = "Option::is_none")] + pub fixed: Option, + + /// Percentage value. + #[serde(skip_serializing_if = "Option::is_none")] + pub percent: Option, + + /// Calculated value (output only). + #[serde(skip_serializing_if = "Option::is_none")] + pub calculated: Option, +} + +/// Replacement method. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ReplacementMethod { + /// Substitute replacement. + Substitute, + /// Recreate replacement. + Recreate, +} + +/// Response for list managed instances. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagersListManagedInstancesResponse { + /// List of managed instances. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub managed_instances: Vec, + + /// Next page token. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +/// Request to delete selected managed instances from an instance group manager. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct InstanceGroupManagersDeleteInstancesRequest { + /// Instance URLs to delete from the managed instance group. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub instances: Vec, + + /// Continue when valid instances are mixed with already-deleting or non-member instances. + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_instances_on_validation_error: Option, +} + +/// Managed instance in an instance group. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstance { + /// URL of the instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + + /// Instance status. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_status: Option, + + /// Current action. + #[serde(skip_serializing_if = "Option::is_none")] + pub current_action: Option, + + /// Last attempt status. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_attempt: Option, + + /// Unique identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Version of the instance template. + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + + /// Health check results for this instance (populated when a health check is attached to the MIG). + /// JSON field: instanceHealth + #[serde( + default, + rename = "instanceHealth", + skip_serializing_if = "Vec::is_empty" + )] + pub instance_health: Vec, +} + +/// Health state of a managed instance as reported by a health check. +/// Returned in `ManagedInstance.instanceHealth[]` by listManagedInstances. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceHealth { + /// URL of the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub health_check: Option, + + /// Detailed health state of the instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub detailed_health_state: Option, +} + +/// Detailed health state values for a managed instance. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ManagedInstanceDetailedHealthState { + /// The instance is reachable and health check responded with HEALTHY. + Healthy, + /// The health check responded with UNHEALTHY. + Unhealthy, + /// The instance is being drained and will not accept new connections. + Draining, + /// The health check timed out. + Timeout, + /// The health state is unknown (e.g., health check not yet run). + Unknown, +} + +/// Status of a managed instance. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ManagedInstanceStatus { + /// Instance is running. + Running, + /// Instance is pending. + Pending, + /// Instance is provisioning. + Provisioning, + /// Instance is staging. + Staging, + /// Instance is stopped. + Stopped, + /// Instance is stopping. + Stopping, + /// Instance is suspended. + Suspended, + /// Instance is suspending. + Suspending, + /// Instance is terminated. + Terminated, +} + +/// Current action on a managed instance. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ManagedInstanceCurrentAction { + /// No action. + None, + /// Creating instance. + Creating, + /// Creating without retries. + CreatingWithoutRetries, + /// Recreating instance. + Recreating, + /// Deleting instance. + Deleting, + /// Abandoning instance. + Abandoning, + /// Restarting instance. + Restarting, + /// Refreshing instance. + Refreshing, + /// Verifying instance. + Verifying, +} + +/// Last attempt status for a managed instance. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceLastAttempt { + /// Errors from last attempt. + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option, +} + +/// Errors from last attempt. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceLastAttemptErrors { + /// List of errors. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub errors: Vec, +} + +/// Individual error from last attempt. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceLastAttemptErrorsErrors { + /// Error code. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + + /// Error message. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + + /// Structured error details, including quota information when present. + #[builder(default)] + #[serde( + default, + rename = "errorDetails", + skip_serializing_if = "Vec::is_empty" + )] + pub error_details: Vec, +} + +/// Structured details for a managed instance last-attempt error. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceLastAttemptErrorDetail { + /// Quota details for quota-related errors. + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_info: Option, + /// Localized error message. + #[serde(skip_serializing_if = "Option::is_none")] + pub localized_message: Option, +} + +/// Quota details for a managed instance last-attempt error. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceLastAttemptQuotaInfo { + /// Compute Engine quota metric name. + #[serde(skip_serializing_if = "Option::is_none")] + pub metric_name: Option, + /// Compute Engine quota limit name. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_name: Option, + /// Current effective quota limit. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Future quota limit being rolled out. + #[serde(skip_serializing_if = "Option::is_none")] + pub future_limit: Option, +} + +/// Localized message for a managed instance last-attempt error. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceLastAttemptLocalizedMessage { + /// Message locale. + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + /// Localized message text. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Version information for a managed instance. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ManagedInstanceVersion { + /// Instance template URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_template: Option, + + /// Version name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +// ============================================================================================= +// Data Structures - Instance +// ============================================================================================= + +/// Represents a compute instance resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Instance { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Machine type URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_type: Option, + + /// Status of the instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Zone URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub zone: Option, + + /// Disks attached to this instance. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disks: Vec, + + /// Network interfaces for this instance. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub network_interfaces: Vec, + + /// Metadata for this instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + + /// Service accounts for this instance. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub service_accounts: Vec, + + /// Tags for this instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, + + /// Scheduling configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduling: Option, + + /// Labels for this instance. + #[builder(default)] + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub labels: std::collections::HashMap, + + /// Whether IP forwarding is allowed. + #[serde(skip_serializing_if = "Option::is_none")] + pub can_ip_forward: Option, + + /// CPU platform. + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_platform: Option, + + /// Guest accelerators for this instance. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub guest_accelerators: Vec, + + /// Shielded instance configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub shielded_instance_config: Option, + + /// Fingerprint for optimistic locking. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Last start timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_start_timestamp: Option, + + /// Last stop timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_stop_timestamp: Option, + + /// Type of resource (always "compute#instance"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Instance status. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum InstanceStatus { + /// Instance is running. + Running, + /// Instance is provisioning. + Provisioning, + /// Instance is staging. + Staging, + /// Instance is stopped. + Stopped, + /// Instance is stopping. + Stopping, + /// Instance is suspended. + Suspended, + /// Instance is suspending. + Suspending, + /// Instance is terminated. + Terminated, + /// Instance is pending. + Pending, +} + +// ============================================================================================= +// Data Structures - Disk +// ============================================================================================= + +/// Represents a persistent disk resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/disks +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Disk { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Size of the disk in GB. + #[serde(skip_serializing_if = "Option::is_none")] + pub size_gb: Option, + + /// Zone URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub zone: Option, + + /// Status of the disk. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Source snapshot URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_snapshot: Option, + + /// Source snapshot ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_snapshot_id: Option, + + /// Source image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_image: Option, + + /// Source image ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_image_id: Option, + + /// Disk type URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// Users of this disk (instance URLs). + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub users: Vec, + + /// Labels for this disk. + #[builder(default)] + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub labels: std::collections::HashMap, + + /// Label fingerprint for optimistic locking. + #[serde(skip_serializing_if = "Option::is_none")] + pub label_fingerprint: Option, + + /// Physical block size in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub physical_block_size_bytes: Option, + + /// Provisioned IOPS. + #[serde(skip_serializing_if = "Option::is_none")] + pub provisioned_iops: Option, + + /// Provisioned throughput. + #[serde(skip_serializing_if = "Option::is_none")] + pub provisioned_throughput: Option, + + /// Last attach timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_attach_timestamp: Option, + + /// Last detach timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_detach_timestamp: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#disk"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Disk status. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DiskStatus { + /// Disk is being created. + Creating, + /// Disk is restoring from snapshot. + Restoring, + /// Disk creation failed. + Failed, + /// Disk is ready. + Ready, + /// Disk is being deleted. + Deleting, +} + +// ============================================================================================= +// Data Structures - Serial Port Output +// ============================================================================================= + +/// Serial port output from a GCP compute instance (port 1 = main console). +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/instances/getSerialPortOutput +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SerialPortOutput { + /// The contents of the serial port output. + pub contents: Option, + /// The starting byte position of the output that was returned. + pub start: Option, + /// The byte position of the next byte to read (pagination cursor). + pub next: Option, +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/types/load_balancer.rs b/crates/alien-gcp-clients/src/gcp/compute/types/load_balancer.rs new file mode 100644 index 000000000..aef8461d4 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/types/load_balancer.rs @@ -0,0 +1,1295 @@ +use super::network::NetworkTier; +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// ============================================================================================= +// Data Structures - Health Check +// ============================================================================================= + +/// Represents a health check resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/healthChecks +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct HealthCheck { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// How often (in seconds) to send a health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub check_interval_sec: Option, + + /// How long (in seconds) to wait before claiming failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_sec: Option, + + /// Number of consecutive failures before marking unhealthy. + #[serde(skip_serializing_if = "Option::is_none")] + pub unhealthy_threshold: Option, + + /// Number of consecutive successes before marking healthy. + #[serde(skip_serializing_if = "Option::is_none")] + pub healthy_threshold: Option, + + /// Type of health check (TCP, HTTP, HTTPS, HTTP2, GRPC). + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// TCP health check configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub tcp_health_check: Option, + + /// HTTP health check configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub http_health_check: Option, + + /// HTTPS health check configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub https_health_check: Option, + + /// HTTP2 health check configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub http2_health_check: Option, + + /// GRPC health check configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub grpc_health_check: Option, + + /// Log configuration for this health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub log_config: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#healthCheck"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Health check type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum HealthCheckType { + /// TCP health check. + Tcp, + /// HTTP health check. + Http, + /// HTTPS health check. + Https, + /// HTTP/2 health check. + Http2, + /// gRPC health check. + Grpc, +} + +/// TCP health check configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct TcpHealthCheck { + /// Port number for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Port name for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_name: Option, + + /// Port specification type. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_specification: Option, + + /// Proxy header type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_header: Option, + + /// Request data to send. + #[serde(skip_serializing_if = "Option::is_none")] + pub request: Option, + + /// Expected response data. + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// HTTP health check configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct HttpHealthCheck { + /// Port number for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Port name for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_name: Option, + + /// Port specification type. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_specification: Option, + + /// Host header for the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + + /// Request path for the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_path: Option, + + /// Proxy header type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_header: Option, + + /// Expected response data. + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// HTTPS health check configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct HttpsHealthCheck { + /// Port number for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Port name for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_name: Option, + + /// Port specification type. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_specification: Option, + + /// Host header for the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + + /// Request path for the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_path: Option, + + /// Proxy header type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_header: Option, + + /// Expected response data. + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// HTTP/2 health check configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Http2HealthCheck { + /// Port number for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Port name for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_name: Option, + + /// Port specification type. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_specification: Option, + + /// Host header for the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + + /// Request path for the health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_path: Option, + + /// Proxy header type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_header: Option, + + /// Expected response data. + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// gRPC health check configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct GrpcHealthCheck { + /// Port number for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Port name for health check. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_name: Option, + + /// Port specification type. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_specification: Option, + + /// gRPC service name. + #[serde(skip_serializing_if = "Option::is_none")] + pub grpc_service_name: Option, +} + +/// Port specification type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PortSpecification { + /// Use a fixed port number. + UseFixedPort, + /// Use a named port. + UseNamedPort, + /// Use the serving port. + UseServingPort, +} + +/// Proxy header type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ProxyHeader { + /// No proxy header. + None, + /// PROXY_V1 header. + ProxyV1, +} + +/// Health check log configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct HealthCheckLogConfig { + /// Whether to enable logging. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, +} + +// ============================================================================================= +// Data Structures - Backend Service +// ============================================================================================= + +/// Represents a backend service resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/backendServices +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct BackendService { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// List of backends. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub backends: Vec, + + /// Health check URLs for this backend service. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub health_checks: Vec, + + /// Timeout in seconds for backend responses. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_sec: Option, + + /// Port number used for communication with backends. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Protocol used to communicate with backends. + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, + + /// Port name for backends. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_name: Option, + + /// Load balancing scheme. + #[serde(skip_serializing_if = "Option::is_none")] + pub load_balancing_scheme: Option, + + /// Session affinity configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_affinity: Option, + + /// Affinity cookie TTL in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub affinity_cookie_ttl_sec: Option, + + /// Connection draining configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub connection_draining: Option, + + /// Fingerprint of this resource (for optimistic locking). + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Whether to enable CDN for this backend service. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_c_d_n: Option, + + /// CDN policy configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub cdn_policy: Option, + + /// Log configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub log_config: Option, + + /// Security policy URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub security_policy: Option, + + /// Locality load balancing policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub locality_lb_policy: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#backendService"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Backend configuration for a backend service. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Backend { + /// URL of the backend group (instance group or NEG). + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + + /// Balancing mode (UTILIZATION, RATE, CONNECTION). + #[serde(skip_serializing_if = "Option::is_none")] + pub balancing_mode: Option, + + /// Capacity scaler (0.0 to 1.0). + #[serde(skip_serializing_if = "Option::is_none")] + pub capacity_scaler: Option, + + /// Maximum connections for this backend. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_connections: Option, + + /// Maximum connections per instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_connections_per_instance: Option, + + /// Maximum connections per endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_connections_per_endpoint: Option, + + /// Maximum rate for this backend. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_rate: Option, + + /// Maximum rate per instance. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_rate_per_instance: Option, + + /// Maximum rate per endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_rate_per_endpoint: Option, + + /// Maximum CPU utilization for this backend. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_utilization: Option, + + /// Description of this backend. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Balancing mode for a backend. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BalancingMode { + /// Balance by CPU utilization. + Utilization, + /// Balance by request rate. + Rate, + /// Balance by connection count. + Connection, +} + +/// Backend service protocol. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BackendServiceProtocol { + /// HTTP protocol. + Http, + /// HTTPS protocol. + Https, + /// HTTP/2 protocol. + Http2, + /// TCP protocol. + Tcp, + /// SSL protocol. + Ssl, + /// gRPC protocol. + Grpc, + /// Unspecified protocol. + Unspecified, +} + +/// Load balancing scheme. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum LoadBalancingScheme { + /// External load balancing. + External, + /// Internal load balancing. + Internal, + /// Internal self-managed load balancing. + InternalSelfManaged, + /// Internal managed load balancing. + InternalManaged, + /// External managed load balancing. + ExternalManaged, +} + +/// Session affinity type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SessionAffinity { + /// No session affinity. + None, + /// Client IP affinity. + ClientIp, + /// Generated cookie affinity. + GeneratedCookie, + /// Client IP with proto affinity. + ClientIpProto, + /// Client IP and port affinity. + ClientIpPortProto, + /// HTTP cookie affinity. + HttpCookie, + /// Header field affinity. + HeaderField, +} + +/// Connection draining configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionDraining { + /// Time in seconds to wait for connections to drain. + #[serde(skip_serializing_if = "Option::is_none")] + pub draining_timeout_sec: Option, +} + +/// Backend service CDN policy. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct BackendServiceCdnPolicy { + /// Cache mode. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_mode: Option, + + /// Signed URL cache max age in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_url_cache_max_age_sec: Option, + + /// Default TTL in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_ttl: Option, + + /// Maximum TTL in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_ttl: Option, + + /// Client TTL in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_ttl: Option, + + /// Whether to serve stale content while revalidating. + #[serde(skip_serializing_if = "Option::is_none")] + pub serve_while_stale: Option, + + /// Negative caching policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub negative_caching: Option, +} + +/// Cache mode. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CacheMode { + /// Use origin headers. + UseOriginHeaders, + /// Force cache all. + ForceCacheAll, + /// Cache all static content. + CacheAllStatic, +} + +/// Backend service log configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct BackendServiceLogConfig { + /// Whether to enable logging. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, + + /// Sample rate (0.0 to 1.0). + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_rate: Option, +} + +/// Locality load balancing policy. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum LocalityLbPolicy { + /// Round robin. + RoundRobin, + /// Least request. + LeastRequest, + /// Ring hash. + RingHash, + /// Random. + Random, + /// Original destination. + OriginalDestination, + /// Maglev. + Maglev, +} + +// ============================================================================================= +// Data Structures - URL Map +// ============================================================================================= + +/// Represents a URL map resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/urlMaps +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct UrlMap { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Default backend service URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_service: Option, + + /// Host rules for this URL map. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub host_rules: Vec, + + /// Path matchers for this URL map. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub path_matchers: Vec, + + /// Fingerprint of this resource (for optimistic locking). + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#urlMap"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Host rule for URL map. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct HostRule { + /// Description of this rule. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// List of hosts to match. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hosts: Vec, + + /// Name of the path matcher to use. + #[serde(skip_serializing_if = "Option::is_none")] + pub path_matcher: Option, +} + +/// Path matcher for URL map. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct PathMatcher { + /// Name of this path matcher. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Description of this path matcher. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Default backend service URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_service: Option, + + /// Path rules for this matcher. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub path_rules: Vec, +} + +/// Path rule for URL map. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct PathRule { + /// Paths to match. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paths: Vec, + + /// Backend service URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, +} + +// ============================================================================================= +// Data Structures - Target HTTP Proxy +// ============================================================================================= + +/// Represents a target HTTP proxy resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpProxies +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct TargetHttpProxy { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// URL of the URL map associated with this proxy. + #[serde(skip_serializing_if = "Option::is_none")] + pub url_map: Option, + + /// Fingerprint of this resource (for optimistic locking). + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Whether to proxy WebSocket requests. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_bind: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#targetHttpProxy"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +// ============================================================================================= +// Data Structures - Target TCP/HTTPS Proxy +// ============================================================================================= + +/// Global target TCP proxy. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetTcpProxies +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct TargetTcpProxy { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub service: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_header: Option, +} + +/// Represents a target HTTPS proxy resource (with SSL certificate support). +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/targetHttpsProxies +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct TargetHttpsProxy { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// URL of the URL map associated with this proxy. + #[serde(skip_serializing_if = "Option::is_none")] + pub url_map: Option, + + /// URLs of SSL certificates associated with this proxy. + /// At least one SSL certificate must be specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_certificates: Option>, + + /// Fingerprint of this resource (for optimistic locking). + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Whether to proxy WebSocket requests. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_bind: Option, + + /// Minimum TLS version (e.g., "TLS_1_2"). + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl_policy: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#targetHttpsProxy"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + + /// QUIC protocol override (e.g., "NONE", "ENABLE", "DISABLE"). + #[serde(skip_serializing_if = "Option::is_none")] + pub quic_override: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct SetSslCertificatesRequest { + pub ssl_certificates: Vec, +} + +// ============================================================================================= +// Data Structures - SSL Certificate +// ============================================================================================= + +/// Self-managed SSL certificate details. +/// Used when SslCertificate.type = "SELF_MANAGED". +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates#SslCertificateSelfManagedSslCertificate +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct SslCertificateSelfManaged { + /// PEM-encoded X.509 certificate chain. + /// The chain must be no greater than 5 certificates long. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate: Option, + + /// PEM-encoded private key. Write-only; never returned in GET responses. + #[serde(skip_serializing_if = "Option::is_none")] + pub private_key: Option, +} + +/// Represents an SSL certificate resource for load balancers. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/sslCertificates +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct SslCertificate { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Type of certificate ("SELF_MANAGED" or "MANAGED"). + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// Self-managed certificate details. + /// Must be populated when type = "SELF_MANAGED". + #[serde(skip_serializing_if = "Option::is_none")] + pub self_managed: Option, + + /// Domains covered by this certificate (output only). + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_alternative_names: Option>, + + /// Expiration timestamp (RFC3339, output only). + #[serde(skip_serializing_if = "Option::is_none")] + pub expire_time: Option, + + /// Creation timestamp (RFC3339, output only). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#sslCertificate"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +// ============================================================================================= +// Data Structures - Global Address +// ============================================================================================= + +/// Represents a global address resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalAddresses +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Address { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// The static IP address represented by this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + + /// The type of address (EXTERNAL or INTERNAL). + #[serde(skip_serializing_if = "Option::is_none")] + pub address_type: Option, + + /// IP version (IPV4 or IPV6). + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_version: Option, + + /// Purpose of the address. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option, + + /// Network tier for this address. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_tier: Option, + + /// Status of the address (RESERVED, IN_USE, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// URL of the resource using this address. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub users: Vec, + + /// Prefix length for IPv6 addresses. + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix_length: Option, + + /// Network URL for internal addresses. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// Subnetwork URL for internal addresses. + #[serde(skip_serializing_if = "Option::is_none")] + pub subnetwork: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#address"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Address type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AddressType { + /// External address. + External, + /// Internal address. + Internal, +} + +/// IP version. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IpVersion { + /// IPv4. + Ipv4, + /// IPv6. + Ipv6, +} + +/// Address purpose. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AddressPurpose { + /// GCE endpoint. + GceEndpoint, + /// VPC peering. + VpcPeering, + /// Private service connect. + PrivateServiceConnect, + /// NAT auto. + NatAuto, + /// Shared loadbalancer VIP. + SharedLoadbalancerVip, + /// DNS resolver. + DnsResolver, +} + +/// Address status. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AddressStatus { + /// Address is reserved. + Reserved, + /// Address is reserved but being used. + Reserving, + /// Address is in use. + InUse, +} + +// ============================================================================================= +// Data Structures - Global Forwarding Rule +// ============================================================================================= + +/// Represents a forwarding rule resource (global or regional). +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalForwardingRules +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/forwardingRules +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ForwardingRule { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// IP address for this forwarding rule. + #[serde(rename = "IPAddress", skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + + /// IP protocol for this forwarding rule. + #[serde(rename = "IPProtocol", skip_serializing_if = "Option::is_none")] + pub ip_protocol: Option, + + /// Port range for this forwarding rule. + #[serde(skip_serializing_if = "Option::is_none")] + pub port_range: Option, + + /// List of ports for this forwarding rule. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, + + /// URL of the target resource. For a Private Service Connect consumer + /// endpoint this is the producer's service-attachment URI. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + + /// URL of the network this forwarding rule belongs to. Required for a + /// Private Service Connect consumer endpoint, which lives in the consumer VPC. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// URL of the subnetwork this forwarding rule draws its internal IP from. + /// Used by internal forwarding rules such as Private Service Connect endpoints. + #[serde(skip_serializing_if = "Option::is_none")] + pub subnetwork: Option, + + /// Load balancing scheme. Left unset for a Private Service Connect consumer + /// endpoint, which is not a load balancer. + #[serde(skip_serializing_if = "Option::is_none")] + pub load_balancing_scheme: Option, + + /// Network tier for this forwarding rule. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_tier: Option, + + /// Fingerprint of this resource (for optimistic locking). + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#forwardingRule"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Forwarding rule IP protocol. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ForwardingRuleProtocol { + /// TCP protocol. + Tcp, + /// UDP protocol. + Udp, + /// ESP protocol. + Esp, + /// AH protocol. + Ah, + /// SCTP protocol. + Sctp, + /// ICMP protocol. + Icmp, + /// L3 default protocol. + L3Default, +} + +// ============================================================================================= +// Data Structures - Network Endpoint Group (NEG) +// ============================================================================================= + +/// Represents a network endpoint group resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/networkEndpointGroups +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpointGroup { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Type of network endpoint group. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_endpoint_type: Option, + + /// Size of the network endpoint group. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + + /// URL of the network to which this NEG belongs. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// URL of the subnetwork to which this NEG belongs. + #[serde(skip_serializing_if = "Option::is_none")] + pub subnetwork: Option, + + /// URL of the zone where the NEG is located. + #[serde(skip_serializing_if = "Option::is_none")] + pub zone: Option, + + /// Default port for endpoints. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_port: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#networkEndpointGroup"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + + /// Cloud Run service configuration for serverless NEG. + /// Only valid when network_endpoint_type is SERVERLESS. + /// Only one of cloud_run, app_engine, or cloud_function may be set. + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud_run: Option, + + /// App Engine service configuration for serverless NEG. + /// Only valid when network_endpoint_type is SERVERLESS. + /// Only one of cloud_run, app_engine, or cloud_function may be set. + #[serde(skip_serializing_if = "Option::is_none")] + pub app_engine: Option, + + /// Cloud Function configuration for serverless NEG. + /// Only valid when network_endpoint_type is SERVERLESS. + /// Only one of cloud_run, app_engine, or cloud_function may be set. + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud_function: Option, +} + +/// Cloud Run service configuration for a serverless NEG. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpointGroupCloudRun { + /// Cloud Run service name. + /// Example: "my-service" + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + + /// Cloud Run service tag (optional). + /// Example: "v1", "production" + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + + /// URL mask for routing to multiple Cloud Run services. + /// Example: ".domain.com/" allows routing based on URL patterns. + #[serde(skip_serializing_if = "Option::is_none")] + pub url_mask: Option, +} + +/// App Engine service configuration for a serverless NEG. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpointGroupAppEngine { + /// App Engine service name (optional). + /// The service name is case-sensitive and must be 1-63 characters long. + /// Example: "default", "my-service" + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + + /// App Engine version (optional). + /// The version name is case-sensitive and must be 1-100 characters long. + /// Example: "v1", "v2" + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + + /// URL mask for routing to multiple App Engine services. + /// Example: "-dot-appname.appspot.com/" + #[serde(skip_serializing_if = "Option::is_none")] + pub url_mask: Option, +} + +/// Cloud Function configuration for a serverless NEG. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpointGroupCloudFunction { + /// Cloud Function name. + /// The function name is case-sensitive and must be 1-63 characters long. + /// Example: "func1" + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, + + /// URL mask for routing to multiple Cloud Functions. + /// Example: "/" allows routing based on URL patterns. + #[serde(skip_serializing_if = "Option::is_none")] + pub url_mask: Option, +} + +/// Network endpoint type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NetworkEndpointType { + /// GCE VM IP port endpoint. + GceVmIpPort, + /// Non-GCP private IP port endpoint. + NonGcpPrivateIpPort, + /// Internet IP port endpoint. + InternetIpPort, + /// Internet FQDN port endpoint. + InternetFqdnPort, + /// Serverless endpoint. + Serverless, + /// Private service connect endpoint. + PrivateServiceConnect, +} + +/// Request to attach network endpoints. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpointGroupsAttachEndpointsRequest { + /// Network endpoints to attach. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub network_endpoints: Vec, +} + +/// Request to detach network endpoints. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpointGroupsDetachEndpointsRequest { + /// Network endpoints to detach. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub network_endpoints: Vec, +} + +/// Network endpoint. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEndpoint { + /// IP address of the endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + + /// Port number for the endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Instance that the endpoint belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + + /// FQDN of the endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub fqdn: Option, +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/types/mod.rs b/crates/alien-gcp-clients/src/gcp/compute/types/mod.rs new file mode 100644 index 000000000..574792fe5 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/types/mod.rs @@ -0,0 +1,9 @@ +mod instance; +mod load_balancer; +mod network; +mod operations; + +pub use instance::*; +pub use load_balancer::*; +pub use network::*; +pub use operations::*; diff --git a/crates/alien-gcp-clients/src/gcp/compute/types/network.rs b/crates/alien-gcp-clients/src/gcp/compute/types/network.rs new file mode 100644 index 000000000..4a044f5de --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/types/network.rs @@ -0,0 +1,987 @@ +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// ============================================================================================= +// Data Structures - Network +// ============================================================================================= + +/// Represents a VPC network resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/networks +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Network { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. Must be 1-63 characters, lowercase letters, numbers, or hyphens. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// When true, VMs in this network without external IPs can access Google APIs using Private Google Access. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_create_subnetworks: Option, + + /// Server-defined list of subnetwork URLs for this VPC network. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subnetworks: Vec, + + /// The network routing mode (REGIONAL or GLOBAL). + #[serde(skip_serializing_if = "Option::is_none")] + pub routing_config: Option, + + /// Maximum Transmission Unit in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub mtu: Option, + + /// Firewall policy enforced on the network. + #[serde(skip_serializing_if = "Option::is_none")] + pub firewall_policy: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Gateway IPv4 address (output only, for legacy networks). + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway_i_pv4: Option, + + /// Internal IPv6 range for this network. + #[serde(skip_serializing_if = "Option::is_none")] + pub internal_ipv6_range: Option, + + /// Type of resource (always "compute#network"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + + /// Network firewall policy enforcement order. + #[serde(skip_serializing_if = "Option::is_none")] + pub network_firewall_policy_enforcement_order: Option, +} + +/// Routing configuration for a network. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct NetworkRoutingConfig { + /// The network-wide routing mode: REGIONAL or GLOBAL. + #[serde(skip_serializing_if = "Option::is_none")] + pub routing_mode: Option, +} + +/// Network routing mode. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RoutingMode { + /// Regional routing: routes are only advertised to routers in the same region. + Regional, + /// Global routing: routes are advertised to all routers in the network. + Global, +} + +/// Network firewall policy enforcement order. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NetworkFirewallPolicyEnforcementOrder { + /// Evaluate firewall policy before VPC firewall rules. + BeforeClassicFirewall, + /// Evaluate firewall policy after VPC firewall rules. + AfterClassicFirewall, +} + +// ============================================================================================= +// Data Structures - Subnetwork +// ============================================================================================= + +/// Represents a subnetwork resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/subnetworks +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Subnetwork { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// URL of the network this subnetwork belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// IP CIDR range for this subnetwork (e.g., "10.0.0.0/24"). + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_cidr_range: Option, + + /// URL of the region this subnetwork belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + + /// Gateway address for default routes to IPs within this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway_address: Option, + + /// Whether VMs in this subnetwork can access Google services without external IPs. + #[serde(skip_serializing_if = "Option::is_none")] + pub private_ip_google_access: Option, + + /// Purpose of the subnetwork (PRIVATE, INTERNAL_HTTPS_LOAD_BALANCER, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option, + + /// Role of the subnetwork (ACTIVE or BACKUP for INTERNAL_HTTPS_LOAD_BALANCER). + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + + /// Secondary IP ranges for this subnetwork. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secondary_ip_ranges: Vec, + + /// Fingerprint of this resource (for optimistic locking). + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Whether flow logs are enabled for this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_flow_logs: Option, + + /// Log configuration for this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub log_config: Option, + + /// Stack type for this subnetwork (IPV4_ONLY or IPV4_IPV6). + #[serde(skip_serializing_if = "Option::is_none")] + pub stack_type: Option, + + /// IPv6 access type for this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv6_access_type: Option, + + /// IPv6 CIDR range for this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv6_cidr_range: Option, + + /// External IPv6 prefix for this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_ipv6_prefix: Option, + + /// Internal IPv6 prefix for this subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub internal_ipv6_prefix: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#subnetwork"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + + /// Private IPv6 Google access type. + #[serde(skip_serializing_if = "Option::is_none")] + pub private_ipv6_google_access: Option, +} + +/// Purpose of a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SubnetworkPurpose { + /// Regular user-created subnetwork. + Private, + /// Reserved for Internal HTTP(S) Load Balancer. + InternalHttpsLoadBalancer, + /// Reserved for Regional Internal HTTP(S) Load Balancer. + RegionalManagedProxy, + /// Reserved for Global Internal HTTP(S) Load Balancer. + GlobalManagedProxy, + /// Reserved for Private Service Connect. + PrivateServiceConnect, + /// Reserved for Private NAT. + PrivateNat, +} + +/// Role of a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SubnetworkRole { + /// Active role. + Active, + /// Backup role. + Backup, +} + +/// Secondary IP range for a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct SubnetworkSecondaryRange { + /// Name of the secondary range. + #[serde(skip_serializing_if = "Option::is_none")] + pub range_name: Option, + + /// IP CIDR range for the secondary range. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_cidr_range: Option, +} + +/// Log configuration for a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct SubnetworkLogConfig { + /// Whether to enable flow logs. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, + + /// Aggregation interval for flow logs. + #[serde(skip_serializing_if = "Option::is_none")] + pub aggregation_interval: Option, + + /// Sampling rate for flow logs (0.0-1.0). + #[serde(skip_serializing_if = "Option::is_none")] + pub flow_sampling: Option, + + /// Metadata to include in flow logs. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + + /// Custom metadata fields to include. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub metadata_fields: Vec, + + /// Filter expression for flow logs. + #[serde(skip_serializing_if = "Option::is_none")] + pub filter_expr: Option, +} + +/// Aggregation interval for flow logs. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AggregationInterval { + /// 5 second interval. + Interval5Sec, + /// 30 second interval. + Interval30Sec, + /// 1 minute interval. + Interval1Min, + /// 5 minute interval. + Interval5Min, + /// 10 minute interval. + Interval10Min, + /// 15 minute interval. + Interval15Min, +} + +/// Metadata configuration for subnetwork logs. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SubnetworkLogConfigMetadata { + /// Exclude all metadata. + ExcludeAllMetadata, + /// Include all metadata. + IncludeAllMetadata, + /// Include custom metadata only. + CustomMetadata, +} + +/// Stack type for a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum StackType { + /// IPv4 only. + Ipv4Only, + /// Dual-stack (IPv4 and IPv6). + Ipv4Ipv6, +} + +/// IPv6 access type for a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Ipv6AccessType { + /// External IPv6 access. + External, + /// Internal IPv6 access. + Internal, +} + +/// Private IPv6 Google access type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PrivateIpv6GoogleAccess { + /// Disable private IPv6 Google access. + DisableGoogleAccess, + /// Enable outbound VM access to Google services via IPv6. + EnableOutboundVmAccessToGoogle, + /// Enable bidirectional access to Google services via IPv6. + EnableBidirectionalAccessToGoogle, +} + +// ============================================================================================= +// Data Structures - Router +// ============================================================================================= + +/// Represents a Cloud Router resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/routers +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Router { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// URL of the region this router belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + + /// URL of the network this router belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// BGP information for this router. + #[serde(skip_serializing_if = "Option::is_none")] + pub bgp: Option, + + /// BGP peers for this router. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bgp_peers: Vec, + + /// NAT configurations for this router. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nats: Vec, + + /// Router interfaces. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub interfaces: Vec, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#router"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + + /// Encrypted interconnect router flag. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_interconnect_router: Option, +} + +/// BGP configuration for a router. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterBgp { + /// Local BGP Autonomous System Number (ASN). + #[serde(skip_serializing_if = "Option::is_none")] + pub asn: Option, + + /// Advertise mode for this BGP speaker. + #[serde(skip_serializing_if = "Option::is_none")] + pub advertise_mode: Option, + + /// Groups of prefixes to be advertised. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub advertised_groups: Vec, + + /// Individual prefixes to be advertised. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub advertised_ip_ranges: Vec, + + /// Keepalive interval in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub keepalive_interval: Option, +} + +/// BGP advertise mode. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AdvertiseMode { + /// Advertise default routes. + Default, + /// Advertise custom routes. + Custom, +} + +/// Groups of prefixes to advertise. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AdvertisedGroup { + /// Advertise all subnets. + AllSubnets, +} + +/// Individual IP range to advertise. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterAdvertisedIpRange { + /// IP range to advertise. + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + + /// Description of this IP range. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// BGP peer configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterBgpPeer { + /// Name of this BGP peer. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Name of the interface the BGP peer is associated with. + #[serde(skip_serializing_if = "Option::is_none")] + pub interface_name: Option, + + /// IP address of the peer. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer_ip_address: Option, + + /// Peer BGP ASN. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer_asn: Option, + + /// Advertise mode for this BGP peer. + #[serde(skip_serializing_if = "Option::is_none")] + pub advertise_mode: Option, + + /// Advertised groups for this peer. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub advertised_groups: Vec, + + /// Advertised IP ranges for this peer. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub advertised_ip_ranges: Vec, + + /// BGP peer status. + #[serde(skip_serializing_if = "Option::is_none")] + pub management_type: Option, + + /// Whether this peer is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, + + /// Advertised route priority. + #[serde(skip_serializing_if = "Option::is_none")] + pub advertised_route_priority: Option, +} + +/// Management type for a BGP peer. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ManagementType { + /// Peer is managed by the user. + ManagedByUser, + /// Peer is managed by an attachment. + ManagedByAttachment, +} + +/// Router interface configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterInterface { + /// Name of this interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// IP range for this interface (CIDR format). + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_range: Option, + + /// URL of the linked VPN tunnel. + #[serde(skip_serializing_if = "Option::is_none")] + pub linked_vpn_tunnel: Option, + + /// URL of the linked interconnect attachment. + #[serde(skip_serializing_if = "Option::is_none")] + pub linked_interconnect_attachment: Option, + + /// Management type for this interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub management_type: Option, + + /// Subnetwork this interface is attached to. + #[serde(skip_serializing_if = "Option::is_none")] + pub subnetwork: Option, + + /// Private IP address for this interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub private_ip_address: Option, + + /// Redundant interface for this router interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub redundant_interface: Option, +} + +/// Cloud NAT configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterNat { + /// Name of this NAT configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Type of NAT (endpoint-independent or endpoint-dependent). + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// Source subnetwork IP ranges to NAT. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_subnetwork_ip_ranges_to_nat: Option, + + /// Subnetworks to NAT. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subnetworks: Vec, + + /// NAT IP allocation option. + #[serde(skip_serializing_if = "Option::is_none")] + pub nat_ip_allocate_option: Option, + + /// NAT IPs to use. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nat_ips: Vec, + + /// Drain NAT IPs. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub drain_nat_ips: Vec, + + /// Minimum ports per VM. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_ports_per_vm: Option, + + /// Maximum ports per VM. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_ports_per_vm: Option, + + /// UDP idle timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub udp_idle_timeout_sec: Option, + + /// ICMP idle timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub icmp_idle_timeout_sec: Option, + + /// TCP established idle timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub tcp_established_idle_timeout_sec: Option, + + /// TCP transitory idle timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub tcp_transitory_idle_timeout_sec: Option, + + /// TCP time wait timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub tcp_time_wait_timeout_sec: Option, + + /// Log configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub log_config: Option, + + /// Whether endpoint-independent mapping is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_endpoint_independent_mapping: Option, + + /// Whether dynamic port allocation is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_dynamic_port_allocation: Option, + + /// NAT rules. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + + /// Auto network tier for this NAT. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_network_tier: Option, +} + +/// NAT type. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NatType { + /// Public NAT. + Public, + /// Private NAT. + Private, +} + +/// Source subnetwork IP ranges to NAT. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SourceSubnetworkIpRangesToNat { + /// NAT all primary and secondary IP ranges of all subnetworks. + AllSubnetworksAllIpRanges, + /// NAT only primary IP ranges of all subnetworks. + AllSubnetworksAllPrimaryIpRanges, + /// NAT only specific subnetworks. + ListOfSubnetworks, +} + +/// NAT IP allocation option. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NatIpAllocateOption { + /// Allocate NAT IPs automatically. + AutoOnly, + /// Use manually specified NAT IPs. + ManualOnly, +} + +/// Subnetwork to NAT configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterNatSubnetworkToNat { + /// Name of the subnetwork. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Source IP ranges to NAT. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_ip_ranges_to_nat: Vec, + + /// Secondary IP range names to NAT. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secondary_ip_range_names: Vec, +} + +/// Source IP ranges to NAT for a subnetwork. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SourceIpRangesToNat { + /// NAT all IP ranges. + AllIpRanges, + /// NAT primary IP range only. + PrimaryIpRange, + /// NAT only specified secondary IP ranges. + ListOfSecondaryIpRanges, +} + +/// NAT log configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterNatLogConfig { + /// Whether to enable logging. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, + + /// Log filter. + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, +} + +/// NAT log filter. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NatLogFilter { + /// Log all events. + All, + /// Log errors only. + ErrorsOnly, + /// Log translations only. + TranslationsOnly, +} + +/// NAT rule. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterNatRule { + /// Rule number. + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_number: Option, + + /// Description of this rule. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Match condition. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#match: Option, + + /// Action to take when the rule matches. + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, +} + +/// NAT rule action. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterNatRuleAction { + /// Source NAT active IPs. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_nat_active_ips: Vec, + + /// Source NAT drain IPs. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_nat_drain_ips: Vec, + + /// Source NAT active ranges. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_nat_active_ranges: Vec, + + /// Source NAT drain ranges. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_nat_drain_ranges: Vec, +} + +/// Network tier. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NetworkTier { + /// Premium tier. + Premium, + /// Standard tier. + Standard, +} + +/// List of routers. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct RouterList { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// List of routers. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + + /// Server-defined URL for this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Token for next page of results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + + /// Type of resource (always "compute#routerList"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +// ============================================================================================= +// Data Structures - Firewall +// ============================================================================================= + +/// Represents a firewall rule resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/firewalls +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Firewall { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// URL of the network this firewall applies to. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + + /// Priority for this rule (0-65535, lower is higher priority). + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + + /// Direction of traffic (INGRESS or EGRESS). + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + + /// Action (allow or deny). + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed: Vec, + + /// Denied traffic specifications. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub denied: Vec, + + /// Source IP ranges for INGRESS rules. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_ranges: Vec, + + /// Destination IP ranges for EGRESS rules. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub destination_ranges: Vec, + + /// Source tags for INGRESS rules. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_tags: Vec, + + /// Target tags for this rule. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_tags: Vec, + + /// Source service accounts for INGRESS rules. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_service_accounts: Vec, + + /// Target service accounts for this rule. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_service_accounts: Vec, + + /// Whether the rule is disabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled: Option, + + /// Whether logging is enabled for this rule. + #[serde(skip_serializing_if = "Option::is_none")] + pub log_config: Option, + + /// Creation timestamp (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub creation_timestamp: Option, + + /// Type of resource (always "compute#firewall"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Direction of a firewall rule. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum FirewallDirection { + /// Incoming traffic. + Ingress, + /// Outgoing traffic. + Egress, +} + +/// Allowed traffic specification. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct FirewallAllowed { + /// IP protocol (tcp, udp, icmp, esp, ah, sctp, ipip, all). + #[serde(rename = "IPProtocol", skip_serializing_if = "Option::is_none")] + pub ip_protocol: Option, + + /// Ports to allow (e.g., "80", "8080-8090"). + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, +} + +/// Denied traffic specification. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct FirewallDenied { + /// IP protocol (tcp, udp, icmp, esp, ah, sctp, ipip, all). + #[serde(rename = "IPProtocol", skip_serializing_if = "Option::is_none")] + pub ip_protocol: Option, + + /// Ports to deny. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, +} + +/// Firewall log configuration. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct FirewallLogConfig { + /// Whether to enable logging. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, + + /// Metadata to include in logs. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Metadata configuration for firewall logs. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum FirewallLogConfigMetadata { + /// Exclude all metadata. + ExcludeAllMetadata, + /// Include all metadata. + IncludeAllMetadata, +} + +/// List of firewall rules. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct FirewallList { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// List of firewall rules. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + + /// Server-defined URL for this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Token for next page of results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + + /// Type of resource (always "compute#firewallList"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} diff --git a/crates/alien-gcp-clients/src/gcp/compute/types/operations.rs b/crates/alien-gcp-clients/src/gcp/compute/types/operations.rs new file mode 100644 index 000000000..2e0b43c80 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/types/operations.rs @@ -0,0 +1,199 @@ +use bon::Builder; +use serde::{Deserialize, Serialize}; + +// ============================================================================================= +// Data Structures - Operation +// ============================================================================================= + +/// Represents a Compute Engine operation. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Operation { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Type of the operation (e.g., "insert", "delete"). + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_type: Option, + + /// URL of the resource the operation modifies. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_link: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// User who requested the operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + + /// Status of the operation: PENDING, RUNNING, or DONE. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Optional progress indicator (0-100). + #[serde(skip_serializing_if = "Option::is_none")] + pub progress: Option, + + /// Time the operation was started (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + + /// Time the operation was completed (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, + + /// Time the operation was requested (RFC3339). + #[serde(skip_serializing_if = "Option::is_none")] + pub insert_time: Option, + + /// URL of the zone where the operation resides (for zonal operations). + #[serde(skip_serializing_if = "Option::is_none")] + pub zone: Option, + + /// URL of the region where the operation resides (for regional operations). + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + + /// Description of the operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// HTTP error status code returned if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub http_error_status_code: Option, + + /// HTTP error message returned if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub http_error_message: Option, + + /// Error information if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + + /// Type of resource (always "compute#operation"). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +impl Operation { + /// Returns true if the operation has completed (status == DONE). + pub fn is_done(&self) -> bool { + matches!(self.status, Some(OperationStatus::Done)) + } + + /// Returns true if the operation completed with an error. + pub fn has_error(&self) -> bool { + self.error.is_some() && !self.error.as_ref().unwrap().errors.is_empty() + } +} + +/// Status of an operation. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum OperationStatus { + /// Operation is pending. + Pending, + /// Operation is running. + Running, + /// Operation is complete. + Done, +} + +/// Error information for a failed operation. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct OperationError { + /// Array of errors. + #[builder(default)] + #[serde(default)] + pub errors: Vec, +} + +/// Individual error item in an operation error. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct OperationErrorItem { + /// Error code. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + + /// Location in the request that caused the error. + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + + /// Human-readable error message. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +// ============================================================================================= +// Data Structures - Zone +// ============================================================================================= + +/// Represents a Compute Engine zone resource. +/// See: https://cloud.google.com/compute/docs/reference/rest/v1/zones +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct Zone { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Name of the zone. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Optional description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Server-defined URL for the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Region URL this zone belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + + /// Zone status, commonly "UP" for usable zones. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Type of resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// List of Compute Engine zones. +#[derive(Debug, Serialize, Deserialize, Clone, Default, Builder)] +#[serde(rename_all = "camelCase")] +pub struct ZoneList { + /// Unique identifier; defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// List of zones. + #[builder(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + + /// Server-defined URL for this resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub self_link: Option, + + /// Token for next page of results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + + /// Type of resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests.rs deleted file mode 100644 index 6eb132a40..000000000 --- a/crates/alien-gcp-clients/tests/gcp_compute_client_tests.rs +++ /dev/null @@ -1,3140 +0,0 @@ -//! Comprehensive E2E tests for the GCP Compute Engine client. -//! -//! These tests create real VPC resources in GCP and verify all operations work correctly. -//! Since VPC resources are expensive to set up and take time, we use a single comprehensive -//! test that exercises all APIs in sequence. - -use alien_client_core::{Error, ErrorData}; -use alien_gcp_clients::compute::{ - Address, AttachedDisk, AttachedDiskInitializeParams, AttachedDiskType, Backend, BackendService, - BackendServiceProtocol, BalancingMode, ComputeApi, ComputeClient, Disk, DiskMode, Firewall, - FirewallAllowed, FirewallDirection, FixedOrPercent, ForwardingRule, ForwardingRuleProtocol, - HealthCheck, HealthCheckType, HttpHealthCheck, InstanceGroupManager, - InstanceGroupManagerUpdatePolicy, InstanceProperties, InstanceTemplate, LoadBalancingScheme, - ManagedInstance, ManagedInstanceCurrentAction, ManagedInstanceStatus, MinimalAction, - NatIpAllocateOption, Network, NetworkEndpointGroup, NetworkEndpointType, NetworkInterface, - NetworkRoutingConfig, Router, RouterNat, RoutingMode, ServiceAccount, - SourceSubnetworkIpRangesToNat, SslCertificate, SslCertificateSelfManaged, Subnetwork, - TargetHttpProxy, TargetHttpsProxy, UpdatePolicyType, UrlMap, -}; -use alien_gcp_clients::platform::{GcpClientConfig, GcpCredentials}; -use reqwest::Client; -use std::collections::HashSet; -use std::env; -use std::path::PathBuf; -use std::sync::Mutex; -use test_context::{test_context, AsyncTestContext}; -use tracing::{info, warn}; -use uuid::Uuid; - -const TEST_REGION: &str = "us-central1"; -const TEST_ZONE: &str = "us-central1-a"; -const NETWORK_DELETE_TIMEOUT_SECONDS: u64 = 300; -const NETWORK_DELETE_RETRY_INTERVAL_SECONDS: u64 = 10; - -struct ComputeTestContext { - client: ComputeClient, - project_id: String, - region: String, - zone: String, - /// Resources to clean up, in reverse order of creation - created_networks: Mutex>, - created_subnetworks: Mutex>, // (region, name) - created_routers: Mutex>, // (region, name) - created_firewalls: Mutex>, - // Load Balancing resources - created_health_checks: Mutex>, - created_backend_services: Mutex>, - created_url_maps: Mutex>, - created_target_http_proxies: Mutex>, - created_global_addresses: Mutex>, - created_global_forwarding_rules: Mutex>, - created_negs: Mutex>, // (zone, name) - // Instance Management resources - created_instance_templates: Mutex>, - created_instance_group_managers: Mutex>, // (zone, name) - // Disk resources - created_disks: Mutex>, // (zone, name) -} - -impl AsyncTestContext for ComputeTestContext { - async fn setup() -> ComputeTestContext { - let root: PathBuf = workspace_root::get_workspace_root(); - dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); - tracing_subscriber::fmt::try_init().ok(); - - let gcp_credentials_json = env::var("GOOGLE_MANAGEMENT_SERVICE_ACCOUNT_KEY") - .unwrap_or_else(|_| panic!("GOOGLE_MANAGEMENT_SERVICE_ACCOUNT_KEY must be set")); - - // Parse project_id from service account - let service_account_value: serde_json::Value = - serde_json::from_str(&gcp_credentials_json).unwrap(); - let project_id = service_account_value - .get("project_id") - .and_then(|v| v.as_str()) - .map(String::from) - .expect("'project_id' must be present in the service account JSON"); - - let config = GcpClientConfig { - project_id: project_id.clone(), - region: TEST_REGION.to_string(), - credentials: GcpCredentials::ServiceAccountKey { - json: gcp_credentials_json, - }, - service_overrides: None, - project_number: None, - }; - - let client = ComputeClient::new(Client::new(), config); - - ComputeTestContext { - client, - project_id, - region: TEST_REGION.to_string(), - zone: TEST_ZONE.to_string(), - created_networks: Mutex::new(HashSet::new()), - created_subnetworks: Mutex::new(HashSet::new()), - created_routers: Mutex::new(HashSet::new()), - created_firewalls: Mutex::new(HashSet::new()), - created_health_checks: Mutex::new(HashSet::new()), - created_backend_services: Mutex::new(HashSet::new()), - created_url_maps: Mutex::new(HashSet::new()), - created_target_http_proxies: Mutex::new(HashSet::new()), - created_global_addresses: Mutex::new(HashSet::new()), - created_global_forwarding_rules: Mutex::new(HashSet::new()), - created_negs: Mutex::new(HashSet::new()), - created_instance_templates: Mutex::new(HashSet::new()), - created_instance_group_managers: Mutex::new(HashSet::new()), - created_disks: Mutex::new(HashSet::new()), - } - } - - async fn teardown(self) { - info!("🧹 Starting Compute Engine test cleanup..."); - - // Clean up in reverse dependency order - - // Delete disks (must be detached first) - let disks_to_cleanup = { - let disks = self.created_disks.lock().unwrap(); - disks.clone() - }; - for (zone, disk_name) in disks_to_cleanup { - self.cleanup_disk(&zone, &disk_name).await; - } - - // Delete instance group managers (must be done before instance templates) - let igms_to_cleanup = { - let igms = self.created_instance_group_managers.lock().unwrap(); - igms.clone() - }; - for (zone, igm_name) in igms_to_cleanup { - self.cleanup_instance_group_manager(&zone, &igm_name).await; - } - - // Delete instance templates - let templates_to_cleanup = { - let templates = self.created_instance_templates.lock().unwrap(); - templates.clone() - }; - for template_name in templates_to_cleanup { - self.cleanup_instance_template(&template_name).await; - } - - // Delete forwarding rules (must be before target proxies and addresses) - let fwds_to_cleanup = { - let fwds = self.created_global_forwarding_rules.lock().unwrap(); - fwds.clone() - }; - for fwd_name in fwds_to_cleanup { - self.cleanup_global_forwarding_rule(&fwd_name).await; - } - - // Delete target HTTP proxies (must be before URL maps) - let proxies_to_cleanup = { - let proxies = self.created_target_http_proxies.lock().unwrap(); - proxies.clone() - }; - for proxy_name in proxies_to_cleanup { - self.cleanup_target_http_proxy(&proxy_name).await; - } - - // Delete URL maps (must be before backend services) - let url_maps_to_cleanup = { - let url_maps = self.created_url_maps.lock().unwrap(); - url_maps.clone() - }; - for url_map_name in url_maps_to_cleanup { - self.cleanup_url_map(&url_map_name).await; - } - - // Delete backend services (must be before health checks and NEGs) - let bs_to_cleanup = { - let bs = self.created_backend_services.lock().unwrap(); - bs.clone() - }; - for bs_name in bs_to_cleanup { - self.cleanup_backend_service(&bs_name).await; - } - - // Delete NEGs - let negs_to_cleanup = { - let negs = self.created_negs.lock().unwrap(); - negs.clone() - }; - for (zone, neg_name) in negs_to_cleanup { - self.cleanup_neg(&zone, &neg_name).await; - } - - // Delete health checks - let hc_to_cleanup = { - let hc = self.created_health_checks.lock().unwrap(); - hc.clone() - }; - for hc_name in hc_to_cleanup { - self.cleanup_health_check(&hc_name).await; - } - - // Delete global addresses - let addrs_to_cleanup = { - let addrs = self.created_global_addresses.lock().unwrap(); - addrs.clone() - }; - for addr_name in addrs_to_cleanup { - self.cleanup_global_address(&addr_name).await; - } - - // Delete firewalls - let firewalls_to_cleanup = { - let firewalls = self.created_firewalls.lock().unwrap(); - firewalls.clone() - }; - for firewall_name in firewalls_to_cleanup { - self.cleanup_firewall(&firewall_name).await; - } - - // Delete routers - let routers_to_cleanup = { - let routers = self.created_routers.lock().unwrap(); - routers.clone() - }; - for (region, router_name) in routers_to_cleanup { - self.cleanup_router(®ion, &router_name).await; - } - - // Delete subnetworks - let subnetworks_to_cleanup = { - let subnetworks = self.created_subnetworks.lock().unwrap(); - subnetworks.clone() - }; - for (region, subnetwork_name) in subnetworks_to_cleanup { - self.cleanup_subnetwork(®ion, &subnetwork_name).await; - } - - // Delete networks - let networks_to_cleanup = { - let networks = self.created_networks.lock().unwrap(); - networks.clone() - }; - for network_name in networks_to_cleanup { - self.cleanup_network(&network_name).await; - } - - info!("✅ Compute Engine test cleanup completed"); - } -} - -impl ComputeTestContext { - fn is_resource_not_ready_error(err: &Error) -> bool { - let msg = format!("{:?}", err).to_lowercase(); - msg.contains("resourcenotready") || msg.contains("not ready") - } - - async fn delete_network_with_retry( - &self, - network_name: &str, - timeout_seconds: u64, - ) -> std::result::Result<(), Box> { - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(timeout_seconds); - let mut attempt = 0u64; - - loop { - if start_time.elapsed() > timeout_duration { - return Err(format!( - "Timed out deleting network {} after {}s", - network_name, timeout_seconds - ) - .into()); - } - - attempt += 1; - info!( - "🧹 Attempting network deletion (attempt {}): {}", - attempt, network_name - ); - - match self.client.delete_network(network_name.to_string()).await { - Ok(operation) => { - let op_name = operation.name.as_deref().ok_or_else(|| { - format!("Delete network {} returned no operation name", network_name) - })?; - - let remaining = timeout_duration - .saturating_sub(start_time.elapsed()) - .as_secs() - .max(1); - - match self.wait_for_global_operation(op_name, remaining).await { - Ok(()) => return Ok(()), - Err(e) => { - warn!( - "Network delete operation for {} did not complete yet: {}", - network_name, e - ); - } - } - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => return Ok(()), - _ if Self::is_resource_not_ready_error(&e) => { - info!( - "Network {} is not ready for deletion yet (attempt {}), retrying...", - network_name, attempt - ); - } - _ => { - return Err( - format!("Failed to delete network {}: {:?}", network_name, e).into(), - ) - } - }, - } - - tokio::time::sleep(tokio::time::Duration::from_secs( - NETWORK_DELETE_RETRY_INTERVAL_SECONDS, - )) - .await; - } - } - - // --- Tracking methods --- - - fn track_network(&self, network_name: &str) { - let mut networks = self.created_networks.lock().unwrap(); - networks.insert(network_name.to_string()); - info!("📝 Tracking network for cleanup: {}", network_name); - } - - fn untrack_network(&self, network_name: &str) { - let mut networks = self.created_networks.lock().unwrap(); - networks.remove(network_name); - info!("✅ Network {} untracked", network_name); - } - - fn track_subnetwork(&self, region: &str, subnetwork_name: &str) { - let mut subnetworks = self.created_subnetworks.lock().unwrap(); - subnetworks.insert((region.to_string(), subnetwork_name.to_string())); - info!( - "📝 Tracking subnetwork for cleanup: {}/{}", - region, subnetwork_name - ); - } - - fn untrack_subnetwork(&self, region: &str, subnetwork_name: &str) { - let mut subnetworks = self.created_subnetworks.lock().unwrap(); - subnetworks.remove(&(region.to_string(), subnetwork_name.to_string())); - info!("✅ Subnetwork {}/{} untracked", region, subnetwork_name); - } - - fn track_router(&self, region: &str, router_name: &str) { - let mut routers = self.created_routers.lock().unwrap(); - routers.insert((region.to_string(), router_name.to_string())); - info!("📝 Tracking router for cleanup: {}/{}", region, router_name); - } - - fn untrack_router(&self, region: &str, router_name: &str) { - let mut routers = self.created_routers.lock().unwrap(); - routers.remove(&(region.to_string(), router_name.to_string())); - info!("✅ Router {}/{} untracked", region, router_name); - } - - fn track_firewall(&self, firewall_name: &str) { - let mut firewalls = self.created_firewalls.lock().unwrap(); - firewalls.insert(firewall_name.to_string()); - info!("📝 Tracking firewall for cleanup: {}", firewall_name); - } - - fn untrack_firewall(&self, firewall_name: &str) { - let mut firewalls = self.created_firewalls.lock().unwrap(); - firewalls.remove(firewall_name); - info!("✅ Firewall {} untracked", firewall_name); - } - - // --- Load Balancing Tracking Methods --- - - fn track_health_check(&self, name: &str) { - let mut hc = self.created_health_checks.lock().unwrap(); - hc.insert(name.to_string()); - info!("📝 Tracking health check for cleanup: {}", name); - } - - fn untrack_health_check(&self, name: &str) { - let mut hc = self.created_health_checks.lock().unwrap(); - hc.remove(name); - info!("✅ Health check {} untracked", name); - } - - fn track_backend_service(&self, name: &str) { - let mut bs = self.created_backend_services.lock().unwrap(); - bs.insert(name.to_string()); - info!("📝 Tracking backend service for cleanup: {}", name); - } - - fn untrack_backend_service(&self, name: &str) { - let mut bs = self.created_backend_services.lock().unwrap(); - bs.remove(name); - info!("✅ Backend service {} untracked", name); - } - - fn track_url_map(&self, name: &str) { - let mut um = self.created_url_maps.lock().unwrap(); - um.insert(name.to_string()); - info!("📝 Tracking URL map for cleanup: {}", name); - } - - fn untrack_url_map(&self, name: &str) { - let mut um = self.created_url_maps.lock().unwrap(); - um.remove(name); - info!("✅ URL map {} untracked", name); - } - - fn track_target_http_proxy(&self, name: &str) { - let mut proxies = self.created_target_http_proxies.lock().unwrap(); - proxies.insert(name.to_string()); - info!("📝 Tracking target HTTP proxy for cleanup: {}", name); - } - - fn untrack_target_http_proxy(&self, name: &str) { - let mut proxies = self.created_target_http_proxies.lock().unwrap(); - proxies.remove(name); - info!("✅ Target HTTP proxy {} untracked", name); - } - - fn track_global_address(&self, name: &str) { - let mut addrs = self.created_global_addresses.lock().unwrap(); - addrs.insert(name.to_string()); - info!("📝 Tracking global address for cleanup: {}", name); - } - - fn untrack_global_address(&self, name: &str) { - let mut addrs = self.created_global_addresses.lock().unwrap(); - addrs.remove(name); - info!("✅ Global address {} untracked", name); - } - - fn track_global_forwarding_rule(&self, name: &str) { - let mut fwds = self.created_global_forwarding_rules.lock().unwrap(); - fwds.insert(name.to_string()); - info!("📝 Tracking global forwarding rule for cleanup: {}", name); - } - - fn untrack_global_forwarding_rule(&self, name: &str) { - let mut fwds = self.created_global_forwarding_rules.lock().unwrap(); - fwds.remove(name); - info!("✅ Global forwarding rule {} untracked", name); - } - - fn track_neg(&self, zone: &str, name: &str) { - let mut negs = self.created_negs.lock().unwrap(); - negs.insert((zone.to_string(), name.to_string())); - info!("📝 Tracking NEG for cleanup: {}/{}", zone, name); - } - - fn untrack_neg(&self, zone: &str, name: &str) { - let mut negs = self.created_negs.lock().unwrap(); - negs.remove(&(zone.to_string(), name.to_string())); - info!("✅ NEG {}/{} untracked", zone, name); - } - - // --- Instance Management Tracking Methods --- - - fn track_instance_template(&self, name: &str) { - let mut templates = self.created_instance_templates.lock().unwrap(); - templates.insert(name.to_string()); - info!("📝 Tracking instance template for cleanup: {}", name); - } - - fn untrack_instance_template(&self, name: &str) { - let mut templates = self.created_instance_templates.lock().unwrap(); - templates.remove(name); - info!("✅ Instance template {} untracked", name); - } - - fn track_instance_group_manager(&self, zone: &str, name: &str) { - let mut igms = self.created_instance_group_managers.lock().unwrap(); - igms.insert((zone.to_string(), name.to_string())); - info!( - "📝 Tracking instance group manager for cleanup: {}/{}", - zone, name - ); - } - - fn untrack_instance_group_manager(&self, zone: &str, name: &str) { - let mut igms = self.created_instance_group_managers.lock().unwrap(); - igms.remove(&(zone.to_string(), name.to_string())); - info!("✅ Instance group manager {}/{} untracked", zone, name); - } - - // --- Disk Tracking Methods --- - - fn track_disk(&self, zone: &str, name: &str) { - let mut disks = self.created_disks.lock().unwrap(); - disks.insert((zone.to_string(), name.to_string())); - info!("📝 Tracking disk for cleanup: {}/{}", zone, name); - } - - fn untrack_disk(&self, zone: &str, name: &str) { - let mut disks = self.created_disks.lock().unwrap(); - disks.remove(&(zone.to_string(), name.to_string())); - info!("✅ Disk {}/{} untracked", zone, name); - } - - // --- Cleanup methods --- - - async fn cleanup_firewall(&self, firewall_name: &str) { - info!("🧹 Cleaning up firewall: {}", firewall_name); - match self.client.delete_firewall(firewall_name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Firewall {} deleted", firewall_name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Firewall {} was already deleted", firewall_name); - } - _ => warn!("Failed to delete firewall {}: {:?}", firewall_name, e), - }, - } - } - - async fn cleanup_router(&self, region: &str, router_name: &str) { - info!("🧹 Cleaning up router: {}/{}", region, router_name); - match self - .client - .delete_router(region.to_string(), router_name.to_string()) - .await - { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_region_operation(region, op_name, 120).await; - } - info!("✅ Router {}/{} deleted", region, router_name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Router {}/{} was already deleted", region, router_name); - } - _ => warn!( - "Failed to delete router {}/{}: {:?}", - region, router_name, e - ), - }, - } - } - - async fn cleanup_subnetwork(&self, region: &str, subnetwork_name: &str) { - info!("🧹 Cleaning up subnetwork: {}/{}", region, subnetwork_name); - match self - .client - .delete_subnetwork(region.to_string(), subnetwork_name.to_string()) - .await - { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_region_operation(region, op_name, 120).await; - } - info!("✅ Subnetwork {}/{} deleted", region, subnetwork_name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!( - "🔍 Subnetwork {}/{} was already deleted", - region, subnetwork_name - ); - } - _ => warn!( - "Failed to delete subnetwork {}/{}: {:?}", - region, subnetwork_name, e - ), - }, - } - } - - async fn cleanup_network(&self, network_name: &str) { - info!("🧹 Cleaning up network: {}", network_name); - match self - .delete_network_with_retry(network_name, NETWORK_DELETE_TIMEOUT_SECONDS) - .await - { - Ok(()) => { - info!("✅ Network {} deleted", network_name); - } - Err(e) => warn!("Failed to delete network {}: {:?}", network_name, e), - } - } - - // --- Load Balancing Cleanup Methods --- - - async fn cleanup_health_check(&self, name: &str) { - info!("🧹 Cleaning up health check: {}", name); - match self.client.delete_health_check(name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Health check {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Health check {} was already deleted", name); - } - _ => warn!("Failed to delete health check {}: {:?}", name, e), - }, - } - } - - async fn cleanup_backend_service(&self, name: &str) { - info!("🧹 Cleaning up backend service: {}", name); - match self.client.delete_backend_service(name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Backend service {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Backend service {} was already deleted", name); - } - _ => warn!("Failed to delete backend service {}: {:?}", name, e), - }, - } - } - - async fn cleanup_url_map(&self, name: &str) { - info!("🧹 Cleaning up URL map: {}", name); - match self.client.delete_url_map(name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ URL map {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 URL map {} was already deleted", name); - } - _ => warn!("Failed to delete URL map {}: {:?}", name, e), - }, - } - } - - async fn cleanup_target_http_proxy(&self, name: &str) { - info!("🧹 Cleaning up target HTTP proxy: {}", name); - match self.client.delete_target_http_proxy(name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Target HTTP proxy {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Target HTTP proxy {} was already deleted", name); - } - _ => warn!("Failed to delete target HTTP proxy {}: {:?}", name, e), - }, - } - } - - async fn cleanup_global_address(&self, name: &str) { - info!("🧹 Cleaning up global address: {}", name); - match self.client.delete_global_address(name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Global address {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Global address {} was already deleted", name); - } - _ => warn!("Failed to delete global address {}: {:?}", name, e), - }, - } - } - - async fn cleanup_global_forwarding_rule(&self, name: &str) { - info!("🧹 Cleaning up global forwarding rule: {}", name); - match self - .client - .delete_global_forwarding_rule(name.to_string()) - .await - { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Global forwarding rule {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Global forwarding rule {} was already deleted", name); - } - _ => warn!("Failed to delete global forwarding rule {}: {:?}", name, e), - }, - } - } - - async fn cleanup_neg(&self, zone: &str, name: &str) { - info!("🧹 Cleaning up NEG: {}/{}", zone, name); - match self - .client - .delete_network_endpoint_group(zone.to_string(), name.to_string()) - .await - { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_zone_operation(zone, op_name, 120).await; - } - info!("✅ NEG {}/{} deleted", zone, name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 NEG {}/{} was already deleted", zone, name); - } - _ => warn!("Failed to delete NEG {}/{}: {:?}", zone, name, e), - }, - } - } - - // --- Instance Management Cleanup Methods --- - - async fn cleanup_instance_template(&self, name: &str) { - info!("🧹 Cleaning up instance template: {}", name); - match self.client.delete_instance_template(name.to_string()).await { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_global_operation(op_name, 120).await; - } - info!("✅ Instance template {} deleted", name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Instance template {} was already deleted", name); - } - _ => warn!("Failed to delete instance template {}: {:?}", name, e), - }, - } - } - - async fn cleanup_instance_group_manager(&self, zone: &str, name: &str) { - info!("🧹 Cleaning up instance group manager: {}/{}", zone, name); - match self - .client - .delete_instance_group_manager(zone.to_string(), name.to_string()) - .await - { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_zone_operation(zone, op_name, 300).await; - } - info!("✅ Instance group manager {}/{} deleted", zone, name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!( - "🔍 Instance group manager {}/{} was already deleted", - zone, name - ); - } - _ => warn!( - "Failed to delete instance group manager {}/{}: {:?}", - zone, name, e - ), - }, - } - } - - // --- Disk Cleanup Methods --- - - async fn cleanup_disk(&self, zone: &str, name: &str) { - info!("🧹 Cleaning up disk: {}/{}", zone, name); - match self - .client - .delete_disk(zone.to_string(), name.to_string()) - .await - { - Ok(operation) => { - if let Some(op_name) = &operation.name { - let _ = self.wait_for_zone_operation(zone, op_name, 120).await; - } - info!("✅ Disk {}/{} deleted", zone, name); - } - Err(e) => match &e.error { - Some(ErrorData::RemoteResourceNotFound { .. }) => { - info!("🔍 Disk {}/{} was already deleted", zone, name); - } - _ => warn!("Failed to delete disk {}/{}: {:?}", zone, name, e), - }, - } - } - - // --- Helper methods --- - - fn generate_unique_name(&self, prefix: &str) -> String { - format!( - "alien-test-{}-{}", - prefix, - Uuid::new_v4().hyphenated().to_string().replace("-", "")[..8].to_lowercase() - ) - } - - fn extract_operation_name(&self, operation_name: &str) -> String { - operation_name - .split('/') - .last() - .unwrap_or(operation_name) - .to_string() - } - - async fn wait_for_global_operation( - &self, - operation_name: &str, - timeout_seconds: u64, - ) -> std::result::Result<(), Box> { - let op_name = self.extract_operation_name(operation_name); - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(timeout_seconds); - - loop { - match self.client.get_global_operation(op_name.clone()).await { - Ok(operation) => { - if operation.is_done() { - if operation.has_error() { - return Err(format!( - "Operation {} failed: {:?}", - op_name, operation.error - ) - .into()); - } - info!("✅ Global operation {} completed!", op_name); - return Ok(()); - } - - if start_time.elapsed() > timeout_duration { - return Err(format!( - "Timeout waiting for global operation {} to complete", - op_name - ) - .into()); - } - - info!("⏳ Global operation {} still running...", op_name); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - Err(e) => { - if start_time.elapsed() > timeout_duration { - return Err(format!( - "Timeout waiting for global operation {} to complete (last error: {:?})", - op_name, e - ) - .into()); - } - warn!("Error checking global operation status: {:?}", e); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - } - } - } - - async fn wait_for_region_operation( - &self, - region: &str, - operation_name: &str, - timeout_seconds: u64, - ) -> std::result::Result<(), Box> { - let op_name = self.extract_operation_name(operation_name); - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(timeout_seconds); - - loop { - if start_time.elapsed() > timeout_duration { - return Err(format!( - "Timeout waiting for region operation {} to complete", - op_name - ) - .into()); - } - - match self - .client - .get_region_operation(region.to_string(), op_name.clone()) - .await - { - Ok(operation) => { - if operation.is_done() { - if operation.has_error() { - return Err(format!( - "Operation {} failed: {:?}", - op_name, operation.error - ) - .into()); - } - info!("✅ Region operation {} completed!", op_name); - return Ok(()); - } - info!("⏳ Region operation {} still running...", op_name); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - Err(e) => { - warn!("Error checking region operation status: {:?}", e); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - } - } - } - - async fn wait_for_zone_operation( - &self, - zone: &str, - operation_name: &str, - timeout_seconds: u64, - ) -> std::result::Result<(), Box> { - let op_name = self.extract_operation_name(operation_name); - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(timeout_seconds); - - loop { - if start_time.elapsed() > timeout_duration { - return Err( - format!("Timeout waiting for zone operation {} to complete", op_name).into(), - ); - } - - match self - .client - .get_zone_operation(zone.to_string(), op_name.clone()) - .await - { - Ok(operation) => { - if operation.is_done() { - if operation.has_error() { - return Err(format!( - "Operation {} failed: {:?}", - op_name, operation.error - ) - .into()); - } - info!("✅ Zone operation {} completed!", op_name); - return Ok(()); - } - info!("⏳ Zone operation {} still running...", op_name); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - Err(e) => { - warn!("Error checking zone operation status: {:?}", e); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - } - } - } - - async fn wait_for_stable_managed_instance( - &self, - zone: &str, - igm_name: &str, - expected_template_name: &str, - timeout_seconds: u64, - ) -> std::result::Result> { - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(timeout_seconds); - - loop { - if start_time.elapsed() > timeout_duration { - return Err(format!( - "Timeout waiting for IGM {}/{} to reach a stable managed instance on template {}", - zone, igm_name, expected_template_name - ) - .into()); - } - - let igm = match self - .client - .get_instance_group_manager(zone.to_string(), igm_name.to_string()) - .await - { - Ok(igm) => igm, - Err(e) => { - warn!( - "Error checking instance group manager {}/{} status: {:?}", - zone, igm_name, e - ); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - continue; - } - }; - - let managed_instances = match self - .client - .list_managed_instances(zone.to_string(), igm_name.to_string()) - .await - { - Ok(instances) => instances, - Err(e) => { - warn!( - "Error listing managed instances for {}/{}: {:?}", - zone, igm_name, e - ); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - continue; - } - }; - - let is_stable = igm - .status - .as_ref() - .and_then(|status| status.is_stable) - .unwrap_or(false); - let version_reached = igm - .status - .as_ref() - .and_then(|status| status.version_target.as_ref()) - .and_then(|target| target.is_reached) - .unwrap_or(false); - - if is_stable && version_reached { - if let Some(instance) = - managed_instances.managed_instances.iter().find(|instance| { - matches!( - instance.instance_status, - Some(ManagedInstanceStatus::Running) - ) && matches!( - instance.current_action, - Some(ManagedInstanceCurrentAction::None) - ) && instance - .version - .as_ref() - .and_then(|version| version.instance_template.as_deref()) - .is_some_and(|template| template.contains(expected_template_name)) - }) - { - let instance_name = instance - .instance - .as_deref() - .and_then(|url| url.split('/').last()) - .unwrap_or("unknown"); - info!( - "✅ IGM {}/{} is stable with managed instance {} on template {}", - zone, igm_name, instance_name, expected_template_name - ); - return Ok(instance.clone()); - } - } - - let instance_summaries: Vec = managed_instances - .managed_instances - .iter() - .map(|instance| { - let instance_name = instance - .instance - .as_deref() - .and_then(|url| url.split('/').last()) - .unwrap_or("unknown"); - let template = instance - .version - .as_ref() - .and_then(|version| version.instance_template.as_deref()) - .unwrap_or("unknown-template"); - format!( - "{} status={:?} action={:?} template={}", - instance_name, instance.instance_status, instance.current_action, template - ) - }) - .collect(); - - info!( - "⏳ Waiting for IGM {}/{} to stabilize after rolling update (stable={}, version_reached={}, instances=[{}])", - zone, - igm_name, - is_stable, - version_reached, - instance_summaries.join("; ") - ); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - } - - fn create_invalid_client(&self) -> ComputeClient { - let invalid_config = GcpClientConfig { - project_id: "fake-project".to_string(), - region: self.region.clone(), - credentials: GcpCredentials::ServiceAccountKey { - json: r#"{"type":"service_account","project_id":"fake","private_key_id":"fake","private_key":"-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n","client_email":"fake@fake.iam.gserviceaccount.com","client_id":"fake","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token"}"#.to_string(), - }, - service_overrides: None, - project_number: None, - }; - ComputeClient::new(Client::new(), invalid_config) - } -} - -// ============================================================================================= -// Basic Framework Test -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_framework_setup_compute(ctx: &mut ComputeTestContext) { - assert!(!ctx.project_id.is_empty(), "Project ID should not be empty"); - assert!(!ctx.region.is_empty(), "Region should not be empty"); - - println!( - "Successfully connected to Compute Engine in project: {} region: {}", - ctx.project_id, ctx.region - ); -} - -// ============================================================================================= -// Comprehensive E2E Test - VPC with all components -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_comprehensive_vpc_lifecycle(ctx: &mut ComputeTestContext) { - println!("🚀 Starting comprehensive VPC lifecycle test"); - - // Generate unique names for all resources - let network_name = ctx.generate_unique_name("vpc"); - let subnetwork_name = ctx.generate_unique_name("subnet"); - let router_name = ctx.generate_unique_name("router"); - let firewall_name = ctx.generate_unique_name("fw"); - - // ========================================================================= - // Step 1: Create VPC Network - // ========================================================================= - println!("\n📦 Step 1: Creating VPC network: {}", network_name); - - let network = Network::builder() - .name(network_name.clone()) - .description("Alien test VPC network".to_string()) - .auto_create_subnetworks(false) // We'll create subnets manually - .routing_config( - NetworkRoutingConfig::builder() - .routing_mode(RoutingMode::Regional) - .build(), - ) - .mtu(1460) - .build(); - - let create_network_op = ctx - .client - .insert_network(network) - .await - .expect("Failed to create network"); - - ctx.track_network(&network_name); - assert!( - create_network_op.name.is_some(), - "Create network operation should have a name" - ); - println!("✅ Network creation initiated"); - - // Wait for network creation to complete - ctx.wait_for_global_operation(create_network_op.name.as_ref().unwrap(), 120) - .await - .expect("Network creation operation timed out"); - - // Verify network was created - let fetched_network = ctx - .client - .get_network(network_name.clone()) - .await - .expect("Failed to get network"); - - assert_eq!( - fetched_network.name.as_ref().unwrap(), - &network_name, - "Network name should match" - ); - assert_eq!( - fetched_network.auto_create_subnetworks, - Some(false), - "autoCreateSubnetworks should be false" - ); - println!("✅ Network verified: {}", network_name); - - // ========================================================================= - // Step 2: Create Subnetwork - // ========================================================================= - println!("\n📦 Step 2: Creating subnetwork: {}", subnetwork_name); - - let network_url = format!( - "projects/{}/global/networks/{}", - ctx.project_id, network_name - ); - - let subnetwork = Subnetwork::builder() - .name(subnetwork_name.clone()) - .description("Alien test subnetwork".to_string()) - .network(network_url.clone()) - .ip_cidr_range("10.128.0.0/20".to_string()) - .private_ip_google_access(true) - .build(); - - let create_subnet_op = ctx - .client - .insert_subnetwork(ctx.region.clone(), subnetwork) - .await - .expect("Failed to create subnetwork"); - - ctx.track_subnetwork(&ctx.region, &subnetwork_name); - assert!( - create_subnet_op.name.is_some(), - "Create subnetwork operation should have a name" - ); - println!("✅ Subnetwork creation initiated"); - - // Wait for subnetwork creation to complete - ctx.wait_for_region_operation(&ctx.region, create_subnet_op.name.as_ref().unwrap(), 120) - .await - .expect("Subnetwork creation operation timed out"); - - // Verify subnetwork was created - let fetched_subnetwork = ctx - .client - .get_subnetwork(ctx.region.clone(), subnetwork_name.clone()) - .await - .expect("Failed to get subnetwork"); - - assert_eq!( - fetched_subnetwork.name.as_ref().unwrap(), - &subnetwork_name, - "Subnetwork name should match" - ); - assert_eq!( - fetched_subnetwork.private_ip_google_access, - Some(true), - "Private Google Access should be enabled" - ); - println!("✅ Subnetwork verified: {}", subnetwork_name); - - // ========================================================================= - // Step 3: Create Router with Cloud NAT - // ========================================================================= - println!( - "\n📦 Step 3: Creating router with Cloud NAT: {}", - router_name - ); - - let router = Router::builder() - .name(router_name.clone()) - .description("Alien test router with NAT".to_string()) - .network(network_url.clone()) - .nats(vec![RouterNat::builder() - .name("alien-test-nat".to_string()) - .source_subnetwork_ip_ranges_to_nat( - SourceSubnetworkIpRangesToNat::AllSubnetworksAllIpRanges, - ) - .nat_ip_allocate_option(NatIpAllocateOption::AutoOnly) - .enable_endpoint_independent_mapping(false) - .enable_dynamic_port_allocation(true) - .min_ports_per_vm(64) - .build()]) - .build(); - - let create_router_op = ctx - .client - .insert_router(ctx.region.clone(), router) - .await - .expect("Failed to create router"); - - ctx.track_router(&ctx.region, &router_name); - assert!( - create_router_op.name.is_some(), - "Create router operation should have a name" - ); - println!("✅ Router creation initiated"); - - // Wait for router creation to complete - ctx.wait_for_region_operation(&ctx.region, create_router_op.name.as_ref().unwrap(), 120) - .await - .expect("Router creation operation timed out"); - - // Verify router was created - let fetched_router = ctx - .client - .get_router(ctx.region.clone(), router_name.clone()) - .await - .expect("Failed to get router"); - - assert_eq!( - fetched_router.name.as_ref().unwrap(), - &router_name, - "Router name should match" - ); - assert!( - !fetched_router.nats.is_empty(), - "Router should have NAT configuration" - ); - println!("✅ Router verified: {}", router_name); - - // Test list_routers - println!("\n📋 Testing list_routers..."); - let router_list = ctx - .client - .list_routers(ctx.region.clone()) - .await - .expect("Failed to list routers"); - - let found_router = router_list - .items - .iter() - .find(|r| r.name.as_ref() == Some(&router_name)); - assert!(found_router.is_some(), "Router should be in list"); - println!("✅ Router found in list"); - - // ========================================================================= - // Step 4: Create Firewall Rule - // ========================================================================= - println!("\n📦 Step 4: Creating firewall rule: {}", firewall_name); - - let firewall = Firewall::builder() - .name(firewall_name.clone()) - .description("Alien test firewall rule".to_string()) - .network(network_url.clone()) - .direction(FirewallDirection::Ingress) - .priority(1000) - .allowed(vec![ - FirewallAllowed::builder() - .ip_protocol("tcp".to_string()) - .ports(vec!["22".to_string(), "80".to_string(), "443".to_string()]) - .build(), - FirewallAllowed::builder() - .ip_protocol("icmp".to_string()) - .build(), - ]) - .source_ranges(vec!["0.0.0.0/0".to_string()]) - .build(); - - let create_firewall_op = ctx - .client - .insert_firewall(firewall) - .await - .expect("Failed to create firewall"); - - ctx.track_firewall(&firewall_name); - assert!( - create_firewall_op.name.is_some(), - "Create firewall operation should have a name" - ); - println!("✅ Firewall creation initiated"); - - // Wait for firewall creation to complete - ctx.wait_for_global_operation(create_firewall_op.name.as_ref().unwrap(), 120) - .await - .expect("Firewall creation operation timed out"); - - // Verify firewall was created - let fetched_firewall = ctx - .client - .get_firewall(firewall_name.clone()) - .await - .expect("Failed to get firewall"); - - assert_eq!( - fetched_firewall.name.as_ref().unwrap(), - &firewall_name, - "Firewall name should match" - ); - assert_eq!( - fetched_firewall.direction, - Some(FirewallDirection::Ingress), - "Firewall direction should be INGRESS" - ); - println!("✅ Firewall verified: {}", firewall_name); - - // Test list_firewalls - println!("\n📋 Testing list_firewalls..."); - let firewall_list = ctx - .client - .list_firewalls() - .await - .expect("Failed to list firewalls"); - - let found_firewall = firewall_list - .items - .iter() - .find(|f| f.name.as_ref() == Some(&firewall_name)); - assert!(found_firewall.is_some(), "Firewall should be in list"); - println!("✅ Firewall found in list"); - - // ========================================================================= - // Step 5: Patch Router (update NAT config) - // ========================================================================= - println!("\n📦 Step 5: Patching router NAT configuration"); - - let updated_router = Router::builder() - .nats(vec![RouterNat::builder() - .name("alien-test-nat".to_string()) - .source_subnetwork_ip_ranges_to_nat( - SourceSubnetworkIpRangesToNat::AllSubnetworksAllIpRanges, - ) - .nat_ip_allocate_option(NatIpAllocateOption::AutoOnly) - .enable_endpoint_independent_mapping(false) - .enable_dynamic_port_allocation(true) - .min_ports_per_vm(128) // Changed from 64 to 128 - .max_ports_per_vm(65536) - .build()]) - .build(); - - let patch_router_op = ctx - .client - .patch_router(ctx.region.clone(), router_name.clone(), updated_router) - .await - .expect("Failed to patch router"); - - assert!( - patch_router_op.name.is_some(), - "Patch router operation should have a name" - ); - println!("✅ Router patch initiated"); - - // Wait for patch to complete - ctx.wait_for_region_operation(&ctx.region, patch_router_op.name.as_ref().unwrap(), 120) - .await - .expect("Router patch operation timed out"); - - // Verify router was updated - let updated_router_check = ctx - .client - .get_router(ctx.region.clone(), router_name.clone()) - .await - .expect("Failed to get updated router"); - - let nat_config = &updated_router_check.nats[0]; - assert_eq!( - nat_config.min_ports_per_vm, - Some(128), - "NAT min_ports_per_vm should be updated to 128" - ); - println!("✅ Router NAT configuration updated successfully"); - - // ========================================================================= - // Step 6: Clean up in reverse order - // ========================================================================= - println!("\n🧹 Step 6: Cleaning up resources"); - - // Delete firewall - println!(" Deleting firewall..."); - let delete_firewall_op = ctx - .client - .delete_firewall(firewall_name.clone()) - .await - .expect("Failed to delete firewall"); - - ctx.wait_for_global_operation(delete_firewall_op.name.as_ref().unwrap(), 120) - .await - .expect("Firewall deletion operation timed out"); - ctx.untrack_firewall(&firewall_name); - println!(" ✅ Firewall deleted"); - - // Delete router - println!(" Deleting router..."); - let delete_router_op = ctx - .client - .delete_router(ctx.region.clone(), router_name.clone()) - .await - .expect("Failed to delete router"); - - ctx.wait_for_region_operation(&ctx.region, delete_router_op.name.as_ref().unwrap(), 120) - .await - .expect("Router deletion operation timed out"); - ctx.untrack_router(&ctx.region, &router_name); - println!(" ✅ Router deleted"); - - // Delete subnetwork - println!(" Deleting subnetwork..."); - let delete_subnet_op = ctx - .client - .delete_subnetwork(ctx.region.clone(), subnetwork_name.clone()) - .await - .expect("Failed to delete subnetwork"); - - ctx.wait_for_region_operation(&ctx.region, delete_subnet_op.name.as_ref().unwrap(), 120) - .await - .expect("Subnetwork deletion operation timed out"); - ctx.untrack_subnetwork(&ctx.region, &subnetwork_name); - println!(" ✅ Subnetwork deleted"); - - // Delete network - println!(" Deleting network..."); - ctx.delete_network_with_retry(&network_name, NETWORK_DELETE_TIMEOUT_SECONDS) - .await - .expect("Failed to delete network"); - ctx.untrack_network(&network_name); - println!(" ✅ Network deleted"); - - println!("\n🎉 Comprehensive VPC lifecycle test completed successfully!"); - println!(" - VPC network created and deleted: ✅"); - println!(" - Subnetwork created and deleted: ✅"); - println!(" - Router with NAT created, patched, and deleted: ✅"); - println!(" - Firewall rule created and deleted: ✅"); - println!(" - List operations verified: ✅"); -} - -// ============================================================================================= -// Error Handling Tests -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_network_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-network-does-not-exist-12345"; - - let result = ctx.client.get_network(non_existent.to_string()).await; - assert!(result.is_err(), "Expected error for non-existent network"); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for network"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_subnetwork_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-subnet-does-not-exist-12345"; - - let result = ctx - .client - .get_subnetwork(ctx.region.clone(), non_existent.to_string()) - .await; - assert!( - result.is_err(), - "Expected error for non-existent subnetwork" - ); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for subnetwork"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_router_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-router-does-not-exist-12345"; - - let result = ctx - .client - .get_router(ctx.region.clone(), non_existent.to_string()) - .await; - assert!(result.is_err(), "Expected error for non-existent router"); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for router"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_firewall_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-firewall-does-not-exist-12345"; - - let result = ctx.client.get_firewall(non_existent.to_string()).await; - assert!(result.is_err(), "Expected error for non-existent firewall"); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for firewall"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_access_denied(ctx: &mut ComputeTestContext) { - let invalid_client = ctx.create_invalid_client(); - - let result = invalid_client.get_network("any-network".to_string()).await; - assert!(result.is_err(), "Expected error with invalid credentials"); - - let err = result.unwrap_err(); - match &err.error { - Some(ErrorData::RemoteAccessDenied { .. }) - | Some(ErrorData::HttpRequestFailed { .. }) - | Some(ErrorData::InvalidInput { .. }) => { - println!("✅ Got expected error type for invalid credentials"); - } - _ => println!("Got error (acceptable for invalid creds): {:?}", err), - } -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_delete_non_existent_network(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-network-does-not-exist-67890"; - - let result = ctx.client.delete_network(non_existent.to_string()).await; - assert!( - result.is_err(), - "Expected error when deleting non-existent network" - ); - - let err = result.unwrap_err(); - match err { - Error { - error: - Some(ErrorData::RemoteResourceNotFound { - ref resource_type, - ref resource_name, - }), - .. - } => { - assert_eq!(resource_type, "Compute Engine"); - assert_eq!(resource_name, non_existent); - println!("✅ Correctly mapped 404 to RemoteResourceNotFound for network deletion"); - } - _ => panic!( - "Expected RemoteResourceNotFound error for non-existent network deletion, got: {:?}", - err - ), - } -} - -// ============================================================================================= -// Operation Status Tests -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_wait_global_operation(ctx: &mut ComputeTestContext) { - // Create a simple network to get an operation - let network_name = ctx.generate_unique_name("op-test"); - - let network = Network::builder() - .name(network_name.clone()) - .description("Operation test network".to_string()) - .auto_create_subnetworks(false) - .build(); - - let create_op = ctx - .client - .insert_network(network) - .await - .expect("Failed to create network for operation test"); - - ctx.track_network(&network_name); - let op_name = create_op.name.as_ref().unwrap(); - - // Test wait_global_operation - println!("Testing wait_global_operation..."); - let wait_result = ctx - .client - .wait_global_operation(ctx.extract_operation_name(op_name)) - .await - .expect("Failed to wait for global operation"); - - assert!( - wait_result.is_done(), - "Operation should be done after waiting" - ); - println!("✅ wait_global_operation completed successfully"); - - // Clean up - ctx.delete_network_with_retry(&network_name, NETWORK_DELETE_TIMEOUT_SECONDS) - .await - .expect("Failed to delete network"); - ctx.untrack_network(&network_name); -} - -// ============================================================================================= -// Comprehensive E2E Test - Load Balancing -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_comprehensive_load_balancing_lifecycle(ctx: &mut ComputeTestContext) { - println!("🚀 Starting comprehensive Load Balancing lifecycle test"); - - // Generate unique names for all resources - let network_name = ctx.generate_unique_name("lb-vpc"); - let subnetwork_name = ctx.generate_unique_name("lb-subnet"); - let health_check_name = ctx.generate_unique_name("hc"); - let neg_name = ctx.generate_unique_name("neg"); - let backend_service_name = ctx.generate_unique_name("bs"); - let url_map_name = ctx.generate_unique_name("urlmap"); - let proxy_name = ctx.generate_unique_name("proxy"); - let address_name = ctx.generate_unique_name("addr"); - let forwarding_rule_name = ctx.generate_unique_name("fwd"); - - // ========================================================================= - // Step 1: Create VPC Network and Subnetwork (prerequisites) - // ========================================================================= - println!("\n📦 Step 1: Creating VPC network and subnetwork for load balancing"); - - let network = Network::builder() - .name(network_name.clone()) - .description("Alien test VPC for load balancing".to_string()) - .auto_create_subnetworks(false) - .build(); - - let create_network_op = ctx - .client - .insert_network(network) - .await - .expect("Failed to create network"); - - ctx.track_network(&network_name); - ctx.wait_for_global_operation(create_network_op.name.as_ref().unwrap(), 120) - .await - .expect("Network creation timed out"); - println!("✅ Network created: {}", network_name); - - let network_url = format!( - "projects/{}/global/networks/{}", - ctx.project_id, network_name - ); - - let subnetwork = Subnetwork::builder() - .name(subnetwork_name.clone()) - .network(network_url.clone()) - .ip_cidr_range("10.0.0.0/24".to_string()) - .build(); - - let create_subnet_op = ctx - .client - .insert_subnetwork(ctx.region.clone(), subnetwork) - .await - .expect("Failed to create subnetwork"); - - ctx.track_subnetwork(&ctx.region, &subnetwork_name); - ctx.wait_for_region_operation(&ctx.region, create_subnet_op.name.as_ref().unwrap(), 120) - .await - .expect("Subnetwork creation timed out"); - println!("✅ Subnetwork created: {}", subnetwork_name); - - // ========================================================================= - // Step 2: Create Health Check - // ========================================================================= - println!("\n📦 Step 2: Creating health check: {}", health_check_name); - - let health_check = HealthCheck::builder() - .name(health_check_name.clone()) - .description("Alien test health check".to_string()) - .r#type(HealthCheckType::Http) - .check_interval_sec(10) - .timeout_sec(5) - .healthy_threshold(2) - .unhealthy_threshold(3) - .http_health_check( - HttpHealthCheck::builder() - .port(80) - .request_path("/health".to_string()) - .build(), - ) - .build(); - - let create_hc_op = ctx - .client - .insert_health_check(health_check) - .await - .expect("Failed to create health check"); - - ctx.track_health_check(&health_check_name); - assert!( - create_hc_op.name.is_some(), - "Create health check operation should have a name" - ); - println!("✅ Health check creation initiated"); - - ctx.wait_for_global_operation(create_hc_op.name.as_ref().unwrap(), 120) - .await - .expect("Health check creation timed out"); - - // Verify health check was created - let fetched_hc = ctx - .client - .get_health_check(health_check_name.clone()) - .await - .expect("Failed to get health check"); - - assert_eq!(fetched_hc.name.as_ref().unwrap(), &health_check_name); - assert_eq!(fetched_hc.r#type, Some(HealthCheckType::Http)); - println!("✅ Health check verified: {}", health_check_name); - - // ========================================================================= - // Step 3: Create Network Endpoint Group (NEG) - // ========================================================================= - println!("\n📦 Step 3: Creating NEG: {}", neg_name); - - let subnetwork_url = format!( - "projects/{}/regions/{}/subnetworks/{}", - ctx.project_id, ctx.region, subnetwork_name - ); - - let neg = NetworkEndpointGroup::builder() - .name(neg_name.clone()) - .description("Alien test NEG".to_string()) - .network_endpoint_type(NetworkEndpointType::GceVmIpPort) - .network(network_url.clone()) - .subnetwork(subnetwork_url) - .default_port(80) - .build(); - - let create_neg_op = ctx - .client - .insert_network_endpoint_group(ctx.zone.clone(), neg) - .await - .expect("Failed to create NEG"); - - ctx.track_neg(&ctx.zone, &neg_name); - assert!( - create_neg_op.name.is_some(), - "Create NEG operation should have a name" - ); - println!("✅ NEG creation initiated"); - - ctx.wait_for_zone_operation(&ctx.zone, create_neg_op.name.as_ref().unwrap(), 120) - .await - .expect("NEG creation timed out"); - - // Verify NEG was created - let fetched_neg = ctx - .client - .get_network_endpoint_group(ctx.zone.clone(), neg_name.clone()) - .await - .expect("Failed to get NEG"); - - assert_eq!(fetched_neg.name.as_ref().unwrap(), &neg_name); - assert_eq!( - fetched_neg.network_endpoint_type, - Some(NetworkEndpointType::GceVmIpPort) - ); - println!("✅ NEG verified: {}", neg_name); - - // ========================================================================= - // Step 4: Create Backend Service - // ========================================================================= - println!( - "\n📦 Step 4: Creating backend service: {}", - backend_service_name - ); - - let health_check_url = format!( - "projects/{}/global/healthChecks/{}", - ctx.project_id, health_check_name - ); - - let neg_url = format!( - "projects/{}/zones/{}/networkEndpointGroups/{}", - ctx.project_id, ctx.zone, neg_name - ); - - let backend_service = BackendService::builder() - .name(backend_service_name.clone()) - .description("Alien test backend service".to_string()) - .protocol(BackendServiceProtocol::Http) - .port_name("http".to_string()) - .timeout_sec(30) - .health_checks(vec![health_check_url]) - .load_balancing_scheme(LoadBalancingScheme::External) - .backends(vec![Backend::builder() - .group(neg_url) - .balancing_mode(BalancingMode::Rate) - .max_rate_per_endpoint(100.0) - .build()]) - .build(); - - let create_bs_op = ctx - .client - .insert_backend_service(backend_service) - .await - .expect("Failed to create backend service"); - - ctx.track_backend_service(&backend_service_name); - assert!( - create_bs_op.name.is_some(), - "Create backend service operation should have a name" - ); - println!("✅ Backend service creation initiated"); - - ctx.wait_for_global_operation(create_bs_op.name.as_ref().unwrap(), 120) - .await - .expect("Backend service creation timed out"); - - // Verify backend service was created - let fetched_bs = ctx - .client - .get_backend_service(backend_service_name.clone()) - .await - .expect("Failed to get backend service"); - - assert_eq!(fetched_bs.name.as_ref().unwrap(), &backend_service_name); - assert_eq!(fetched_bs.protocol, Some(BackendServiceProtocol::Http)); - println!("✅ Backend service verified: {}", backend_service_name); - - // ========================================================================= - // Step 5: Create URL Map - // ========================================================================= - println!("\n📦 Step 5: Creating URL map: {}", url_map_name); - - let backend_service_url = format!( - "projects/{}/global/backendServices/{}", - ctx.project_id, backend_service_name - ); - - let url_map = UrlMap::builder() - .name(url_map_name.clone()) - .description("Alien test URL map".to_string()) - .default_service(backend_service_url) - .build(); - - let create_um_op = ctx - .client - .insert_url_map(url_map) - .await - .expect("Failed to create URL map"); - - ctx.track_url_map(&url_map_name); - assert!( - create_um_op.name.is_some(), - "Create URL map operation should have a name" - ); - println!("✅ URL map creation initiated"); - - ctx.wait_for_global_operation(create_um_op.name.as_ref().unwrap(), 120) - .await - .expect("URL map creation timed out"); - - // Verify URL map was created - let fetched_um = ctx - .client - .get_url_map(url_map_name.clone()) - .await - .expect("Failed to get URL map"); - - assert_eq!(fetched_um.name.as_ref().unwrap(), &url_map_name); - println!("✅ URL map verified: {}", url_map_name); - - // ========================================================================= - // Step 6: Create Target HTTP Proxy - // ========================================================================= - println!("\n📦 Step 6: Creating target HTTP proxy: {}", proxy_name); - - let url_map_url = format!( - "projects/{}/global/urlMaps/{}", - ctx.project_id, url_map_name - ); - - let target_http_proxy = TargetHttpProxy::builder() - .name(proxy_name.clone()) - .description("Alien test target HTTP proxy".to_string()) - .url_map(url_map_url) - .build(); - - let create_proxy_op = ctx - .client - .insert_target_http_proxy(target_http_proxy) - .await - .expect("Failed to create target HTTP proxy"); - - ctx.track_target_http_proxy(&proxy_name); - assert!( - create_proxy_op.name.is_some(), - "Create proxy operation should have a name" - ); - println!("✅ Target HTTP proxy creation initiated"); - - ctx.wait_for_global_operation(create_proxy_op.name.as_ref().unwrap(), 120) - .await - .expect("Target HTTP proxy creation timed out"); - - // Verify proxy was created - let fetched_proxy = ctx - .client - .get_target_http_proxy(proxy_name.clone()) - .await - .expect("Failed to get target HTTP proxy"); - - assert_eq!(fetched_proxy.name.as_ref().unwrap(), &proxy_name); - println!("✅ Target HTTP proxy verified: {}", proxy_name); - - // ========================================================================= - // Step 7: Create Global Address - // ========================================================================= - println!("\n📦 Step 7: Creating global address: {}", address_name); - - let address = Address::builder() - .name(address_name.clone()) - .description("Alien test global address".to_string()) - .build(); - - let create_addr_op = ctx - .client - .insert_global_address(address) - .await - .expect("Failed to create global address"); - - ctx.track_global_address(&address_name); - assert!( - create_addr_op.name.is_some(), - "Create address operation should have a name" - ); - println!("✅ Global address creation initiated"); - - ctx.wait_for_global_operation(create_addr_op.name.as_ref().unwrap(), 120) - .await - .expect("Global address creation timed out"); - - // Verify address was created - let fetched_addr = ctx - .client - .get_global_address(address_name.clone()) - .await - .expect("Failed to get global address"); - - assert_eq!(fetched_addr.name.as_ref().unwrap(), &address_name); - assert!(fetched_addr.address.is_some(), "Address should have an IP"); - println!( - "✅ Global address verified: {} (IP: {})", - address_name, - fetched_addr.address.as_ref().unwrap() - ); - - // ========================================================================= - // Step 8: Create Global Forwarding Rule - // ========================================================================= - println!( - "\n📦 Step 8: Creating global forwarding rule: {}", - forwarding_rule_name - ); - - let proxy_url = format!( - "projects/{}/global/targetHttpProxies/{}", - ctx.project_id, proxy_name - ); - - let forwarding_rule = ForwardingRule::builder() - .name(forwarding_rule_name.clone()) - .description("Alien test forwarding rule".to_string()) - .ip_address(fetched_addr.address.clone().unwrap()) - .ip_protocol(ForwardingRuleProtocol::Tcp) - .port_range("80-80".to_string()) - .target(proxy_url) - .load_balancing_scheme(LoadBalancingScheme::External) - .build(); - - let create_fwd_op = ctx - .client - .insert_global_forwarding_rule(forwarding_rule) - .await - .expect("Failed to create forwarding rule"); - - ctx.track_global_forwarding_rule(&forwarding_rule_name); - assert!( - create_fwd_op.name.is_some(), - "Create forwarding rule operation should have a name" - ); - println!("✅ Global forwarding rule creation initiated"); - - ctx.wait_for_global_operation(create_fwd_op.name.as_ref().unwrap(), 120) - .await - .expect("Global forwarding rule creation timed out"); - - // Verify forwarding rule was created - let fetched_fwd = ctx - .client - .get_global_forwarding_rule(forwarding_rule_name.clone()) - .await - .expect("Failed to get forwarding rule"); - - assert_eq!(fetched_fwd.name.as_ref().unwrap(), &forwarding_rule_name); - println!( - "✅ Global forwarding rule verified: {}", - forwarding_rule_name - ); - - // ========================================================================= - // Step 9: Test patch backend service - // ========================================================================= - println!("\n📦 Step 9: Patching backend service"); - - let patched_bs = BackendService::builder() - .timeout_sec(60) // Changed from 30 to 60 - .build(); - - let patch_bs_op = ctx - .client - .patch_backend_service(backend_service_name.clone(), patched_bs) - .await - .expect("Failed to patch backend service"); - - ctx.wait_for_global_operation(patch_bs_op.name.as_ref().unwrap(), 120) - .await - .expect("Backend service patch timed out"); - - let updated_bs = ctx - .client - .get_backend_service(backend_service_name.clone()) - .await - .expect("Failed to get updated backend service"); - - assert_eq!(updated_bs.timeout_sec, Some(60)); - println!("✅ Backend service patched successfully"); - - // ========================================================================= - // Step 10: Cleanup in reverse dependency order - // ========================================================================= - println!("\n🧹 Step 10: Cleaning up load balancing resources"); - - // Delete forwarding rule - let delete_fwd_op = ctx - .client - .delete_global_forwarding_rule(forwarding_rule_name.clone()) - .await - .expect("Failed to delete forwarding rule"); - ctx.wait_for_global_operation(delete_fwd_op.name.as_ref().unwrap(), 120) - .await - .expect("Forwarding rule deletion timed out"); - ctx.untrack_global_forwarding_rule(&forwarding_rule_name); - println!(" ✅ Forwarding rule deleted"); - - // Delete target HTTP proxy - let delete_proxy_op = ctx - .client - .delete_target_http_proxy(proxy_name.clone()) - .await - .expect("Failed to delete proxy"); - ctx.wait_for_global_operation(delete_proxy_op.name.as_ref().unwrap(), 120) - .await - .expect("Proxy deletion timed out"); - ctx.untrack_target_http_proxy(&proxy_name); - println!(" ✅ Target HTTP proxy deleted"); - - // Delete URL map - let delete_um_op = ctx - .client - .delete_url_map(url_map_name.clone()) - .await - .expect("Failed to delete URL map"); - ctx.wait_for_global_operation(delete_um_op.name.as_ref().unwrap(), 120) - .await - .expect("URL map deletion timed out"); - ctx.untrack_url_map(&url_map_name); - println!(" ✅ URL map deleted"); - - // Delete backend service - let delete_bs_op = ctx - .client - .delete_backend_service(backend_service_name.clone()) - .await - .expect("Failed to delete backend service"); - ctx.wait_for_global_operation(delete_bs_op.name.as_ref().unwrap(), 120) - .await - .expect("Backend service deletion timed out"); - ctx.untrack_backend_service(&backend_service_name); - println!(" ✅ Backend service deleted"); - - // Delete NEG - let delete_neg_op = ctx - .client - .delete_network_endpoint_group(ctx.zone.clone(), neg_name.clone()) - .await - .expect("Failed to delete NEG"); - ctx.wait_for_zone_operation(&ctx.zone, delete_neg_op.name.as_ref().unwrap(), 120) - .await - .expect("NEG deletion timed out"); - ctx.untrack_neg(&ctx.zone, &neg_name); - println!(" ✅ NEG deleted"); - - // Delete health check - let delete_hc_op = ctx - .client - .delete_health_check(health_check_name.clone()) - .await - .expect("Failed to delete health check"); - ctx.wait_for_global_operation(delete_hc_op.name.as_ref().unwrap(), 120) - .await - .expect("Health check deletion timed out"); - ctx.untrack_health_check(&health_check_name); - println!(" ✅ Health check deleted"); - - // Delete global address - let delete_addr_op = ctx - .client - .delete_global_address(address_name.clone()) - .await - .expect("Failed to delete address"); - ctx.wait_for_global_operation(delete_addr_op.name.as_ref().unwrap(), 120) - .await - .expect("Address deletion timed out"); - ctx.untrack_global_address(&address_name); - println!(" ✅ Global address deleted"); - - // Delete subnetwork - let delete_subnet_op = ctx - .client - .delete_subnetwork(ctx.region.clone(), subnetwork_name.clone()) - .await - .expect("Failed to delete subnetwork"); - ctx.wait_for_region_operation(&ctx.region, delete_subnet_op.name.as_ref().unwrap(), 120) - .await - .expect("Subnetwork deletion timed out"); - ctx.untrack_subnetwork(&ctx.region, &subnetwork_name); - println!(" ✅ Subnetwork deleted"); - - // Delete network - ctx.delete_network_with_retry(&network_name, NETWORK_DELETE_TIMEOUT_SECONDS) - .await - .expect("Failed to delete network"); - ctx.untrack_network(&network_name); - println!(" ✅ Network deleted"); - - println!("\n🎉 Comprehensive Load Balancing lifecycle test completed successfully!"); - println!(" - Health check created and deleted: ✅"); - println!(" - Network Endpoint Group created and deleted: ✅"); - println!(" - Backend service created, patched, and deleted: ✅"); - println!(" - URL map created and deleted: ✅"); - println!(" - Target HTTP proxy created and deleted: ✅"); - println!(" - Global address created and deleted: ✅"); - println!(" - Global forwarding rule created and deleted: ✅"); -} - -// ============================================================================================= -// Comprehensive E2E Test - Persistent Disk -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_comprehensive_disk_lifecycle(ctx: &mut ComputeTestContext) { - println!("🚀 Starting comprehensive Persistent Disk lifecycle test"); - - let disk_name = ctx.generate_unique_name("disk"); - - // ========================================================================= - // Step 1: Create Disk - // ========================================================================= - println!("\n📦 Step 1: Creating disk: {}", disk_name); - - let disk = Disk::builder() - .name(disk_name.clone()) - .description("Alien test persistent disk".to_string()) - .size_gb("10".to_string()) - .r#type(format!( - "projects/{}/zones/{}/diskTypes/pd-standard", - ctx.project_id, ctx.zone - )) - .build(); - - let create_disk_op = ctx - .client - .insert_disk(ctx.zone.clone(), disk) - .await - .expect("Failed to create disk"); - - ctx.track_disk(&ctx.zone, &disk_name); - assert!( - create_disk_op.name.is_some(), - "Create disk operation should have a name" - ); - println!("✅ Disk creation initiated"); - - ctx.wait_for_zone_operation(&ctx.zone, create_disk_op.name.as_ref().unwrap(), 120) - .await - .expect("Disk creation timed out"); - - // Verify disk was created - let fetched_disk = ctx - .client - .get_disk(ctx.zone.clone(), disk_name.clone()) - .await - .expect("Failed to get disk"); - - assert_eq!(fetched_disk.name.as_ref().unwrap(), &disk_name); - assert_eq!(fetched_disk.size_gb, Some("10".to_string())); - println!("✅ Disk verified: {}", disk_name); - - // ========================================================================= - // Step 2: Delete Disk - // ========================================================================= - println!("\n🧹 Step 2: Deleting disk"); - - let delete_disk_op = ctx - .client - .delete_disk(ctx.zone.clone(), disk_name.clone()) - .await - .expect("Failed to delete disk"); - - ctx.wait_for_zone_operation(&ctx.zone, delete_disk_op.name.as_ref().unwrap(), 120) - .await - .expect("Disk deletion timed out"); - ctx.untrack_disk(&ctx.zone, &disk_name); - println!("✅ Disk deleted"); - - // Verify disk was deleted (should return 404) - let result = ctx - .client - .get_disk(ctx.zone.clone(), disk_name.clone()) - .await; - assert!(result.is_err(), "Disk should be deleted"); - - println!("\n🎉 Comprehensive Persistent Disk lifecycle test completed successfully!"); - println!(" - Disk created and deleted: ✅"); -} - -// ============================================================================================= -// Comprehensive E2E Test - Instance Management -// ============================================================================================= - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_comprehensive_instance_management_lifecycle(ctx: &mut ComputeTestContext) { - println!("🚀 Starting comprehensive Instance Management lifecycle test"); - - let template_name = ctx.generate_unique_name("template"); - let igm_name = ctx.generate_unique_name("igm"); - - // ========================================================================= - // Step 1: Create Instance Template - // ========================================================================= - println!("\n📦 Step 1: Creating instance template: {}", template_name); - - let instance_template = InstanceTemplate::builder() - .name(template_name.clone()) - .description("Alien test instance template".to_string()) - .properties( - InstanceProperties::builder() - .machine_type("e2-micro".to_string()) - .disks(vec![AttachedDisk::builder() - .r#type(AttachedDiskType::Persistent) - .boot(true) - .mode(DiskMode::ReadWrite) - .auto_delete(true) - .initialize_params( - AttachedDiskInitializeParams::builder() - .source_image( - "projects/debian-cloud/global/images/family/debian-11".to_string(), - ) - .disk_size_gb("10".to_string()) - .build(), - ) - .build()]) - .network_interfaces(vec![NetworkInterface::builder() - .network("global/networks/default".to_string()) - .build()]) - .service_accounts(vec![ServiceAccount::builder() - .email("default".to_string()) - .scopes(vec![ - "https://www.googleapis.com/auth/cloud-platform".to_string() - ]) - .build()]) - .build(), - ) - .build(); - - let create_template_op = ctx - .client - .insert_instance_template(instance_template) - .await - .expect("Failed to create instance template"); - - ctx.track_instance_template(&template_name); - assert!( - create_template_op.name.is_some(), - "Create template operation should have a name" - ); - println!("✅ Instance template creation initiated"); - - ctx.wait_for_global_operation(create_template_op.name.as_ref().unwrap(), 120) - .await - .expect("Instance template creation timed out"); - - // Verify template was created - let fetched_template = ctx - .client - .get_instance_template(template_name.clone()) - .await - .expect("Failed to get instance template"); - - assert_eq!(fetched_template.name.as_ref().unwrap(), &template_name); - println!("✅ Instance template verified: {}", template_name); - - // ========================================================================= - // Step 2: Create Instance Group Manager - // ========================================================================= - println!("\n📦 Step 2: Creating instance group manager: {}", igm_name); - - let template_url = format!( - "projects/{}/global/instanceTemplates/{}", - ctx.project_id, template_name - ); - - let igm = InstanceGroupManager::builder() - .name(igm_name.clone()) - .description("Alien test instance group manager".to_string()) - .instance_template(template_url) - .base_instance_name(format!("alien-test-{}", &igm_name[..8.min(igm_name.len())])) - .target_size(0) // Start with 0 instances - .update_policy( - InstanceGroupManagerUpdatePolicy::builder() - .r#type(UpdatePolicyType::Proactive) - .build(), - ) - .build(); - - let create_igm_op = ctx - .client - .insert_instance_group_manager(ctx.zone.clone(), igm) - .await - .expect("Failed to create instance group manager"); - - ctx.track_instance_group_manager(&ctx.zone, &igm_name); - assert!( - create_igm_op.name.is_some(), - "Create IGM operation should have a name" - ); - println!("✅ Instance group manager creation initiated"); - - ctx.wait_for_zone_operation(&ctx.zone, create_igm_op.name.as_ref().unwrap(), 180) - .await - .expect("Instance group manager creation timed out"); - - // Verify IGM was created - let fetched_igm = ctx - .client - .get_instance_group_manager(ctx.zone.clone(), igm_name.clone()) - .await - .expect("Failed to get instance group manager"); - - assert_eq!(fetched_igm.name.as_ref().unwrap(), &igm_name); - assert_eq!(fetched_igm.target_size, Some(0)); - println!("✅ Instance group manager verified: {}", igm_name); - - // ========================================================================= - // Step 3: Resize Instance Group Manager - // ========================================================================= - println!("\n📦 Step 3: Resizing instance group manager to 1 instance"); - - let resize_op = ctx - .client - .resize_instance_group_manager(ctx.zone.clone(), igm_name.clone(), 1) - .await - .expect("Failed to resize IGM"); - - assert!( - resize_op.name.is_some(), - "Resize operation should have a name" - ); - println!("✅ Resize operation initiated"); - - ctx.wait_for_zone_operation(&ctx.zone, resize_op.name.as_ref().unwrap(), 300) - .await - .expect("Resize operation timed out"); - - // Wait a bit for instances to be created - tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; - - // ========================================================================= - // Step 3.5: Create a second instance template for patch test - // ========================================================================= - println!("\n📦 Step 3.5: Creating second instance template for patch test"); - - let template_v2_name = ctx.generate_unique_name("template-v2"); - - let instance_template_v2 = InstanceTemplate::builder() - .name(template_v2_name.clone()) - .description( - "Alien test instance template v2 (for patch_instance_group_manager)".to_string(), - ) - .properties( - InstanceProperties::builder() - .machine_type("e2-micro".to_string()) - .disks(vec![AttachedDisk::builder() - .r#type(AttachedDiskType::Persistent) - .boot(true) - .mode(DiskMode::ReadWrite) - .auto_delete(true) - .initialize_params( - AttachedDiskInitializeParams::builder() - .source_image( - "projects/debian-cloud/global/images/family/debian-11".to_string(), - ) - .disk_size_gb("10".to_string()) - .build(), - ) - .build()]) - .network_interfaces(vec![NetworkInterface::builder() - .network("global/networks/default".to_string()) - .build()]) - .service_accounts(vec![ServiceAccount::builder() - .email("default".to_string()) - .scopes(vec![ - "https://www.googleapis.com/auth/cloud-platform".to_string() - ]) - .build()]) - .build(), - ) - .build(); - - let create_template_v2_op = ctx - .client - .insert_instance_template(instance_template_v2) - .await - .expect("Failed to create second instance template"); - - ctx.track_instance_template(&template_v2_name); - ctx.wait_for_global_operation(create_template_v2_op.name.as_ref().unwrap(), 120) - .await - .expect("Second instance template creation timed out"); - println!("✅ Second instance template created: {}", template_v2_name); - - // ========================================================================= - // Step 3.6: Patch the IGM to use the new template with PROACTIVE update policy - // ========================================================================= - println!("\n📦 Step 3.6: Patching IGM to use new template with PROACTIVE rolling update"); - - let template_v2_url = format!( - "https://compute.googleapis.com/compute/v1/projects/{}/global/instanceTemplates/{}", - ctx.project_id, template_v2_name - ); - - let igm_patch = InstanceGroupManager::builder() - .instance_template(template_v2_url.clone()) - .update_policy(InstanceGroupManagerUpdatePolicy { - r#type: Some(UpdatePolicyType::Proactive), - minimal_action: Some(MinimalAction::Replace), - most_disruptive_allowed_action: None, - // maxSurge: 1 — create 1 extra VM before terminating old (works with target_size=1) - max_surge: Some(FixedOrPercent { - fixed: Some(1), - percent: None, - calculated: None, - }), - // maxUnavailable: 0 — never reduce capacity below target_size - max_unavailable: Some(FixedOrPercent { - fixed: Some(0), - percent: None, - calculated: None, - }), - replacement_method: None, - }) - .build(); - - let patch_op = ctx - .client - .patch_instance_group_manager(ctx.zone.clone(), igm_name.clone(), igm_patch) - .await - .expect("Failed to patch instance group manager"); - - assert!( - patch_op.name.is_some(), - "Patch operation should have a name" - ); - println!("✅ Patch operation initiated: {:?}", patch_op.name); - - ctx.wait_for_zone_operation(&ctx.zone, patch_op.name.as_ref().unwrap(), 120) - .await - .expect("Patch operation timed out"); - - // ========================================================================= - // Step 3.7: Verify the IGM now references the new template - // ========================================================================= - println!("\n📦 Step 3.7: Verifying IGM references new template after patch"); - - let patched_igm = ctx - .client - .get_instance_group_manager(ctx.zone.clone(), igm_name.clone()) - .await - .expect("Failed to get patched IGM"); - - let current_template = patched_igm.instance_template.as_deref().unwrap_or(""); - assert!( - current_template.contains(&template_v2_name), - "IGM should now reference the new template '{}', but has '{}'", - template_v2_name, - current_template - ); - println!( - "✅ IGM correctly references new template: {}", - template_v2_name - ); - - let stable_managed_instance = ctx - .wait_for_stable_managed_instance(&ctx.zone, &igm_name, &template_v2_name, 300) - .await - .expect("Managed instance group never converged on the patched template"); - - // ========================================================================= - // Step 4: List Managed Instances - // ========================================================================= - println!("\n📦 Step 4: Listing managed instances"); - - let managed_instances = ctx - .client - .list_managed_instances(ctx.zone.clone(), igm_name.clone()) - .await - .expect("Failed to list managed instances"); - - println!( - " Found {} managed instances", - managed_instances.managed_instances.len() - ); - for mi in &managed_instances.managed_instances { - if let Some(instance_url) = &mi.instance { - let instance_name = instance_url.split('/').last().unwrap_or("unknown"); - println!( - " - Instance: {}, Status: {:?}", - instance_name, mi.instance_status - ); - } - } - println!("✅ Managed instances listed"); - - // ========================================================================= - // Step 4.1: Get serial port output from the managed instance - // ========================================================================= - println!("\n📦 Step 4.1: Reading serial port output from first instance"); - - let stable_instance_url = stable_managed_instance - .instance - .as_ref() - .expect("Stable managed instance should have an instance URL"); - let stable_instance_name = stable_instance_url - .split('/') - .last() - .unwrap_or("unknown") - .to_string(); - - // Retry because the instance may not be ready immediately after creation or replacement. - let mut serial_output = None; - for attempt in 1..=12 { - match ctx - .client - .get_serial_port_output(ctx.zone.clone(), stable_instance_name.clone()) - .await - { - Ok(output) => { - serial_output = Some(output); - break; - } - Err(e) => { - let msg = format!("{:?}", e); - if msg.contains("resourceNotReady") || msg.contains("not ready") { - println!( - " Instance not ready for serial port (attempt {}/12), waiting 10s...", - attempt - ); - tokio::time::sleep(std::time::Duration::from_secs(10)).await; - } else { - panic!("Failed to get serial port output: {:?}", e); - } - } - } - } - - let serial_output = - serial_output.expect("Instance never became ready for serial port output after 120s"); - println!( - " Serial port output length: {} bytes", - serial_output.contents.as_deref().unwrap_or("").len() - ); - assert!( - serial_output.contents.is_some(), - "Serial port output should have a contents field" - ); - println!( - "✅ Serial port output retrieved successfully from {}", - stable_instance_name - ); - - // ========================================================================= - // Step 4.5: Attach and detach a persistent disk to a managed instance - // ========================================================================= - println!("\n📦 Step 4.5: Attaching and detaching a disk to a managed instance"); - - let instance_name = stable_instance_name.clone(); - - let disk_name = ctx.generate_unique_name("attach-disk"); - let device_name = format!("dev-{}", &disk_name[..8.min(disk_name.len())]); - let disk = Disk::builder() - .name(disk_name.clone()) - .description("Alien test attached disk".to_string()) - .size_gb("10".to_string()) - .r#type(format!( - "projects/{}/zones/{}/diskTypes/pd-standard", - ctx.project_id, ctx.zone - )) - .build(); - - let create_disk_op = ctx - .client - .insert_disk(ctx.zone.clone(), disk) - .await - .expect("Failed to create disk for attachment"); - ctx.track_disk(&ctx.zone, &disk_name); - - ctx.wait_for_zone_operation(&ctx.zone, create_disk_op.name.as_ref().unwrap(), 120) - .await - .expect("Disk creation timed out"); - - let attached_disk = AttachedDisk::builder() - .r#type(AttachedDiskType::Persistent) - .mode(DiskMode::ReadWrite) - .source(format!( - "projects/{}/zones/{}/disks/{}", - ctx.project_id, ctx.zone, disk_name - )) - .device_name(device_name.clone()) - .auto_delete(false) - .build(); - - let attach_op = ctx - .client - .attach_disk(ctx.zone.clone(), instance_name.clone(), attached_disk) - .await - .expect("Failed to attach disk"); - - ctx.wait_for_zone_operation(&ctx.zone, attach_op.name.as_ref().unwrap(), 300) - .await - .expect("Disk attach operation failed or timed out"); - println!("✅ Disk attached to instance {}", instance_name); - - let detach_op = ctx - .client - .detach_disk(ctx.zone.clone(), instance_name.clone(), device_name.clone()) - .await - .expect("Failed to detach disk"); - - ctx.wait_for_zone_operation(&ctx.zone, detach_op.name.as_ref().unwrap(), 300) - .await - .expect("Disk detach operation failed or timed out"); - println!("✅ Disk detached from instance {}", instance_name); - - let delete_disk_op = ctx - .client - .delete_disk(ctx.zone.clone(), disk_name.clone()) - .await - .expect("Failed to delete attached disk"); - ctx.wait_for_zone_operation(&ctx.zone, delete_disk_op.name.as_ref().unwrap(), 120) - .await - .expect("Disk deletion failed or timed out"); - ctx.untrack_disk(&ctx.zone, &disk_name); - println!("✅ Attached disk deleted"); - - // ========================================================================= - // Step 5: Resize back to 0 before cleanup - // ========================================================================= - println!("\n📦 Step 5: Resizing instance group manager back to 0"); - - let resize_down_op = ctx - .client - .resize_instance_group_manager(ctx.zone.clone(), igm_name.clone(), 0) - .await - .expect("Failed to resize IGM down"); - - ctx.wait_for_zone_operation(&ctx.zone, resize_down_op.name.as_ref().unwrap(), 300) - .await - .expect("Resize down operation timed out"); - println!("✅ Instance group manager resized to 0"); - - // Wait for instances to be deleted - tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; - - // ========================================================================= - // Step 6: Cleanup - // ========================================================================= - println!("\n🧹 Step 6: Cleaning up instance management resources"); - - // Delete IGM - let delete_igm_op = ctx - .client - .delete_instance_group_manager(ctx.zone.clone(), igm_name.clone()) - .await - .expect("Failed to delete IGM"); - - ctx.wait_for_zone_operation(&ctx.zone, delete_igm_op.name.as_ref().unwrap(), 300) - .await - .expect("IGM deletion timed out"); - ctx.untrack_instance_group_manager(&ctx.zone, &igm_name); - println!(" ✅ Instance group manager deleted"); - - // Delete instance template - let delete_template_op = ctx - .client - .delete_instance_template(template_name.clone()) - .await - .expect("Failed to delete instance template"); - - ctx.wait_for_global_operation(delete_template_op.name.as_ref().unwrap(), 120) - .await - .expect("Instance template deletion timed out"); - ctx.untrack_instance_template(&template_name); - println!(" ✅ Instance template deleted"); - - println!("\n🎉 Comprehensive Instance Management lifecycle test completed successfully!"); - println!(" - Instance template created and deleted: ✅"); - println!(" - Instance group manager created, resized, and deleted: ✅"); - println!(" - Managed instances listed: ✅"); -} - -// ============================================================================================= -// Error Handling Tests for New APIs -// ============================================================================================= - -// ------------------------------------------------------------------------- -// HTTPS Load Balancing Tests (SSL Certificates + Target HTTPS Proxies) -// ------------------------------------------------------------------------- - -/// This test covers the full lifecycle of SSL certificates and HTTPS proxies: -/// 1. Create an SSL certificate (self-managed) -/// 2. Verify the certificate was created -/// 3. Create a Target HTTPS proxy referencing the certificate -/// 4. Verify the HTTPS proxy was created -/// 5. Delete the HTTPS proxy -/// 6. Delete the SSL certificate -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_comprehensive_ssl_https_proxy_lifecycle(ctx: &mut ComputeTestContext) { - println!("🚀 Starting comprehensive SSL certificate and HTTPS proxy lifecycle test"); - - // Generate unique names - let ssl_cert_name = ctx.generate_unique_name("ssl-cert"); - let https_proxy_name = ctx.generate_unique_name("https-proxy"); - let url_map_name = ctx.generate_unique_name("urlmap-for-https"); - let backend_service_name = ctx.generate_unique_name("bs-for-https"); - let health_check_name = ctx.generate_unique_name("hc-for-https"); - - // ========================================================================= - // Step 1: Create prerequisites (health check, backend service, URL map) - // ========================================================================= - println!("\n📦 Step 1: Creating prerequisites for HTTPS proxy"); - - // Create health check - let health_check = HealthCheck::builder() - .name(health_check_name.clone()) - .r#type(HealthCheckType::Http) - .check_interval_sec(10) - .timeout_sec(5) - .http_health_check(HttpHealthCheck::builder().port(80).build()) - .build(); - - let create_hc_op = ctx - .client - .insert_health_check(health_check) - .await - .expect("Failed to create health check"); - ctx.track_health_check(&health_check_name); - ctx.wait_for_global_operation(create_hc_op.name.as_ref().unwrap(), 120) - .await - .expect("Health check creation timed out"); - - let health_check_url = format!( - "projects/{}/global/healthChecks/{}", - ctx.project_id, health_check_name - ); - - // Create backend service - let backend_service = BackendService::builder() - .name(backend_service_name.clone()) - .protocol(BackendServiceProtocol::Http) - .health_checks(vec![health_check_url]) - .build(); - - let create_bs_op = ctx - .client - .insert_backend_service(backend_service) - .await - .expect("Failed to create backend service"); - ctx.track_backend_service(&backend_service_name); - ctx.wait_for_global_operation(create_bs_op.name.as_ref().unwrap(), 120) - .await - .expect("Backend service creation timed out"); - - let backend_service_url = format!( - "projects/{}/global/backendServices/{}", - ctx.project_id, backend_service_name - ); - - // Create URL map - let url_map = UrlMap::builder() - .name(url_map_name.clone()) - .default_service(backend_service_url) - .build(); - - let create_urlmap_op = ctx - .client - .insert_url_map(url_map) - .await - .expect("Failed to create URL map"); - ctx.track_url_map(&url_map_name); - ctx.wait_for_global_operation(create_urlmap_op.name.as_ref().unwrap(), 120) - .await - .expect("URL map creation timed out"); - - let url_map_url = format!( - "projects/{}/global/urlMaps/{}", - ctx.project_id, url_map_name - ); - - println!("✅ Prerequisites created"); - - // ========================================================================= - // Step 2: Create SSL Certificate - // ========================================================================= - println!("\n📦 Step 2: Creating SSL certificate: {}", ssl_cert_name); - - // Generate a valid self-signed certificate with CN and SAN using rcgen - use rcgen::{CertificateParams, DistinguishedName, DnType}; - - let mut params = CertificateParams::new(vec!["example.com".to_string()]) - .expect("Failed to create certificate params"); - - // Set distinguished name with Common Name - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "example.com"); - dn.push(DnType::OrganizationName, "Alien Test"); - dn.push(DnType::CountryName, "US"); - params.distinguished_name = dn; - - // Add Subject Alternative Names (required by GCP) - params.subject_alt_names = vec![ - rcgen::SanType::DnsName(rcgen::Ia5String::try_from("example.com").unwrap()), - rcgen::SanType::DnsName(rcgen::Ia5String::try_from("*.example.com").unwrap()), - ]; - - let key_pair = rcgen::KeyPair::generate().expect("Failed to generate key pair"); - let cert = params - .self_signed(&key_pair) - .expect("Failed to generate certificate"); - - let certificate_pem = cert.pem(); - let private_key_pem = key_pair.serialize_pem(); - - let ssl_certificate = SslCertificate::builder() - .name(ssl_cert_name.clone()) - .description("Alien test SSL certificate".to_string()) - .r#type("SELF_MANAGED".to_string()) - .self_managed( - SslCertificateSelfManaged::builder() - .certificate(certificate_pem.to_string()) - .private_key(private_key_pem.to_string()) - .build(), - ) - .build(); - - let create_ssl_op = ctx - .client - .insert_ssl_certificate(ssl_certificate) - .await - .expect("Failed to create SSL certificate"); - - // Track for cleanup (we'll add tracking helper) - ctx.wait_for_global_operation(create_ssl_op.name.as_ref().unwrap(), 120) - .await - .expect("SSL certificate creation timed out"); - - println!("✅ SSL certificate created"); - - // Verify certificate was created - let fetched_cert = ctx - .client - .get_ssl_certificate(ssl_cert_name.clone()) - .await - .expect("Failed to get SSL certificate"); - - assert_eq!(fetched_cert.name.as_ref().unwrap(), &ssl_cert_name); - assert!(fetched_cert.id.is_some(), "Certificate should have an ID"); - println!("✅ Verified SSL certificate: {}", ssl_cert_name); - - // ========================================================================= - // Step 3: Create Target HTTPS Proxy - // ========================================================================= - println!( - "\n📦 Step 3: Creating Target HTTPS proxy: {}", - https_proxy_name - ); - - let ssl_cert_url = format!( - "projects/{}/global/sslCertificates/{}", - ctx.project_id, ssl_cert_name - ); - - let https_proxy = TargetHttpsProxy::builder() - .name(https_proxy_name.clone()) - .description("Alien test HTTPS proxy".to_string()) - .url_map(url_map_url) - .ssl_certificates(vec![ssl_cert_url]) - .build(); - - let create_proxy_op = ctx - .client - .insert_target_https_proxy(https_proxy) - .await - .expect("Failed to create Target HTTPS proxy"); - - ctx.wait_for_global_operation(create_proxy_op.name.as_ref().unwrap(), 120) - .await - .expect("Target HTTPS proxy creation timed out"); - - println!("✅ Target HTTPS proxy created"); - - // Verify HTTPS proxy was created - let fetched_proxy = ctx - .client - .get_target_https_proxy(https_proxy_name.clone()) - .await - .expect("Failed to get Target HTTPS proxy"); - - assert_eq!(fetched_proxy.name.as_ref().unwrap(), &https_proxy_name); - assert!(fetched_proxy.id.is_some(), "Proxy should have an ID"); - assert!( - fetched_proxy.ssl_certificates.is_some(), - "Proxy should have SSL certificates" - ); - println!("✅ Verified Target HTTPS proxy: {}", https_proxy_name); - - // ========================================================================= - // Step 4: Delete Target HTTPS Proxy - // ========================================================================= - println!("\n🗑️ Step 4: Deleting Target HTTPS proxy"); - - let delete_proxy_op = ctx - .client - .delete_target_https_proxy(https_proxy_name.clone()) - .await - .expect("Failed to delete Target HTTPS proxy"); - - ctx.wait_for_global_operation(delete_proxy_op.name.as_ref().unwrap(), 120) - .await - .expect("Target HTTPS proxy deletion timed out"); - - // Verify deletion - let get_deleted_result = ctx - .client - .get_target_https_proxy(https_proxy_name.clone()) - .await; - assert!( - get_deleted_result.is_err(), - "Target HTTPS proxy should be deleted" - ); - println!("✅ Target HTTPS proxy deleted"); - - // ========================================================================= - // Step 5: Delete SSL Certificate - // ========================================================================= - println!("\n🗑️ Step 5: Deleting SSL certificate"); - - let delete_ssl_op = ctx - .client - .delete_ssl_certificate(ssl_cert_name.clone()) - .await - .expect("Failed to delete SSL certificate"); - - ctx.wait_for_global_operation(delete_ssl_op.name.as_ref().unwrap(), 120) - .await - .expect("SSL certificate deletion timed out"); - - // Verify deletion - let get_deleted_cert_result = ctx.client.get_ssl_certificate(ssl_cert_name.clone()).await; - assert!( - get_deleted_cert_result.is_err(), - "SSL certificate should be deleted" - ); - println!("✅ SSL certificate deleted"); - - // Clean up prerequisites - ctx.cleanup_url_map(&url_map_name).await; - ctx.untrack_url_map(&url_map_name); - ctx.cleanup_backend_service(&backend_service_name).await; - ctx.untrack_backend_service(&backend_service_name); - ctx.cleanup_health_check(&health_check_name).await; - ctx.untrack_health_check(&health_check_name); - - println!("\n🎉 SSL certificate and HTTPS proxy lifecycle test completed successfully!"); -} - -// ------------------------------------------------------------------------- -// Not Found Error Tests -// ------------------------------------------------------------------------- - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_health_check_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-hc-does-not-exist-12345"; - - let result = ctx.client.get_health_check(non_existent.to_string()).await; - assert!( - result.is_err(), - "Expected error for non-existent health check" - ); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for health check"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_backend_service_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-bs-does-not-exist-12345"; - - let result = ctx - .client - .get_backend_service(non_existent.to_string()) - .await; - assert!( - result.is_err(), - "Expected error for non-existent backend service" - ); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for backend service"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_disk_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-disk-does-not-exist-12345"; - - let result = ctx - .client - .get_disk(ctx.zone.clone(), non_existent.to_string()) - .await; - assert!(result.is_err(), "Expected error for non-existent disk"); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for disk"); -} - -#[test_context(ComputeTestContext)] -#[tokio::test] -async fn test_error_instance_template_not_found(ctx: &mut ComputeTestContext) { - let non_existent = "alien-test-template-does-not-exist-12345"; - - let result = ctx - .client - .get_instance_template(non_existent.to_string()) - .await; - assert!( - result.is_err(), - "Expected error for non-existent instance template" - ); - - let err = result.unwrap_err(); - assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); - println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for instance template"); -} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/context.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/context.rs new file mode 100644 index 000000000..8de2c4863 --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/context.rs @@ -0,0 +1,1079 @@ +use alien_client_core::{Error, ErrorData}; +use alien_gcp_clients::compute::{ + ComputeApi, ComputeClient, ManagedInstance, ManagedInstanceCurrentAction, ManagedInstanceStatus, +}; +use alien_gcp_clients::platform::{GcpClientConfig, GcpCredentials}; +use reqwest::Client; +use std::collections::HashSet; +use std::env; +use std::path::PathBuf; +use std::sync::Mutex; +use test_context::AsyncTestContext; +use tracing::{info, warn}; +use uuid::Uuid; + +const TEST_REGION: &str = "us-central1"; +const TEST_ZONE: &str = "us-central1-a"; +pub(crate) const NETWORK_DELETE_TIMEOUT_SECONDS: u64 = 300; +const NETWORK_DELETE_RETRY_INTERVAL_SECONDS: u64 = 10; + +pub(crate) struct ComputeTestContext { + pub(crate) client: ComputeClient, + pub(crate) project_id: String, + pub(crate) region: String, + pub(crate) zone: String, + /// Resources to clean up, in reverse order of creation + created_networks: Mutex>, + created_subnetworks: Mutex>, // (region, name) + created_routers: Mutex>, // (region, name) + created_firewalls: Mutex>, + // Load Balancing resources + created_health_checks: Mutex>, + created_backend_services: Mutex>, + created_url_maps: Mutex>, + created_target_http_proxies: Mutex>, + created_global_addresses: Mutex>, + created_global_forwarding_rules: Mutex>, + created_negs: Mutex>, // (zone, name) + // Instance Management resources + created_instance_templates: Mutex>, + created_instance_group_managers: Mutex>, // (zone, name) + // Disk resources + created_disks: Mutex>, // (zone, name) +} + +impl AsyncTestContext for ComputeTestContext { + async fn setup() -> ComputeTestContext { + let root: PathBuf = workspace_root::get_workspace_root(); + dotenvy::from_path(root.join(".env.test")).expect("Failed to load .env.test"); + tracing_subscriber::fmt::try_init().ok(); + + let gcp_credentials_json = env::var("GOOGLE_MANAGEMENT_SERVICE_ACCOUNT_KEY") + .unwrap_or_else(|_| panic!("GOOGLE_MANAGEMENT_SERVICE_ACCOUNT_KEY must be set")); + + // Parse project_id from service account + let service_account_value: serde_json::Value = + serde_json::from_str(&gcp_credentials_json).unwrap(); + let project_id = service_account_value + .get("project_id") + .and_then(|v| v.as_str()) + .map(String::from) + .expect("'project_id' must be present in the service account JSON"); + + let config = GcpClientConfig { + project_id: project_id.clone(), + region: TEST_REGION.to_string(), + credentials: GcpCredentials::ServiceAccountKey { + json: gcp_credentials_json, + }, + service_overrides: None, + project_number: None, + }; + + let client = ComputeClient::new(Client::new(), config); + + ComputeTestContext { + client, + project_id, + region: TEST_REGION.to_string(), + zone: TEST_ZONE.to_string(), + created_networks: Mutex::new(HashSet::new()), + created_subnetworks: Mutex::new(HashSet::new()), + created_routers: Mutex::new(HashSet::new()), + created_firewalls: Mutex::new(HashSet::new()), + created_health_checks: Mutex::new(HashSet::new()), + created_backend_services: Mutex::new(HashSet::new()), + created_url_maps: Mutex::new(HashSet::new()), + created_target_http_proxies: Mutex::new(HashSet::new()), + created_global_addresses: Mutex::new(HashSet::new()), + created_global_forwarding_rules: Mutex::new(HashSet::new()), + created_negs: Mutex::new(HashSet::new()), + created_instance_templates: Mutex::new(HashSet::new()), + created_instance_group_managers: Mutex::new(HashSet::new()), + created_disks: Mutex::new(HashSet::new()), + } + } + + async fn teardown(self) { + info!("🧹 Starting Compute Engine test cleanup..."); + + // Clean up in reverse dependency order + + // Delete disks (must be detached first) + let disks_to_cleanup = { + let disks = self.created_disks.lock().unwrap(); + disks.clone() + }; + for (zone, disk_name) in disks_to_cleanup { + self.cleanup_disk(&zone, &disk_name).await; + } + + // Delete instance group managers (must be done before instance templates) + let igms_to_cleanup = { + let igms = self.created_instance_group_managers.lock().unwrap(); + igms.clone() + }; + for (zone, igm_name) in igms_to_cleanup { + self.cleanup_instance_group_manager(&zone, &igm_name).await; + } + + // Delete instance templates + let templates_to_cleanup = { + let templates = self.created_instance_templates.lock().unwrap(); + templates.clone() + }; + for template_name in templates_to_cleanup { + self.cleanup_instance_template(&template_name).await; + } + + // Delete forwarding rules (must be before target proxies and addresses) + let fwds_to_cleanup = { + let fwds = self.created_global_forwarding_rules.lock().unwrap(); + fwds.clone() + }; + for fwd_name in fwds_to_cleanup { + self.cleanup_global_forwarding_rule(&fwd_name).await; + } + + // Delete target HTTP proxies (must be before URL maps) + let proxies_to_cleanup = { + let proxies = self.created_target_http_proxies.lock().unwrap(); + proxies.clone() + }; + for proxy_name in proxies_to_cleanup { + self.cleanup_target_http_proxy(&proxy_name).await; + } + + // Delete URL maps (must be before backend services) + let url_maps_to_cleanup = { + let url_maps = self.created_url_maps.lock().unwrap(); + url_maps.clone() + }; + for url_map_name in url_maps_to_cleanup { + self.cleanup_url_map(&url_map_name).await; + } + + // Delete backend services (must be before health checks and NEGs) + let bs_to_cleanup = { + let bs = self.created_backend_services.lock().unwrap(); + bs.clone() + }; + for bs_name in bs_to_cleanup { + self.cleanup_backend_service(&bs_name).await; + } + + // Delete NEGs + let negs_to_cleanup = { + let negs = self.created_negs.lock().unwrap(); + negs.clone() + }; + for (zone, neg_name) in negs_to_cleanup { + self.cleanup_neg(&zone, &neg_name).await; + } + + // Delete health checks + let hc_to_cleanup = { + let hc = self.created_health_checks.lock().unwrap(); + hc.clone() + }; + for hc_name in hc_to_cleanup { + self.cleanup_health_check(&hc_name).await; + } + + // Delete global addresses + let addrs_to_cleanup = { + let addrs = self.created_global_addresses.lock().unwrap(); + addrs.clone() + }; + for addr_name in addrs_to_cleanup { + self.cleanup_global_address(&addr_name).await; + } + + // Delete firewalls + let firewalls_to_cleanup = { + let firewalls = self.created_firewalls.lock().unwrap(); + firewalls.clone() + }; + for firewall_name in firewalls_to_cleanup { + self.cleanup_firewall(&firewall_name).await; + } + + // Delete routers + let routers_to_cleanup = { + let routers = self.created_routers.lock().unwrap(); + routers.clone() + }; + for (region, router_name) in routers_to_cleanup { + self.cleanup_router(®ion, &router_name).await; + } + + // Delete subnetworks + let subnetworks_to_cleanup = { + let subnetworks = self.created_subnetworks.lock().unwrap(); + subnetworks.clone() + }; + for (region, subnetwork_name) in subnetworks_to_cleanup { + self.cleanup_subnetwork(®ion, &subnetwork_name).await; + } + + // Delete networks + let networks_to_cleanup = { + let networks = self.created_networks.lock().unwrap(); + networks.clone() + }; + for network_name in networks_to_cleanup { + self.cleanup_network(&network_name).await; + } + + info!("✅ Compute Engine test cleanup completed"); + } +} + +impl ComputeTestContext { + fn is_resource_not_ready_error(err: &Error) -> bool { + let msg = format!("{:?}", err).to_lowercase(); + msg.contains("resourcenotready") || msg.contains("not ready") + } + + pub(crate) async fn delete_network_with_retry( + &self, + network_name: &str, + timeout_seconds: u64, + ) -> std::result::Result<(), Box> { + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout_seconds); + let mut attempt = 0u64; + + loop { + if start_time.elapsed() > timeout_duration { + return Err(format!( + "Timed out deleting network {} after {}s", + network_name, timeout_seconds + ) + .into()); + } + + attempt += 1; + info!( + "🧹 Attempting network deletion (attempt {}): {}", + attempt, network_name + ); + + match self.client.delete_network(network_name.to_string()).await { + Ok(operation) => { + let op_name = operation.name.as_deref().ok_or_else(|| { + format!("Delete network {} returned no operation name", network_name) + })?; + + let remaining = timeout_duration + .saturating_sub(start_time.elapsed()) + .as_secs() + .max(1); + + match self.wait_for_global_operation(op_name, remaining).await { + Ok(()) => return Ok(()), + Err(e) => { + warn!( + "Network delete operation for {} did not complete yet: {}", + network_name, e + ); + } + } + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => return Ok(()), + _ if Self::is_resource_not_ready_error(&e) => { + info!( + "Network {} is not ready for deletion yet (attempt {}), retrying...", + network_name, attempt + ); + } + _ => { + return Err( + format!("Failed to delete network {}: {:?}", network_name, e).into(), + ) + } + }, + } + + tokio::time::sleep(tokio::time::Duration::from_secs( + NETWORK_DELETE_RETRY_INTERVAL_SECONDS, + )) + .await; + } + } + + // --- Tracking methods --- + + pub(crate) fn track_network(&self, network_name: &str) { + let mut networks = self.created_networks.lock().unwrap(); + networks.insert(network_name.to_string()); + info!("📝 Tracking network for cleanup: {}", network_name); + } + + pub(crate) fn untrack_network(&self, network_name: &str) { + let mut networks = self.created_networks.lock().unwrap(); + networks.remove(network_name); + info!("✅ Network {} untracked", network_name); + } + + pub(crate) fn track_subnetwork(&self, region: &str, subnetwork_name: &str) { + let mut subnetworks = self.created_subnetworks.lock().unwrap(); + subnetworks.insert((region.to_string(), subnetwork_name.to_string())); + info!( + "📝 Tracking subnetwork for cleanup: {}/{}", + region, subnetwork_name + ); + } + + pub(crate) fn untrack_subnetwork(&self, region: &str, subnetwork_name: &str) { + let mut subnetworks = self.created_subnetworks.lock().unwrap(); + subnetworks.remove(&(region.to_string(), subnetwork_name.to_string())); + info!("✅ Subnetwork {}/{} untracked", region, subnetwork_name); + } + + pub(crate) fn track_router(&self, region: &str, router_name: &str) { + let mut routers = self.created_routers.lock().unwrap(); + routers.insert((region.to_string(), router_name.to_string())); + info!("📝 Tracking router for cleanup: {}/{}", region, router_name); + } + + pub(crate) fn untrack_router(&self, region: &str, router_name: &str) { + let mut routers = self.created_routers.lock().unwrap(); + routers.remove(&(region.to_string(), router_name.to_string())); + info!("✅ Router {}/{} untracked", region, router_name); + } + + pub(crate) fn track_firewall(&self, firewall_name: &str) { + let mut firewalls = self.created_firewalls.lock().unwrap(); + firewalls.insert(firewall_name.to_string()); + info!("📝 Tracking firewall for cleanup: {}", firewall_name); + } + + pub(crate) fn untrack_firewall(&self, firewall_name: &str) { + let mut firewalls = self.created_firewalls.lock().unwrap(); + firewalls.remove(firewall_name); + info!("✅ Firewall {} untracked", firewall_name); + } + + // --- Load Balancing Tracking Methods --- + + pub(crate) fn track_health_check(&self, name: &str) { + let mut hc = self.created_health_checks.lock().unwrap(); + hc.insert(name.to_string()); + info!("📝 Tracking health check for cleanup: {}", name); + } + + pub(crate) fn untrack_health_check(&self, name: &str) { + let mut hc = self.created_health_checks.lock().unwrap(); + hc.remove(name); + info!("✅ Health check {} untracked", name); + } + + pub(crate) fn track_backend_service(&self, name: &str) { + let mut bs = self.created_backend_services.lock().unwrap(); + bs.insert(name.to_string()); + info!("📝 Tracking backend service for cleanup: {}", name); + } + + pub(crate) fn untrack_backend_service(&self, name: &str) { + let mut bs = self.created_backend_services.lock().unwrap(); + bs.remove(name); + info!("✅ Backend service {} untracked", name); + } + + pub(crate) fn track_url_map(&self, name: &str) { + let mut um = self.created_url_maps.lock().unwrap(); + um.insert(name.to_string()); + info!("📝 Tracking URL map for cleanup: {}", name); + } + + pub(crate) fn untrack_url_map(&self, name: &str) { + let mut um = self.created_url_maps.lock().unwrap(); + um.remove(name); + info!("✅ URL map {} untracked", name); + } + + pub(crate) fn track_target_http_proxy(&self, name: &str) { + let mut proxies = self.created_target_http_proxies.lock().unwrap(); + proxies.insert(name.to_string()); + info!("📝 Tracking target HTTP proxy for cleanup: {}", name); + } + + pub(crate) fn untrack_target_http_proxy(&self, name: &str) { + let mut proxies = self.created_target_http_proxies.lock().unwrap(); + proxies.remove(name); + info!("✅ Target HTTP proxy {} untracked", name); + } + + pub(crate) fn track_global_address(&self, name: &str) { + let mut addrs = self.created_global_addresses.lock().unwrap(); + addrs.insert(name.to_string()); + info!("📝 Tracking global address for cleanup: {}", name); + } + + pub(crate) fn untrack_global_address(&self, name: &str) { + let mut addrs = self.created_global_addresses.lock().unwrap(); + addrs.remove(name); + info!("✅ Global address {} untracked", name); + } + + pub(crate) fn track_global_forwarding_rule(&self, name: &str) { + let mut fwds = self.created_global_forwarding_rules.lock().unwrap(); + fwds.insert(name.to_string()); + info!("📝 Tracking global forwarding rule for cleanup: {}", name); + } + + pub(crate) fn untrack_global_forwarding_rule(&self, name: &str) { + let mut fwds = self.created_global_forwarding_rules.lock().unwrap(); + fwds.remove(name); + info!("✅ Global forwarding rule {} untracked", name); + } + + pub(crate) fn track_neg(&self, zone: &str, name: &str) { + let mut negs = self.created_negs.lock().unwrap(); + negs.insert((zone.to_string(), name.to_string())); + info!("📝 Tracking NEG for cleanup: {}/{}", zone, name); + } + + pub(crate) fn untrack_neg(&self, zone: &str, name: &str) { + let mut negs = self.created_negs.lock().unwrap(); + negs.remove(&(zone.to_string(), name.to_string())); + info!("✅ NEG {}/{} untracked", zone, name); + } + + // --- Instance Management Tracking Methods --- + + pub(crate) fn track_instance_template(&self, name: &str) { + let mut templates = self.created_instance_templates.lock().unwrap(); + templates.insert(name.to_string()); + info!("📝 Tracking instance template for cleanup: {}", name); + } + + pub(crate) fn untrack_instance_template(&self, name: &str) { + let mut templates = self.created_instance_templates.lock().unwrap(); + templates.remove(name); + info!("✅ Instance template {} untracked", name); + } + + pub(crate) fn track_instance_group_manager(&self, zone: &str, name: &str) { + let mut igms = self.created_instance_group_managers.lock().unwrap(); + igms.insert((zone.to_string(), name.to_string())); + info!( + "📝 Tracking instance group manager for cleanup: {}/{}", + zone, name + ); + } + + pub(crate) fn untrack_instance_group_manager(&self, zone: &str, name: &str) { + let mut igms = self.created_instance_group_managers.lock().unwrap(); + igms.remove(&(zone.to_string(), name.to_string())); + info!("✅ Instance group manager {}/{} untracked", zone, name); + } + + // --- Disk Tracking Methods --- + + pub(crate) fn track_disk(&self, zone: &str, name: &str) { + let mut disks = self.created_disks.lock().unwrap(); + disks.insert((zone.to_string(), name.to_string())); + info!("📝 Tracking disk for cleanup: {}/{}", zone, name); + } + + pub(crate) fn untrack_disk(&self, zone: &str, name: &str) { + let mut disks = self.created_disks.lock().unwrap(); + disks.remove(&(zone.to_string(), name.to_string())); + info!("✅ Disk {}/{} untracked", zone, name); + } + + // --- Cleanup methods --- + + async fn cleanup_firewall(&self, firewall_name: &str) { + info!("🧹 Cleaning up firewall: {}", firewall_name); + match self.client.delete_firewall(firewall_name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Firewall {} deleted", firewall_name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Firewall {} was already deleted", firewall_name); + } + _ => warn!("Failed to delete firewall {}: {:?}", firewall_name, e), + }, + } + } + + async fn cleanup_router(&self, region: &str, router_name: &str) { + info!("🧹 Cleaning up router: {}/{}", region, router_name); + match self + .client + .delete_router(region.to_string(), router_name.to_string()) + .await + { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_region_operation(region, op_name, 120).await; + } + info!("✅ Router {}/{} deleted", region, router_name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Router {}/{} was already deleted", region, router_name); + } + _ => warn!( + "Failed to delete router {}/{}: {:?}", + region, router_name, e + ), + }, + } + } + + async fn cleanup_subnetwork(&self, region: &str, subnetwork_name: &str) { + info!("🧹 Cleaning up subnetwork: {}/{}", region, subnetwork_name); + match self + .client + .delete_subnetwork(region.to_string(), subnetwork_name.to_string()) + .await + { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_region_operation(region, op_name, 120).await; + } + info!("✅ Subnetwork {}/{} deleted", region, subnetwork_name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!( + "🔍 Subnetwork {}/{} was already deleted", + region, subnetwork_name + ); + } + _ => warn!( + "Failed to delete subnetwork {}/{}: {:?}", + region, subnetwork_name, e + ), + }, + } + } + + async fn cleanup_network(&self, network_name: &str) { + info!("🧹 Cleaning up network: {}", network_name); + match self + .delete_network_with_retry(network_name, NETWORK_DELETE_TIMEOUT_SECONDS) + .await + { + Ok(()) => { + info!("✅ Network {} deleted", network_name); + } + Err(e) => warn!("Failed to delete network {}: {:?}", network_name, e), + } + } + + // --- Load Balancing Cleanup Methods --- + + pub(crate) async fn cleanup_health_check(&self, name: &str) { + info!("🧹 Cleaning up health check: {}", name); + match self.client.delete_health_check(name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Health check {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Health check {} was already deleted", name); + } + _ => warn!("Failed to delete health check {}: {:?}", name, e), + }, + } + } + + pub(crate) async fn cleanup_backend_service(&self, name: &str) { + info!("🧹 Cleaning up backend service: {}", name); + match self.client.delete_backend_service(name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Backend service {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Backend service {} was already deleted", name); + } + _ => warn!("Failed to delete backend service {}: {:?}", name, e), + }, + } + } + + pub(crate) async fn cleanup_url_map(&self, name: &str) { + info!("🧹 Cleaning up URL map: {}", name); + match self.client.delete_url_map(name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ URL map {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 URL map {} was already deleted", name); + } + _ => warn!("Failed to delete URL map {}: {:?}", name, e), + }, + } + } + + async fn cleanup_target_http_proxy(&self, name: &str) { + info!("🧹 Cleaning up target HTTP proxy: {}", name); + match self.client.delete_target_http_proxy(name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Target HTTP proxy {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Target HTTP proxy {} was already deleted", name); + } + _ => warn!("Failed to delete target HTTP proxy {}: {:?}", name, e), + }, + } + } + + async fn cleanup_global_address(&self, name: &str) { + info!("🧹 Cleaning up global address: {}", name); + match self.client.delete_global_address(name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Global address {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Global address {} was already deleted", name); + } + _ => warn!("Failed to delete global address {}: {:?}", name, e), + }, + } + } + + async fn cleanup_global_forwarding_rule(&self, name: &str) { + info!("🧹 Cleaning up global forwarding rule: {}", name); + match self + .client + .delete_global_forwarding_rule(name.to_string()) + .await + { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Global forwarding rule {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Global forwarding rule {} was already deleted", name); + } + _ => warn!("Failed to delete global forwarding rule {}: {:?}", name, e), + }, + } + } + + async fn cleanup_neg(&self, zone: &str, name: &str) { + info!("🧹 Cleaning up NEG: {}/{}", zone, name); + match self + .client + .delete_network_endpoint_group(zone.to_string(), name.to_string()) + .await + { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_zone_operation(zone, op_name, 120).await; + } + info!("✅ NEG {}/{} deleted", zone, name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 NEG {}/{} was already deleted", zone, name); + } + _ => warn!("Failed to delete NEG {}/{}: {:?}", zone, name, e), + }, + } + } + + // --- Instance Management Cleanup Methods --- + + async fn cleanup_instance_template(&self, name: &str) { + info!("🧹 Cleaning up instance template: {}", name); + match self.client.delete_instance_template(name.to_string()).await { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_global_operation(op_name, 120).await; + } + info!("✅ Instance template {} deleted", name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Instance template {} was already deleted", name); + } + _ => warn!("Failed to delete instance template {}: {:?}", name, e), + }, + } + } + + async fn cleanup_instance_group_manager(&self, zone: &str, name: &str) { + info!("🧹 Cleaning up instance group manager: {}/{}", zone, name); + match self + .client + .delete_instance_group_manager(zone.to_string(), name.to_string()) + .await + { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_zone_operation(zone, op_name, 300).await; + } + info!("✅ Instance group manager {}/{} deleted", zone, name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!( + "🔍 Instance group manager {}/{} was already deleted", + zone, name + ); + } + _ => warn!( + "Failed to delete instance group manager {}/{}: {:?}", + zone, name, e + ), + }, + } + } + + // --- Disk Cleanup Methods --- + + async fn cleanup_disk(&self, zone: &str, name: &str) { + info!("🧹 Cleaning up disk: {}/{}", zone, name); + match self + .client + .delete_disk(zone.to_string(), name.to_string()) + .await + { + Ok(operation) => { + if let Some(op_name) = &operation.name { + let _ = self.wait_for_zone_operation(zone, op_name, 120).await; + } + info!("✅ Disk {}/{} deleted", zone, name); + } + Err(e) => match &e.error { + Some(ErrorData::RemoteResourceNotFound { .. }) => { + info!("🔍 Disk {}/{} was already deleted", zone, name); + } + _ => warn!("Failed to delete disk {}/{}: {:?}", zone, name, e), + }, + } + } + + // --- Helper methods --- + + pub(crate) fn generate_unique_name(&self, prefix: &str) -> String { + format!( + "alien-test-{}-{}", + prefix, + Uuid::new_v4().hyphenated().to_string().replace("-", "")[..8].to_lowercase() + ) + } + + pub(crate) fn extract_operation_name(&self, operation_name: &str) -> String { + operation_name + .split('/') + .last() + .unwrap_or(operation_name) + .to_string() + } + + pub(crate) async fn wait_for_global_operation( + &self, + operation_name: &str, + timeout_seconds: u64, + ) -> std::result::Result<(), Box> { + let op_name = self.extract_operation_name(operation_name); + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout_seconds); + + loop { + match self.client.get_global_operation(op_name.clone()).await { + Ok(operation) => { + if operation.is_done() { + if operation.has_error() { + return Err(format!( + "Operation {} failed: {:?}", + op_name, operation.error + ) + .into()); + } + info!("✅ Global operation {} completed!", op_name); + return Ok(()); + } + + if start_time.elapsed() > timeout_duration { + return Err(format!( + "Timeout waiting for global operation {} to complete", + op_name + ) + .into()); + } + + info!("⏳ Global operation {} still running...", op_name); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + Err(e) => { + if start_time.elapsed() > timeout_duration { + return Err(format!( + "Timeout waiting for global operation {} to complete (last error: {:?})", + op_name, e + ) + .into()); + } + warn!("Error checking global operation status: {:?}", e); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + } + } + + pub(crate) async fn wait_for_region_operation( + &self, + region: &str, + operation_name: &str, + timeout_seconds: u64, + ) -> std::result::Result<(), Box> { + let op_name = self.extract_operation_name(operation_name); + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout_seconds); + + loop { + if start_time.elapsed() > timeout_duration { + return Err(format!( + "Timeout waiting for region operation {} to complete", + op_name + ) + .into()); + } + + match self + .client + .get_region_operation(region.to_string(), op_name.clone()) + .await + { + Ok(operation) => { + if operation.is_done() { + if operation.has_error() { + return Err(format!( + "Operation {} failed: {:?}", + op_name, operation.error + ) + .into()); + } + info!("✅ Region operation {} completed!", op_name); + return Ok(()); + } + info!("⏳ Region operation {} still running...", op_name); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + Err(e) => { + warn!("Error checking region operation status: {:?}", e); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + } + } + + pub(crate) async fn wait_for_zone_operation( + &self, + zone: &str, + operation_name: &str, + timeout_seconds: u64, + ) -> std::result::Result<(), Box> { + let op_name = self.extract_operation_name(operation_name); + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout_seconds); + + loop { + if start_time.elapsed() > timeout_duration { + return Err( + format!("Timeout waiting for zone operation {} to complete", op_name).into(), + ); + } + + match self + .client + .get_zone_operation(zone.to_string(), op_name.clone()) + .await + { + Ok(operation) => { + if operation.is_done() { + if operation.has_error() { + return Err(format!( + "Operation {} failed: {:?}", + op_name, operation.error + ) + .into()); + } + info!("✅ Zone operation {} completed!", op_name); + return Ok(()); + } + info!("⏳ Zone operation {} still running...", op_name); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + Err(e) => { + warn!("Error checking zone operation status: {:?}", e); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + } + } + + pub(crate) async fn wait_for_stable_managed_instance( + &self, + zone: &str, + igm_name: &str, + expected_template_name: &str, + timeout_seconds: u64, + ) -> std::result::Result> { + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout_seconds); + + loop { + if start_time.elapsed() > timeout_duration { + return Err(format!( + "Timeout waiting for IGM {}/{} to reach a stable managed instance on template {}", + zone, igm_name, expected_template_name + ) + .into()); + } + + let igm = match self + .client + .get_instance_group_manager(zone.to_string(), igm_name.to_string()) + .await + { + Ok(igm) => igm, + Err(e) => { + warn!( + "Error checking instance group manager {}/{} status: {:?}", + zone, igm_name, e + ); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + continue; + } + }; + + let managed_instances = match self + .client + .list_managed_instances(zone.to_string(), igm_name.to_string()) + .await + { + Ok(instances) => instances, + Err(e) => { + warn!( + "Error listing managed instances for {}/{}: {:?}", + zone, igm_name, e + ); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + continue; + } + }; + + let is_stable = igm + .status + .as_ref() + .and_then(|status| status.is_stable) + .unwrap_or(false); + let version_reached = igm + .status + .as_ref() + .and_then(|status| status.version_target.as_ref()) + .and_then(|target| target.is_reached) + .unwrap_or(false); + + if is_stable && version_reached { + if let Some(instance) = + managed_instances.managed_instances.iter().find(|instance| { + matches!( + instance.instance_status, + Some(ManagedInstanceStatus::Running) + ) && matches!( + instance.current_action, + Some(ManagedInstanceCurrentAction::None) + ) && instance + .version + .as_ref() + .and_then(|version| version.instance_template.as_deref()) + .is_some_and(|template| template.contains(expected_template_name)) + }) + { + let instance_name = instance + .instance + .as_deref() + .and_then(|url| url.split('/').last()) + .unwrap_or("unknown"); + info!( + "✅ IGM {}/{} is stable with managed instance {} on template {}", + zone, igm_name, instance_name, expected_template_name + ); + return Ok(instance.clone()); + } + } + + let instance_summaries: Vec = managed_instances + .managed_instances + .iter() + .map(|instance| { + let instance_name = instance + .instance + .as_deref() + .and_then(|url| url.split('/').last()) + .unwrap_or("unknown"); + let template = instance + .version + .as_ref() + .and_then(|version| version.instance_template.as_deref()) + .unwrap_or("unknown-template"); + format!( + "{} status={:?} action={:?} template={}", + instance_name, instance.instance_status, instance.current_action, template + ) + }) + .collect(); + + info!( + "⏳ Waiting for IGM {}/{} to stabilize after rolling update (stable={}, version_reached={}, instances=[{}])", + zone, + igm_name, + is_stable, + version_reached, + instance_summaries.join("; ") + ); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + + pub(crate) fn create_invalid_client(&self) -> ComputeClient { + let invalid_config = GcpClientConfig { + project_id: "fake-project".to_string(), + region: self.region.clone(), + credentials: GcpCredentials::ServiceAccountKey { + json: r#"{"type":"service_account","project_id":"fake","private_key_id":"fake","private_key":"-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n","client_email":"fake@fake.iam.gserviceaccount.com","client_id":"fake","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token"}"#.to_string(), + }, + service_overrides: None, + project_number: None, + }; + ComputeClient::new(Client::new(), invalid_config) + } +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/disks.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/disks.rs new file mode 100644 index 000000000..1f0ebca63 --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/disks.rs @@ -0,0 +1,85 @@ +use crate::context::ComputeTestContext; +use alien_gcp_clients::compute::{ComputeApi, Disk}; +use test_context::test_context; + +// ============================================================================================= +// Comprehensive E2E Test - Persistent Disk +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_comprehensive_disk_lifecycle(ctx: &mut ComputeTestContext) { + println!("🚀 Starting comprehensive Persistent Disk lifecycle test"); + + let disk_name = ctx.generate_unique_name("disk"); + + // ========================================================================= + // Step 1: Create Disk + // ========================================================================= + println!("\n📦 Step 1: Creating disk: {}", disk_name); + + let disk = Disk::builder() + .name(disk_name.clone()) + .description("Alien test persistent disk".to_string()) + .size_gb("10".to_string()) + .r#type(format!( + "projects/{}/zones/{}/diskTypes/pd-standard", + ctx.project_id, ctx.zone + )) + .build(); + + let create_disk_op = ctx + .client + .insert_disk(ctx.zone.clone(), disk) + .await + .expect("Failed to create disk"); + + ctx.track_disk(&ctx.zone, &disk_name); + assert!( + create_disk_op.name.is_some(), + "Create disk operation should have a name" + ); + println!("✅ Disk creation initiated"); + + ctx.wait_for_zone_operation(&ctx.zone, create_disk_op.name.as_ref().unwrap(), 120) + .await + .expect("Disk creation timed out"); + + // Verify disk was created + let fetched_disk = ctx + .client + .get_disk(ctx.zone.clone(), disk_name.clone()) + .await + .expect("Failed to get disk"); + + assert_eq!(fetched_disk.name.as_ref().unwrap(), &disk_name); + assert_eq!(fetched_disk.size_gb, Some("10".to_string())); + println!("✅ Disk verified: {}", disk_name); + + // ========================================================================= + // Step 2: Delete Disk + // ========================================================================= + println!("\n🧹 Step 2: Deleting disk"); + + let delete_disk_op = ctx + .client + .delete_disk(ctx.zone.clone(), disk_name.clone()) + .await + .expect("Failed to delete disk"); + + ctx.wait_for_zone_operation(&ctx.zone, delete_disk_op.name.as_ref().unwrap(), 120) + .await + .expect("Disk deletion timed out"); + ctx.untrack_disk(&ctx.zone, &disk_name); + println!("✅ Disk deleted"); + + // Verify disk was deleted (should return 404) + let result = ctx + .client + .get_disk(ctx.zone.clone(), disk_name.clone()) + .await; + assert!(result.is_err(), "Disk should be deleted"); + + println!("\n🎉 Comprehensive Persistent Disk lifecycle test completed successfully!"); + println!(" - Disk created and deleted: ✅"); +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/errors.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/errors.rs new file mode 100644 index 000000000..9e8c8dd4c --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/errors.rs @@ -0,0 +1,240 @@ +use crate::context::{ComputeTestContext, NETWORK_DELETE_TIMEOUT_SECONDS}; +use alien_client_core::{Error, ErrorData}; +use alien_gcp_clients::compute::{ComputeApi, Network}; +use test_context::test_context; + +// ============================================================================================= +// Error Handling Tests +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_network_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-network-does-not-exist-12345"; + + let result = ctx.client.get_network(non_existent.to_string()).await; + assert!(result.is_err(), "Expected error for non-existent network"); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for network"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_subnetwork_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-subnet-does-not-exist-12345"; + + let result = ctx + .client + .get_subnetwork(ctx.region.clone(), non_existent.to_string()) + .await; + assert!( + result.is_err(), + "Expected error for non-existent subnetwork" + ); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for subnetwork"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_router_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-router-does-not-exist-12345"; + + let result = ctx + .client + .get_router(ctx.region.clone(), non_existent.to_string()) + .await; + assert!(result.is_err(), "Expected error for non-existent router"); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for router"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_firewall_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-firewall-does-not-exist-12345"; + + let result = ctx.client.get_firewall(non_existent.to_string()).await; + assert!(result.is_err(), "Expected error for non-existent firewall"); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for firewall"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_access_denied(ctx: &mut ComputeTestContext) { + let invalid_client = ctx.create_invalid_client(); + + let result = invalid_client.get_network("any-network".to_string()).await; + assert!(result.is_err(), "Expected error with invalid credentials"); + + let err = result.unwrap_err(); + match &err.error { + Some(ErrorData::RemoteAccessDenied { .. }) + | Some(ErrorData::HttpRequestFailed { .. }) + | Some(ErrorData::InvalidInput { .. }) => { + println!("✅ Got expected error type for invalid credentials"); + } + _ => println!("Got error (acceptable for invalid creds): {:?}", err), + } +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_delete_non_existent_network(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-network-does-not-exist-67890"; + + let result = ctx.client.delete_network(non_existent.to_string()).await; + assert!( + result.is_err(), + "Expected error when deleting non-existent network" + ); + + let err = result.unwrap_err(); + match err { + Error { + error: + Some(ErrorData::RemoteResourceNotFound { + ref resource_type, + ref resource_name, + }), + .. + } => { + assert_eq!(resource_type, "Compute Engine"); + assert_eq!(resource_name, non_existent); + println!("✅ Correctly mapped 404 to RemoteResourceNotFound for network deletion"); + } + _ => panic!( + "Expected RemoteResourceNotFound error for non-existent network deletion, got: {:?}", + err + ), + } +} + +// ============================================================================================= +// Operation Status Tests +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_wait_global_operation(ctx: &mut ComputeTestContext) { + // Create a simple network to get an operation + let network_name = ctx.generate_unique_name("op-test"); + + let network = Network::builder() + .name(network_name.clone()) + .description("Operation test network".to_string()) + .auto_create_subnetworks(false) + .build(); + + let create_op = ctx + .client + .insert_network(network) + .await + .expect("Failed to create network for operation test"); + + ctx.track_network(&network_name); + let op_name = create_op.name.as_ref().unwrap(); + + // Test wait_global_operation + println!("Testing wait_global_operation..."); + let wait_result = ctx + .client + .wait_global_operation(ctx.extract_operation_name(op_name)) + .await + .expect("Failed to wait for global operation"); + + assert!( + wait_result.is_done(), + "Operation should be done after waiting" + ); + println!("✅ wait_global_operation completed successfully"); + + // Clean up + ctx.delete_network_with_retry(&network_name, NETWORK_DELETE_TIMEOUT_SECONDS) + .await + .expect("Failed to delete network"); + ctx.untrack_network(&network_name); +} + +// ------------------------------------------------------------------------- +// Not Found Error Tests +// ------------------------------------------------------------------------- + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_health_check_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-hc-does-not-exist-12345"; + + let result = ctx.client.get_health_check(non_existent.to_string()).await; + assert!( + result.is_err(), + "Expected error for non-existent health check" + ); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for health check"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_backend_service_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-bs-does-not-exist-12345"; + + let result = ctx + .client + .get_backend_service(non_existent.to_string()) + .await; + assert!( + result.is_err(), + "Expected error for non-existent backend service" + ); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for backend service"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_disk_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-disk-does-not-exist-12345"; + + let result = ctx + .client + .get_disk(ctx.zone.clone(), non_existent.to_string()) + .await; + assert!(result.is_err(), "Expected error for non-existent disk"); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for disk"); +} + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_error_instance_template_not_found(ctx: &mut ComputeTestContext) { + let non_existent = "alien-test-template-does-not-exist-12345"; + + let result = ctx + .client + .get_instance_template(non_existent.to_string()) + .await; + assert!( + result.is_err(), + "Expected error for non-existent instance template" + ); + + let err = result.unwrap_err(); + assert_eq!(err.code, "REMOTE_RESOURCE_NOT_FOUND"); + println!("✅ Correctly mapped 404 to REMOTE_RESOURCE_NOT_FOUND for instance template"); +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/instances.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/instances.rs new file mode 100644 index 000000000..4ecf68102 --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/instances.rs @@ -0,0 +1,502 @@ +use crate::context::ComputeTestContext; +use alien_gcp_clients::compute::{ + AttachedDisk, AttachedDiskInitializeParams, AttachedDiskType, ComputeApi, Disk, DiskMode, + FixedOrPercent, InstanceGroupManager, InstanceGroupManagerUpdatePolicy, InstanceProperties, + InstanceTemplate, MinimalAction, NetworkInterface, ServiceAccount, UpdatePolicyType, +}; +use test_context::test_context; + +// ============================================================================================= +// Comprehensive E2E Test - Instance Management +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_comprehensive_instance_management_lifecycle(ctx: &mut ComputeTestContext) { + println!("🚀 Starting comprehensive Instance Management lifecycle test"); + + let template_name = ctx.generate_unique_name("template"); + let igm_name = ctx.generate_unique_name("igm"); + + // ========================================================================= + // Step 1: Create Instance Template + // ========================================================================= + println!("\n📦 Step 1: Creating instance template: {}", template_name); + + let instance_template = InstanceTemplate::builder() + .name(template_name.clone()) + .description("Alien test instance template".to_string()) + .properties( + InstanceProperties::builder() + .machine_type("e2-micro".to_string()) + .disks(vec![AttachedDisk::builder() + .r#type(AttachedDiskType::Persistent) + .boot(true) + .mode(DiskMode::ReadWrite) + .auto_delete(true) + .initialize_params( + AttachedDiskInitializeParams::builder() + .source_image( + "projects/debian-cloud/global/images/family/debian-11".to_string(), + ) + .disk_size_gb("10".to_string()) + .build(), + ) + .build()]) + .network_interfaces(vec![NetworkInterface::builder() + .network("global/networks/default".to_string()) + .build()]) + .service_accounts(vec![ServiceAccount::builder() + .email("default".to_string()) + .scopes(vec![ + "https://www.googleapis.com/auth/cloud-platform".to_string() + ]) + .build()]) + .build(), + ) + .build(); + + let create_template_op = ctx + .client + .insert_instance_template(instance_template) + .await + .expect("Failed to create instance template"); + + ctx.track_instance_template(&template_name); + assert!( + create_template_op.name.is_some(), + "Create template operation should have a name" + ); + println!("✅ Instance template creation initiated"); + + ctx.wait_for_global_operation(create_template_op.name.as_ref().unwrap(), 120) + .await + .expect("Instance template creation timed out"); + + // Verify template was created + let fetched_template = ctx + .client + .get_instance_template(template_name.clone()) + .await + .expect("Failed to get instance template"); + + assert_eq!(fetched_template.name.as_ref().unwrap(), &template_name); + println!("✅ Instance template verified: {}", template_name); + + // ========================================================================= + // Step 2: Create Instance Group Manager + // ========================================================================= + println!("\n📦 Step 2: Creating instance group manager: {}", igm_name); + + let template_url = format!( + "projects/{}/global/instanceTemplates/{}", + ctx.project_id, template_name + ); + + let igm = InstanceGroupManager::builder() + .name(igm_name.clone()) + .description("Alien test instance group manager".to_string()) + .instance_template(template_url) + .base_instance_name(format!("alien-test-{}", &igm_name[..8.min(igm_name.len())])) + .target_size(0) // Start with 0 instances + .update_policy( + InstanceGroupManagerUpdatePolicy::builder() + .r#type(UpdatePolicyType::Proactive) + .build(), + ) + .build(); + + let create_igm_op = ctx + .client + .insert_instance_group_manager(ctx.zone.clone(), igm) + .await + .expect("Failed to create instance group manager"); + + ctx.track_instance_group_manager(&ctx.zone, &igm_name); + assert!( + create_igm_op.name.is_some(), + "Create IGM operation should have a name" + ); + println!("✅ Instance group manager creation initiated"); + + ctx.wait_for_zone_operation(&ctx.zone, create_igm_op.name.as_ref().unwrap(), 180) + .await + .expect("Instance group manager creation timed out"); + + // Verify IGM was created + let fetched_igm = ctx + .client + .get_instance_group_manager(ctx.zone.clone(), igm_name.clone()) + .await + .expect("Failed to get instance group manager"); + + assert_eq!(fetched_igm.name.as_ref().unwrap(), &igm_name); + assert_eq!(fetched_igm.target_size, Some(0)); + println!("✅ Instance group manager verified: {}", igm_name); + + // ========================================================================= + // Step 3: Resize Instance Group Manager + // ========================================================================= + println!("\n📦 Step 3: Resizing instance group manager to 1 instance"); + + let resize_op = ctx + .client + .resize_instance_group_manager(ctx.zone.clone(), igm_name.clone(), 1) + .await + .expect("Failed to resize IGM"); + + assert!( + resize_op.name.is_some(), + "Resize operation should have a name" + ); + println!("✅ Resize operation initiated"); + + ctx.wait_for_zone_operation(&ctx.zone, resize_op.name.as_ref().unwrap(), 300) + .await + .expect("Resize operation timed out"); + + // Wait a bit for instances to be created + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + + // ========================================================================= + // Step 3.5: Create a second instance template for patch test + // ========================================================================= + println!("\n📦 Step 3.5: Creating second instance template for patch test"); + + let template_v2_name = ctx.generate_unique_name("template-v2"); + + let instance_template_v2 = InstanceTemplate::builder() + .name(template_v2_name.clone()) + .description( + "Alien test instance template v2 (for patch_instance_group_manager)".to_string(), + ) + .properties( + InstanceProperties::builder() + .machine_type("e2-micro".to_string()) + .disks(vec![AttachedDisk::builder() + .r#type(AttachedDiskType::Persistent) + .boot(true) + .mode(DiskMode::ReadWrite) + .auto_delete(true) + .initialize_params( + AttachedDiskInitializeParams::builder() + .source_image( + "projects/debian-cloud/global/images/family/debian-11".to_string(), + ) + .disk_size_gb("10".to_string()) + .build(), + ) + .build()]) + .network_interfaces(vec![NetworkInterface::builder() + .network("global/networks/default".to_string()) + .build()]) + .service_accounts(vec![ServiceAccount::builder() + .email("default".to_string()) + .scopes(vec![ + "https://www.googleapis.com/auth/cloud-platform".to_string() + ]) + .build()]) + .build(), + ) + .build(); + + let create_template_v2_op = ctx + .client + .insert_instance_template(instance_template_v2) + .await + .expect("Failed to create second instance template"); + + ctx.track_instance_template(&template_v2_name); + ctx.wait_for_global_operation(create_template_v2_op.name.as_ref().unwrap(), 120) + .await + .expect("Second instance template creation timed out"); + println!("✅ Second instance template created: {}", template_v2_name); + + // ========================================================================= + // Step 3.6: Patch the IGM to use the new template with PROACTIVE update policy + // ========================================================================= + println!("\n📦 Step 3.6: Patching IGM to use new template with PROACTIVE rolling update"); + + let template_v2_url = format!( + "https://compute.googleapis.com/compute/v1/projects/{}/global/instanceTemplates/{}", + ctx.project_id, template_v2_name + ); + + let igm_patch = InstanceGroupManager::builder() + .instance_template(template_v2_url.clone()) + .update_policy(InstanceGroupManagerUpdatePolicy { + r#type: Some(UpdatePolicyType::Proactive), + minimal_action: Some(MinimalAction::Replace), + most_disruptive_allowed_action: None, + // maxSurge: 1 — create 1 extra VM before terminating old (works with target_size=1) + max_surge: Some(FixedOrPercent { + fixed: Some(1), + percent: None, + calculated: None, + }), + // maxUnavailable: 0 — never reduce capacity below target_size + max_unavailable: Some(FixedOrPercent { + fixed: Some(0), + percent: None, + calculated: None, + }), + replacement_method: None, + }) + .build(); + + let patch_op = ctx + .client + .patch_instance_group_manager(ctx.zone.clone(), igm_name.clone(), igm_patch) + .await + .expect("Failed to patch instance group manager"); + + assert!( + patch_op.name.is_some(), + "Patch operation should have a name" + ); + println!("✅ Patch operation initiated: {:?}", patch_op.name); + + ctx.wait_for_zone_operation(&ctx.zone, patch_op.name.as_ref().unwrap(), 120) + .await + .expect("Patch operation timed out"); + + // ========================================================================= + // Step 3.7: Verify the IGM now references the new template + // ========================================================================= + println!("\n📦 Step 3.7: Verifying IGM references new template after patch"); + + let patched_igm = ctx + .client + .get_instance_group_manager(ctx.zone.clone(), igm_name.clone()) + .await + .expect("Failed to get patched IGM"); + + let current_template = patched_igm.instance_template.as_deref().unwrap_or(""); + assert!( + current_template.contains(&template_v2_name), + "IGM should now reference the new template '{}', but has '{}'", + template_v2_name, + current_template + ); + println!( + "✅ IGM correctly references new template: {}", + template_v2_name + ); + + let stable_managed_instance = ctx + .wait_for_stable_managed_instance(&ctx.zone, &igm_name, &template_v2_name, 300) + .await + .expect("Managed instance group never converged on the patched template"); + + // ========================================================================= + // Step 4: List Managed Instances + // ========================================================================= + println!("\n📦 Step 4: Listing managed instances"); + + let managed_instances = ctx + .client + .list_managed_instances(ctx.zone.clone(), igm_name.clone()) + .await + .expect("Failed to list managed instances"); + + println!( + " Found {} managed instances", + managed_instances.managed_instances.len() + ); + for mi in &managed_instances.managed_instances { + if let Some(instance_url) = &mi.instance { + let instance_name = instance_url.split('/').last().unwrap_or("unknown"); + println!( + " - Instance: {}, Status: {:?}", + instance_name, mi.instance_status + ); + } + } + println!("✅ Managed instances listed"); + + // ========================================================================= + // Step 4.1: Get serial port output from the managed instance + // ========================================================================= + println!("\n📦 Step 4.1: Reading serial port output from first instance"); + + let stable_instance_url = stable_managed_instance + .instance + .as_ref() + .expect("Stable managed instance should have an instance URL"); + let stable_instance_name = stable_instance_url + .split('/') + .last() + .unwrap_or("unknown") + .to_string(); + + // Retry because the instance may not be ready immediately after creation or replacement. + let mut serial_output = None; + for attempt in 1..=12 { + match ctx + .client + .get_serial_port_output(ctx.zone.clone(), stable_instance_name.clone()) + .await + { + Ok(output) => { + serial_output = Some(output); + break; + } + Err(e) => { + let msg = format!("{:?}", e); + if msg.contains("resourceNotReady") || msg.contains("not ready") { + println!( + " Instance not ready for serial port (attempt {}/12), waiting 10s...", + attempt + ); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } else { + panic!("Failed to get serial port output: {:?}", e); + } + } + } + } + + let serial_output = + serial_output.expect("Instance never became ready for serial port output after 120s"); + println!( + " Serial port output length: {} bytes", + serial_output.contents.as_deref().unwrap_or("").len() + ); + assert!( + serial_output.contents.is_some(), + "Serial port output should have a contents field" + ); + println!( + "✅ Serial port output retrieved successfully from {}", + stable_instance_name + ); + + // ========================================================================= + // Step 4.5: Attach and detach a persistent disk to a managed instance + // ========================================================================= + println!("\n📦 Step 4.5: Attaching and detaching a disk to a managed instance"); + + let instance_name = stable_instance_name.clone(); + + let disk_name = ctx.generate_unique_name("attach-disk"); + let device_name = format!("dev-{}", &disk_name[..8.min(disk_name.len())]); + let disk = Disk::builder() + .name(disk_name.clone()) + .description("Alien test attached disk".to_string()) + .size_gb("10".to_string()) + .r#type(format!( + "projects/{}/zones/{}/diskTypes/pd-standard", + ctx.project_id, ctx.zone + )) + .build(); + + let create_disk_op = ctx + .client + .insert_disk(ctx.zone.clone(), disk) + .await + .expect("Failed to create disk for attachment"); + ctx.track_disk(&ctx.zone, &disk_name); + + ctx.wait_for_zone_operation(&ctx.zone, create_disk_op.name.as_ref().unwrap(), 120) + .await + .expect("Disk creation timed out"); + + let attached_disk = AttachedDisk::builder() + .r#type(AttachedDiskType::Persistent) + .mode(DiskMode::ReadWrite) + .source(format!( + "projects/{}/zones/{}/disks/{}", + ctx.project_id, ctx.zone, disk_name + )) + .device_name(device_name.clone()) + .auto_delete(false) + .build(); + + let attach_op = ctx + .client + .attach_disk(ctx.zone.clone(), instance_name.clone(), attached_disk) + .await + .expect("Failed to attach disk"); + + ctx.wait_for_zone_operation(&ctx.zone, attach_op.name.as_ref().unwrap(), 300) + .await + .expect("Disk attach operation failed or timed out"); + println!("✅ Disk attached to instance {}", instance_name); + + let detach_op = ctx + .client + .detach_disk(ctx.zone.clone(), instance_name.clone(), device_name.clone()) + .await + .expect("Failed to detach disk"); + + ctx.wait_for_zone_operation(&ctx.zone, detach_op.name.as_ref().unwrap(), 300) + .await + .expect("Disk detach operation failed or timed out"); + println!("✅ Disk detached from instance {}", instance_name); + + let delete_disk_op = ctx + .client + .delete_disk(ctx.zone.clone(), disk_name.clone()) + .await + .expect("Failed to delete attached disk"); + ctx.wait_for_zone_operation(&ctx.zone, delete_disk_op.name.as_ref().unwrap(), 120) + .await + .expect("Disk deletion failed or timed out"); + ctx.untrack_disk(&ctx.zone, &disk_name); + println!("✅ Attached disk deleted"); + + // ========================================================================= + // Step 5: Resize back to 0 before cleanup + // ========================================================================= + println!("\n📦 Step 5: Resizing instance group manager back to 0"); + + let resize_down_op = ctx + .client + .resize_instance_group_manager(ctx.zone.clone(), igm_name.clone(), 0) + .await + .expect("Failed to resize IGM down"); + + ctx.wait_for_zone_operation(&ctx.zone, resize_down_op.name.as_ref().unwrap(), 300) + .await + .expect("Resize down operation timed out"); + println!("✅ Instance group manager resized to 0"); + + // Wait for instances to be deleted + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + + // ========================================================================= + // Step 6: Cleanup + // ========================================================================= + println!("\n🧹 Step 6: Cleaning up instance management resources"); + + // Delete IGM + let delete_igm_op = ctx + .client + .delete_instance_group_manager(ctx.zone.clone(), igm_name.clone()) + .await + .expect("Failed to delete IGM"); + + ctx.wait_for_zone_operation(&ctx.zone, delete_igm_op.name.as_ref().unwrap(), 300) + .await + .expect("IGM deletion timed out"); + ctx.untrack_instance_group_manager(&ctx.zone, &igm_name); + println!(" ✅ Instance group manager deleted"); + + // Delete instance template + let delete_template_op = ctx + .client + .delete_instance_template(template_name.clone()) + .await + .expect("Failed to delete instance template"); + + ctx.wait_for_global_operation(delete_template_op.name.as_ref().unwrap(), 120) + .await + .expect("Instance template deletion timed out"); + ctx.untrack_instance_template(&template_name); + println!(" ✅ Instance template deleted"); + + println!("\n🎉 Comprehensive Instance Management lifecycle test completed successfully!"); + println!(" - Instance template created and deleted: ✅"); + println!(" - Instance group manager created, resized, and deleted: ✅"); + println!(" - Managed instances listed: ✅"); +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/load_balancing.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/load_balancing.rs new file mode 100644 index 000000000..c02f1320c --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/load_balancing.rs @@ -0,0 +1,561 @@ +use crate::context::{ComputeTestContext, NETWORK_DELETE_TIMEOUT_SECONDS}; +use alien_gcp_clients::compute::{ + Address, Backend, BackendService, BackendServiceProtocol, BalancingMode, ComputeApi, + ForwardingRule, ForwardingRuleProtocol, HealthCheck, HealthCheckType, HttpHealthCheck, + LoadBalancingScheme, Network, NetworkEndpointGroup, NetworkEndpointType, Subnetwork, + TargetHttpProxy, UrlMap, +}; +use test_context::test_context; + +// ============================================================================================= +// Comprehensive E2E Test - Load Balancing +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_comprehensive_load_balancing_lifecycle(ctx: &mut ComputeTestContext) { + println!("🚀 Starting comprehensive Load Balancing lifecycle test"); + + // Generate unique names for all resources + let network_name = ctx.generate_unique_name("lb-vpc"); + let subnetwork_name = ctx.generate_unique_name("lb-subnet"); + let health_check_name = ctx.generate_unique_name("hc"); + let neg_name = ctx.generate_unique_name("neg"); + let backend_service_name = ctx.generate_unique_name("bs"); + let url_map_name = ctx.generate_unique_name("urlmap"); + let proxy_name = ctx.generate_unique_name("proxy"); + let address_name = ctx.generate_unique_name("addr"); + let forwarding_rule_name = ctx.generate_unique_name("fwd"); + + // ========================================================================= + // Step 1: Create VPC Network and Subnetwork (prerequisites) + // ========================================================================= + println!("\n📦 Step 1: Creating VPC network and subnetwork for load balancing"); + + let network = Network::builder() + .name(network_name.clone()) + .description("Alien test VPC for load balancing".to_string()) + .auto_create_subnetworks(false) + .build(); + + let create_network_op = ctx + .client + .insert_network(network) + .await + .expect("Failed to create network"); + + ctx.track_network(&network_name); + ctx.wait_for_global_operation(create_network_op.name.as_ref().unwrap(), 120) + .await + .expect("Network creation timed out"); + println!("✅ Network created: {}", network_name); + + let network_url = format!( + "projects/{}/global/networks/{}", + ctx.project_id, network_name + ); + + let subnetwork = Subnetwork::builder() + .name(subnetwork_name.clone()) + .network(network_url.clone()) + .ip_cidr_range("10.0.0.0/24".to_string()) + .build(); + + let create_subnet_op = ctx + .client + .insert_subnetwork(ctx.region.clone(), subnetwork) + .await + .expect("Failed to create subnetwork"); + + ctx.track_subnetwork(&ctx.region, &subnetwork_name); + ctx.wait_for_region_operation(&ctx.region, create_subnet_op.name.as_ref().unwrap(), 120) + .await + .expect("Subnetwork creation timed out"); + println!("✅ Subnetwork created: {}", subnetwork_name); + + // ========================================================================= + // Step 2: Create Health Check + // ========================================================================= + println!("\n📦 Step 2: Creating health check: {}", health_check_name); + + let health_check = HealthCheck::builder() + .name(health_check_name.clone()) + .description("Alien test health check".to_string()) + .r#type(HealthCheckType::Http) + .check_interval_sec(10) + .timeout_sec(5) + .healthy_threshold(2) + .unhealthy_threshold(3) + .http_health_check( + HttpHealthCheck::builder() + .port(80) + .request_path("/health".to_string()) + .build(), + ) + .build(); + + let create_hc_op = ctx + .client + .insert_health_check(health_check) + .await + .expect("Failed to create health check"); + + ctx.track_health_check(&health_check_name); + assert!( + create_hc_op.name.is_some(), + "Create health check operation should have a name" + ); + println!("✅ Health check creation initiated"); + + ctx.wait_for_global_operation(create_hc_op.name.as_ref().unwrap(), 120) + .await + .expect("Health check creation timed out"); + + // Verify health check was created + let fetched_hc = ctx + .client + .get_health_check(health_check_name.clone()) + .await + .expect("Failed to get health check"); + + assert_eq!(fetched_hc.name.as_ref().unwrap(), &health_check_name); + assert_eq!(fetched_hc.r#type, Some(HealthCheckType::Http)); + println!("✅ Health check verified: {}", health_check_name); + + // ========================================================================= + // Step 3: Create Network Endpoint Group (NEG) + // ========================================================================= + println!("\n📦 Step 3: Creating NEG: {}", neg_name); + + let subnetwork_url = format!( + "projects/{}/regions/{}/subnetworks/{}", + ctx.project_id, ctx.region, subnetwork_name + ); + + let neg = NetworkEndpointGroup::builder() + .name(neg_name.clone()) + .description("Alien test NEG".to_string()) + .network_endpoint_type(NetworkEndpointType::GceVmIpPort) + .network(network_url.clone()) + .subnetwork(subnetwork_url) + .default_port(80) + .build(); + + let create_neg_op = ctx + .client + .insert_network_endpoint_group(ctx.zone.clone(), neg) + .await + .expect("Failed to create NEG"); + + ctx.track_neg(&ctx.zone, &neg_name); + assert!( + create_neg_op.name.is_some(), + "Create NEG operation should have a name" + ); + println!("✅ NEG creation initiated"); + + ctx.wait_for_zone_operation(&ctx.zone, create_neg_op.name.as_ref().unwrap(), 120) + .await + .expect("NEG creation timed out"); + + // Verify NEG was created + let fetched_neg = ctx + .client + .get_network_endpoint_group(ctx.zone.clone(), neg_name.clone()) + .await + .expect("Failed to get NEG"); + + assert_eq!(fetched_neg.name.as_ref().unwrap(), &neg_name); + assert_eq!( + fetched_neg.network_endpoint_type, + Some(NetworkEndpointType::GceVmIpPort) + ); + println!("✅ NEG verified: {}", neg_name); + + // ========================================================================= + // Step 4: Create Backend Service + // ========================================================================= + println!( + "\n📦 Step 4: Creating backend service: {}", + backend_service_name + ); + + let health_check_url = format!( + "projects/{}/global/healthChecks/{}", + ctx.project_id, health_check_name + ); + + let neg_url = format!( + "projects/{}/zones/{}/networkEndpointGroups/{}", + ctx.project_id, ctx.zone, neg_name + ); + + let backend_service = BackendService::builder() + .name(backend_service_name.clone()) + .description("Alien test backend service".to_string()) + .protocol(BackendServiceProtocol::Http) + .port_name("http".to_string()) + .timeout_sec(30) + .health_checks(vec![health_check_url]) + .load_balancing_scheme(LoadBalancingScheme::External) + .backends(vec![Backend::builder() + .group(neg_url) + .balancing_mode(BalancingMode::Rate) + .max_rate_per_endpoint(100.0) + .build()]) + .build(); + + let create_bs_op = ctx + .client + .insert_backend_service(backend_service) + .await + .expect("Failed to create backend service"); + + ctx.track_backend_service(&backend_service_name); + assert!( + create_bs_op.name.is_some(), + "Create backend service operation should have a name" + ); + println!("✅ Backend service creation initiated"); + + ctx.wait_for_global_operation(create_bs_op.name.as_ref().unwrap(), 120) + .await + .expect("Backend service creation timed out"); + + // Verify backend service was created + let fetched_bs = ctx + .client + .get_backend_service(backend_service_name.clone()) + .await + .expect("Failed to get backend service"); + + assert_eq!(fetched_bs.name.as_ref().unwrap(), &backend_service_name); + assert_eq!(fetched_bs.protocol, Some(BackendServiceProtocol::Http)); + println!("✅ Backend service verified: {}", backend_service_name); + + // ========================================================================= + // Step 5: Create URL Map + // ========================================================================= + println!("\n📦 Step 5: Creating URL map: {}", url_map_name); + + let backend_service_url = format!( + "projects/{}/global/backendServices/{}", + ctx.project_id, backend_service_name + ); + + let url_map = UrlMap::builder() + .name(url_map_name.clone()) + .description("Alien test URL map".to_string()) + .default_service(backend_service_url) + .build(); + + let create_um_op = ctx + .client + .insert_url_map(url_map) + .await + .expect("Failed to create URL map"); + + ctx.track_url_map(&url_map_name); + assert!( + create_um_op.name.is_some(), + "Create URL map operation should have a name" + ); + println!("✅ URL map creation initiated"); + + ctx.wait_for_global_operation(create_um_op.name.as_ref().unwrap(), 120) + .await + .expect("URL map creation timed out"); + + // Verify URL map was created + let fetched_um = ctx + .client + .get_url_map(url_map_name.clone()) + .await + .expect("Failed to get URL map"); + + assert_eq!(fetched_um.name.as_ref().unwrap(), &url_map_name); + println!("✅ URL map verified: {}", url_map_name); + + // ========================================================================= + // Step 6: Create Target HTTP Proxy + // ========================================================================= + println!("\n📦 Step 6: Creating target HTTP proxy: {}", proxy_name); + + let url_map_url = format!( + "projects/{}/global/urlMaps/{}", + ctx.project_id, url_map_name + ); + + let target_http_proxy = TargetHttpProxy::builder() + .name(proxy_name.clone()) + .description("Alien test target HTTP proxy".to_string()) + .url_map(url_map_url) + .build(); + + let create_proxy_op = ctx + .client + .insert_target_http_proxy(target_http_proxy) + .await + .expect("Failed to create target HTTP proxy"); + + ctx.track_target_http_proxy(&proxy_name); + assert!( + create_proxy_op.name.is_some(), + "Create proxy operation should have a name" + ); + println!("✅ Target HTTP proxy creation initiated"); + + ctx.wait_for_global_operation(create_proxy_op.name.as_ref().unwrap(), 120) + .await + .expect("Target HTTP proxy creation timed out"); + + // Verify proxy was created + let fetched_proxy = ctx + .client + .get_target_http_proxy(proxy_name.clone()) + .await + .expect("Failed to get target HTTP proxy"); + + assert_eq!(fetched_proxy.name.as_ref().unwrap(), &proxy_name); + println!("✅ Target HTTP proxy verified: {}", proxy_name); + + // ========================================================================= + // Step 7: Create Global Address + // ========================================================================= + println!("\n📦 Step 7: Creating global address: {}", address_name); + + let address = Address::builder() + .name(address_name.clone()) + .description("Alien test global address".to_string()) + .build(); + + let create_addr_op = ctx + .client + .insert_global_address(address) + .await + .expect("Failed to create global address"); + + ctx.track_global_address(&address_name); + assert!( + create_addr_op.name.is_some(), + "Create address operation should have a name" + ); + println!("✅ Global address creation initiated"); + + ctx.wait_for_global_operation(create_addr_op.name.as_ref().unwrap(), 120) + .await + .expect("Global address creation timed out"); + + // Verify address was created + let fetched_addr = ctx + .client + .get_global_address(address_name.clone()) + .await + .expect("Failed to get global address"); + + assert_eq!(fetched_addr.name.as_ref().unwrap(), &address_name); + assert!(fetched_addr.address.is_some(), "Address should have an IP"); + println!( + "✅ Global address verified: {} (IP: {})", + address_name, + fetched_addr.address.as_ref().unwrap() + ); + + // ========================================================================= + // Step 8: Create Global Forwarding Rule + // ========================================================================= + println!( + "\n📦 Step 8: Creating global forwarding rule: {}", + forwarding_rule_name + ); + + let proxy_url = format!( + "projects/{}/global/targetHttpProxies/{}", + ctx.project_id, proxy_name + ); + + let forwarding_rule = ForwardingRule::builder() + .name(forwarding_rule_name.clone()) + .description("Alien test forwarding rule".to_string()) + .ip_address(fetched_addr.address.clone().unwrap()) + .ip_protocol(ForwardingRuleProtocol::Tcp) + .port_range("80-80".to_string()) + .target(proxy_url) + .load_balancing_scheme(LoadBalancingScheme::External) + .build(); + + let create_fwd_op = ctx + .client + .insert_global_forwarding_rule(forwarding_rule) + .await + .expect("Failed to create forwarding rule"); + + ctx.track_global_forwarding_rule(&forwarding_rule_name); + assert!( + create_fwd_op.name.is_some(), + "Create forwarding rule operation should have a name" + ); + println!("✅ Global forwarding rule creation initiated"); + + ctx.wait_for_global_operation(create_fwd_op.name.as_ref().unwrap(), 120) + .await + .expect("Global forwarding rule creation timed out"); + + // Verify forwarding rule was created + let fetched_fwd = ctx + .client + .get_global_forwarding_rule(forwarding_rule_name.clone()) + .await + .expect("Failed to get forwarding rule"); + + assert_eq!(fetched_fwd.name.as_ref().unwrap(), &forwarding_rule_name); + println!( + "✅ Global forwarding rule verified: {}", + forwarding_rule_name + ); + + // ========================================================================= + // Step 9: Test patch backend service + // ========================================================================= + println!("\n📦 Step 9: Patching backend service"); + + let patched_bs = BackendService::builder() + .timeout_sec(60) // Changed from 30 to 60 + .build(); + + let patch_bs_op = ctx + .client + .patch_backend_service(backend_service_name.clone(), patched_bs) + .await + .expect("Failed to patch backend service"); + + ctx.wait_for_global_operation(patch_bs_op.name.as_ref().unwrap(), 120) + .await + .expect("Backend service patch timed out"); + + let updated_bs = ctx + .client + .get_backend_service(backend_service_name.clone()) + .await + .expect("Failed to get updated backend service"); + + assert_eq!(updated_bs.timeout_sec, Some(60)); + println!("✅ Backend service patched successfully"); + + // ========================================================================= + // Step 10: Cleanup in reverse dependency order + // ========================================================================= + println!("\n🧹 Step 10: Cleaning up load balancing resources"); + + // Delete forwarding rule + let delete_fwd_op = ctx + .client + .delete_global_forwarding_rule(forwarding_rule_name.clone()) + .await + .expect("Failed to delete forwarding rule"); + ctx.wait_for_global_operation(delete_fwd_op.name.as_ref().unwrap(), 120) + .await + .expect("Forwarding rule deletion timed out"); + ctx.untrack_global_forwarding_rule(&forwarding_rule_name); + println!(" ✅ Forwarding rule deleted"); + + // Delete target HTTP proxy + let delete_proxy_op = ctx + .client + .delete_target_http_proxy(proxy_name.clone()) + .await + .expect("Failed to delete proxy"); + ctx.wait_for_global_operation(delete_proxy_op.name.as_ref().unwrap(), 120) + .await + .expect("Proxy deletion timed out"); + ctx.untrack_target_http_proxy(&proxy_name); + println!(" ✅ Target HTTP proxy deleted"); + + // Delete URL map + let delete_um_op = ctx + .client + .delete_url_map(url_map_name.clone()) + .await + .expect("Failed to delete URL map"); + ctx.wait_for_global_operation(delete_um_op.name.as_ref().unwrap(), 120) + .await + .expect("URL map deletion timed out"); + ctx.untrack_url_map(&url_map_name); + println!(" ✅ URL map deleted"); + + // Delete backend service + let delete_bs_op = ctx + .client + .delete_backend_service(backend_service_name.clone()) + .await + .expect("Failed to delete backend service"); + ctx.wait_for_global_operation(delete_bs_op.name.as_ref().unwrap(), 120) + .await + .expect("Backend service deletion timed out"); + ctx.untrack_backend_service(&backend_service_name); + println!(" ✅ Backend service deleted"); + + // Delete NEG + let delete_neg_op = ctx + .client + .delete_network_endpoint_group(ctx.zone.clone(), neg_name.clone()) + .await + .expect("Failed to delete NEG"); + ctx.wait_for_zone_operation(&ctx.zone, delete_neg_op.name.as_ref().unwrap(), 120) + .await + .expect("NEG deletion timed out"); + ctx.untrack_neg(&ctx.zone, &neg_name); + println!(" ✅ NEG deleted"); + + // Delete health check + let delete_hc_op = ctx + .client + .delete_health_check(health_check_name.clone()) + .await + .expect("Failed to delete health check"); + ctx.wait_for_global_operation(delete_hc_op.name.as_ref().unwrap(), 120) + .await + .expect("Health check deletion timed out"); + ctx.untrack_health_check(&health_check_name); + println!(" ✅ Health check deleted"); + + // Delete global address + let delete_addr_op = ctx + .client + .delete_global_address(address_name.clone()) + .await + .expect("Failed to delete address"); + ctx.wait_for_global_operation(delete_addr_op.name.as_ref().unwrap(), 120) + .await + .expect("Address deletion timed out"); + ctx.untrack_global_address(&address_name); + println!(" ✅ Global address deleted"); + + // Delete subnetwork + let delete_subnet_op = ctx + .client + .delete_subnetwork(ctx.region.clone(), subnetwork_name.clone()) + .await + .expect("Failed to delete subnetwork"); + ctx.wait_for_region_operation(&ctx.region, delete_subnet_op.name.as_ref().unwrap(), 120) + .await + .expect("Subnetwork deletion timed out"); + ctx.untrack_subnetwork(&ctx.region, &subnetwork_name); + println!(" ✅ Subnetwork deleted"); + + // Delete network + ctx.delete_network_with_retry(&network_name, NETWORK_DELETE_TIMEOUT_SECONDS) + .await + .expect("Failed to delete network"); + ctx.untrack_network(&network_name); + println!(" ✅ Network deleted"); + + println!("\n🎉 Comprehensive Load Balancing lifecycle test completed successfully!"); + println!(" - Health check created and deleted: ✅"); + println!(" - Network Endpoint Group created and deleted: ✅"); + println!(" - Backend service created, patched, and deleted: ✅"); + println!(" - URL map created and deleted: ✅"); + println!(" - Target HTTP proxy created and deleted: ✅"); + println!(" - Global address created and deleted: ✅"); + println!(" - Global forwarding rule created and deleted: ✅"); +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/main.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/main.rs new file mode 100644 index 000000000..975cdf8d7 --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/main.rs @@ -0,0 +1,32 @@ +//! Comprehensive E2E tests for the GCP Compute Engine client. +//! +//! These tests create real VPC resources in GCP and verify all operations work correctly. +//! Since VPC resources are expensive to set up and take time, we use a single comprehensive +//! test that exercises all APIs in sequence. + +mod context; +mod disks; +mod errors; +mod instances; +mod load_balancing; +mod ssl_proxy; +mod vpc; + +use crate::context::ComputeTestContext; +use test_context::test_context; + +// ============================================================================================= +// Basic Framework Test +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_framework_setup_compute(ctx: &mut ComputeTestContext) { + assert!(!ctx.project_id.is_empty(), "Project ID should not be empty"); + assert!(!ctx.region.is_empty(), "Region should not be empty"); + + println!( + "Successfully connected to Compute Engine in project: {} region: {}", + ctx.project_id, ctx.region + ); +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/ssl_proxy.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/ssl_proxy.rs new file mode 100644 index 000000000..4175b2aae --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/ssl_proxy.rs @@ -0,0 +1,277 @@ +use crate::context::ComputeTestContext; +use alien_gcp_clients::compute::{ + BackendService, BackendServiceProtocol, ComputeApi, HealthCheck, HealthCheckType, + HttpHealthCheck, SslCertificate, SslCertificateSelfManaged, TargetHttpsProxy, UrlMap, +}; +use rcgen::{CertificateParams, DistinguishedName, DnType}; +use test_context::test_context; + +// ------------------------------------------------------------------------- +// HTTPS Load Balancing Tests (SSL Certificates + Target HTTPS Proxies) +// ------------------------------------------------------------------------- + +/// This test covers the full lifecycle of SSL certificates and HTTPS proxies: +/// 1. Create an SSL certificate (self-managed) +/// 2. Verify the certificate was created +/// 3. Create a Target HTTPS proxy referencing the certificate +/// 4. Verify the HTTPS proxy was created +/// 5. Delete the HTTPS proxy +/// 6. Delete the SSL certificate +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_comprehensive_ssl_https_proxy_lifecycle(ctx: &mut ComputeTestContext) { + println!("🚀 Starting comprehensive SSL certificate and HTTPS proxy lifecycle test"); + + // Generate unique names + let ssl_cert_name = ctx.generate_unique_name("ssl-cert"); + let https_proxy_name = ctx.generate_unique_name("https-proxy"); + let url_map_name = ctx.generate_unique_name("urlmap-for-https"); + let backend_service_name = ctx.generate_unique_name("bs-for-https"); + let health_check_name = ctx.generate_unique_name("hc-for-https"); + + // ========================================================================= + // Step 1: Create prerequisites (health check, backend service, URL map) + // ========================================================================= + println!("\n📦 Step 1: Creating prerequisites for HTTPS proxy"); + + // Create health check + let health_check = HealthCheck::builder() + .name(health_check_name.clone()) + .r#type(HealthCheckType::Http) + .check_interval_sec(10) + .timeout_sec(5) + .http_health_check(HttpHealthCheck::builder().port(80).build()) + .build(); + + let create_hc_op = ctx + .client + .insert_health_check(health_check) + .await + .expect("Failed to create health check"); + ctx.track_health_check(&health_check_name); + ctx.wait_for_global_operation(create_hc_op.name.as_ref().unwrap(), 120) + .await + .expect("Health check creation timed out"); + + let health_check_url = format!( + "projects/{}/global/healthChecks/{}", + ctx.project_id, health_check_name + ); + + // Create backend service + let backend_service = BackendService::builder() + .name(backend_service_name.clone()) + .protocol(BackendServiceProtocol::Http) + .health_checks(vec![health_check_url]) + .build(); + + let create_bs_op = ctx + .client + .insert_backend_service(backend_service) + .await + .expect("Failed to create backend service"); + ctx.track_backend_service(&backend_service_name); + ctx.wait_for_global_operation(create_bs_op.name.as_ref().unwrap(), 120) + .await + .expect("Backend service creation timed out"); + + let backend_service_url = format!( + "projects/{}/global/backendServices/{}", + ctx.project_id, backend_service_name + ); + + // Create URL map + let url_map = UrlMap::builder() + .name(url_map_name.clone()) + .default_service(backend_service_url) + .build(); + + let create_urlmap_op = ctx + .client + .insert_url_map(url_map) + .await + .expect("Failed to create URL map"); + ctx.track_url_map(&url_map_name); + ctx.wait_for_global_operation(create_urlmap_op.name.as_ref().unwrap(), 120) + .await + .expect("URL map creation timed out"); + + let url_map_url = format!( + "projects/{}/global/urlMaps/{}", + ctx.project_id, url_map_name + ); + + println!("✅ Prerequisites created"); + + // ========================================================================= + // Step 2: Create SSL Certificate + // ========================================================================= + println!("\n📦 Step 2: Creating SSL certificate: {}", ssl_cert_name); + + // Generate a valid self-signed certificate with CN and SAN using rcgen + let mut params = CertificateParams::new(vec!["example.com".to_string()]) + .expect("Failed to create certificate params"); + + // Set distinguished name with Common Name + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "example.com"); + dn.push(DnType::OrganizationName, "Alien Test"); + dn.push(DnType::CountryName, "US"); + params.distinguished_name = dn; + + // Add Subject Alternative Names (required by GCP) + params.subject_alt_names = vec![ + rcgen::SanType::DnsName(rcgen::Ia5String::try_from("example.com").unwrap()), + rcgen::SanType::DnsName(rcgen::Ia5String::try_from("*.example.com").unwrap()), + ]; + + let key_pair = rcgen::KeyPair::generate().expect("Failed to generate key pair"); + let cert = params + .self_signed(&key_pair) + .expect("Failed to generate certificate"); + + let certificate_pem = cert.pem(); + let private_key_pem = key_pair.serialize_pem(); + + let ssl_certificate = SslCertificate::builder() + .name(ssl_cert_name.clone()) + .description("Alien test SSL certificate".to_string()) + .r#type("SELF_MANAGED".to_string()) + .self_managed( + SslCertificateSelfManaged::builder() + .certificate(certificate_pem.to_string()) + .private_key(private_key_pem.to_string()) + .build(), + ) + .build(); + + let create_ssl_op = ctx + .client + .insert_ssl_certificate(ssl_certificate) + .await + .expect("Failed to create SSL certificate"); + + // Track for cleanup (we'll add tracking helper) + ctx.wait_for_global_operation(create_ssl_op.name.as_ref().unwrap(), 120) + .await + .expect("SSL certificate creation timed out"); + + println!("✅ SSL certificate created"); + + // Verify certificate was created + let fetched_cert = ctx + .client + .get_ssl_certificate(ssl_cert_name.clone()) + .await + .expect("Failed to get SSL certificate"); + + assert_eq!(fetched_cert.name.as_ref().unwrap(), &ssl_cert_name); + assert!(fetched_cert.id.is_some(), "Certificate should have an ID"); + println!("✅ Verified SSL certificate: {}", ssl_cert_name); + + // ========================================================================= + // Step 3: Create Target HTTPS Proxy + // ========================================================================= + println!( + "\n📦 Step 3: Creating Target HTTPS proxy: {}", + https_proxy_name + ); + + let ssl_cert_url = format!( + "projects/{}/global/sslCertificates/{}", + ctx.project_id, ssl_cert_name + ); + + let https_proxy = TargetHttpsProxy::builder() + .name(https_proxy_name.clone()) + .description("Alien test HTTPS proxy".to_string()) + .url_map(url_map_url) + .ssl_certificates(vec![ssl_cert_url]) + .build(); + + let create_proxy_op = ctx + .client + .insert_target_https_proxy(https_proxy) + .await + .expect("Failed to create Target HTTPS proxy"); + + ctx.wait_for_global_operation(create_proxy_op.name.as_ref().unwrap(), 120) + .await + .expect("Target HTTPS proxy creation timed out"); + + println!("✅ Target HTTPS proxy created"); + + // Verify HTTPS proxy was created + let fetched_proxy = ctx + .client + .get_target_https_proxy(https_proxy_name.clone()) + .await + .expect("Failed to get Target HTTPS proxy"); + + assert_eq!(fetched_proxy.name.as_ref().unwrap(), &https_proxy_name); + assert!(fetched_proxy.id.is_some(), "Proxy should have an ID"); + assert!( + fetched_proxy.ssl_certificates.is_some(), + "Proxy should have SSL certificates" + ); + println!("✅ Verified Target HTTPS proxy: {}", https_proxy_name); + + // ========================================================================= + // Step 4: Delete Target HTTPS Proxy + // ========================================================================= + println!("\n🗑️ Step 4: Deleting Target HTTPS proxy"); + + let delete_proxy_op = ctx + .client + .delete_target_https_proxy(https_proxy_name.clone()) + .await + .expect("Failed to delete Target HTTPS proxy"); + + ctx.wait_for_global_operation(delete_proxy_op.name.as_ref().unwrap(), 120) + .await + .expect("Target HTTPS proxy deletion timed out"); + + // Verify deletion + let get_deleted_result = ctx + .client + .get_target_https_proxy(https_proxy_name.clone()) + .await; + assert!( + get_deleted_result.is_err(), + "Target HTTPS proxy should be deleted" + ); + println!("✅ Target HTTPS proxy deleted"); + + // ========================================================================= + // Step 5: Delete SSL Certificate + // ========================================================================= + println!("\n🗑️ Step 5: Deleting SSL certificate"); + + let delete_ssl_op = ctx + .client + .delete_ssl_certificate(ssl_cert_name.clone()) + .await + .expect("Failed to delete SSL certificate"); + + ctx.wait_for_global_operation(delete_ssl_op.name.as_ref().unwrap(), 120) + .await + .expect("SSL certificate deletion timed out"); + + // Verify deletion + let get_deleted_cert_result = ctx.client.get_ssl_certificate(ssl_cert_name.clone()).await; + assert!( + get_deleted_cert_result.is_err(), + "SSL certificate should be deleted" + ); + println!("✅ SSL certificate deleted"); + + // Clean up prerequisites + ctx.cleanup_url_map(&url_map_name).await; + ctx.untrack_url_map(&url_map_name); + ctx.cleanup_backend_service(&backend_service_name).await; + ctx.untrack_backend_service(&backend_service_name); + ctx.cleanup_health_check(&health_check_name).await; + ctx.untrack_health_check(&health_check_name); + + println!("\n🎉 SSL certificate and HTTPS proxy lifecycle test completed successfully!"); +} diff --git a/crates/alien-gcp-clients/tests/gcp_compute_client_tests/vpc.rs b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/vpc.rs new file mode 100644 index 000000000..c58c4e8b2 --- /dev/null +++ b/crates/alien-gcp-clients/tests/gcp_compute_client_tests/vpc.rs @@ -0,0 +1,395 @@ +use crate::context::{ComputeTestContext, NETWORK_DELETE_TIMEOUT_SECONDS}; +use alien_gcp_clients::compute::{ + ComputeApi, Firewall, FirewallAllowed, FirewallDirection, NatIpAllocateOption, Network, + NetworkRoutingConfig, Router, RouterNat, RoutingMode, SourceSubnetworkIpRangesToNat, + Subnetwork, +}; +use test_context::test_context; + +// ============================================================================================= +// Comprehensive E2E Test - VPC with all components +// ============================================================================================= + +#[test_context(ComputeTestContext)] +#[tokio::test] +async fn test_comprehensive_vpc_lifecycle(ctx: &mut ComputeTestContext) { + println!("🚀 Starting comprehensive VPC lifecycle test"); + + // Generate unique names for all resources + let network_name = ctx.generate_unique_name("vpc"); + let subnetwork_name = ctx.generate_unique_name("subnet"); + let router_name = ctx.generate_unique_name("router"); + let firewall_name = ctx.generate_unique_name("fw"); + + // ========================================================================= + // Step 1: Create VPC Network + // ========================================================================= + println!("\n📦 Step 1: Creating VPC network: {}", network_name); + + let network = Network::builder() + .name(network_name.clone()) + .description("Alien test VPC network".to_string()) + .auto_create_subnetworks(false) // We'll create subnets manually + .routing_config( + NetworkRoutingConfig::builder() + .routing_mode(RoutingMode::Regional) + .build(), + ) + .mtu(1460) + .build(); + + let create_network_op = ctx + .client + .insert_network(network) + .await + .expect("Failed to create network"); + + ctx.track_network(&network_name); + assert!( + create_network_op.name.is_some(), + "Create network operation should have a name" + ); + println!("✅ Network creation initiated"); + + // Wait for network creation to complete + ctx.wait_for_global_operation(create_network_op.name.as_ref().unwrap(), 120) + .await + .expect("Network creation operation timed out"); + + // Verify network was created + let fetched_network = ctx + .client + .get_network(network_name.clone()) + .await + .expect("Failed to get network"); + + assert_eq!( + fetched_network.name.as_ref().unwrap(), + &network_name, + "Network name should match" + ); + assert_eq!( + fetched_network.auto_create_subnetworks, + Some(false), + "autoCreateSubnetworks should be false" + ); + println!("✅ Network verified: {}", network_name); + + // ========================================================================= + // Step 2: Create Subnetwork + // ========================================================================= + println!("\n📦 Step 2: Creating subnetwork: {}", subnetwork_name); + + let network_url = format!( + "projects/{}/global/networks/{}", + ctx.project_id, network_name + ); + + let subnetwork = Subnetwork::builder() + .name(subnetwork_name.clone()) + .description("Alien test subnetwork".to_string()) + .network(network_url.clone()) + .ip_cidr_range("10.128.0.0/20".to_string()) + .private_ip_google_access(true) + .build(); + + let create_subnet_op = ctx + .client + .insert_subnetwork(ctx.region.clone(), subnetwork) + .await + .expect("Failed to create subnetwork"); + + ctx.track_subnetwork(&ctx.region, &subnetwork_name); + assert!( + create_subnet_op.name.is_some(), + "Create subnetwork operation should have a name" + ); + println!("✅ Subnetwork creation initiated"); + + // Wait for subnetwork creation to complete + ctx.wait_for_region_operation(&ctx.region, create_subnet_op.name.as_ref().unwrap(), 120) + .await + .expect("Subnetwork creation operation timed out"); + + // Verify subnetwork was created + let fetched_subnetwork = ctx + .client + .get_subnetwork(ctx.region.clone(), subnetwork_name.clone()) + .await + .expect("Failed to get subnetwork"); + + assert_eq!( + fetched_subnetwork.name.as_ref().unwrap(), + &subnetwork_name, + "Subnetwork name should match" + ); + assert_eq!( + fetched_subnetwork.private_ip_google_access, + Some(true), + "Private Google Access should be enabled" + ); + println!("✅ Subnetwork verified: {}", subnetwork_name); + + // ========================================================================= + // Step 3: Create Router with Cloud NAT + // ========================================================================= + println!( + "\n📦 Step 3: Creating router with Cloud NAT: {}", + router_name + ); + + let router = Router::builder() + .name(router_name.clone()) + .description("Alien test router with NAT".to_string()) + .network(network_url.clone()) + .nats(vec![RouterNat::builder() + .name("alien-test-nat".to_string()) + .source_subnetwork_ip_ranges_to_nat( + SourceSubnetworkIpRangesToNat::AllSubnetworksAllIpRanges, + ) + .nat_ip_allocate_option(NatIpAllocateOption::AutoOnly) + .enable_endpoint_independent_mapping(false) + .enable_dynamic_port_allocation(true) + .min_ports_per_vm(64) + .build()]) + .build(); + + let create_router_op = ctx + .client + .insert_router(ctx.region.clone(), router) + .await + .expect("Failed to create router"); + + ctx.track_router(&ctx.region, &router_name); + assert!( + create_router_op.name.is_some(), + "Create router operation should have a name" + ); + println!("✅ Router creation initiated"); + + // Wait for router creation to complete + ctx.wait_for_region_operation(&ctx.region, create_router_op.name.as_ref().unwrap(), 120) + .await + .expect("Router creation operation timed out"); + + // Verify router was created + let fetched_router = ctx + .client + .get_router(ctx.region.clone(), router_name.clone()) + .await + .expect("Failed to get router"); + + assert_eq!( + fetched_router.name.as_ref().unwrap(), + &router_name, + "Router name should match" + ); + assert!( + !fetched_router.nats.is_empty(), + "Router should have NAT configuration" + ); + println!("✅ Router verified: {}", router_name); + + // Test list_routers + println!("\n📋 Testing list_routers..."); + let router_list = ctx + .client + .list_routers(ctx.region.clone()) + .await + .expect("Failed to list routers"); + + let found_router = router_list + .items + .iter() + .find(|r| r.name.as_ref() == Some(&router_name)); + assert!(found_router.is_some(), "Router should be in list"); + println!("✅ Router found in list"); + + // ========================================================================= + // Step 4: Create Firewall Rule + // ========================================================================= + println!("\n📦 Step 4: Creating firewall rule: {}", firewall_name); + + let firewall = Firewall::builder() + .name(firewall_name.clone()) + .description("Alien test firewall rule".to_string()) + .network(network_url.clone()) + .direction(FirewallDirection::Ingress) + .priority(1000) + .allowed(vec![ + FirewallAllowed::builder() + .ip_protocol("tcp".to_string()) + .ports(vec!["22".to_string(), "80".to_string(), "443".to_string()]) + .build(), + FirewallAllowed::builder() + .ip_protocol("icmp".to_string()) + .build(), + ]) + .source_ranges(vec!["0.0.0.0/0".to_string()]) + .build(); + + let create_firewall_op = ctx + .client + .insert_firewall(firewall) + .await + .expect("Failed to create firewall"); + + ctx.track_firewall(&firewall_name); + assert!( + create_firewall_op.name.is_some(), + "Create firewall operation should have a name" + ); + println!("✅ Firewall creation initiated"); + + // Wait for firewall creation to complete + ctx.wait_for_global_operation(create_firewall_op.name.as_ref().unwrap(), 120) + .await + .expect("Firewall creation operation timed out"); + + // Verify firewall was created + let fetched_firewall = ctx + .client + .get_firewall(firewall_name.clone()) + .await + .expect("Failed to get firewall"); + + assert_eq!( + fetched_firewall.name.as_ref().unwrap(), + &firewall_name, + "Firewall name should match" + ); + assert_eq!( + fetched_firewall.direction, + Some(FirewallDirection::Ingress), + "Firewall direction should be INGRESS" + ); + println!("✅ Firewall verified: {}", firewall_name); + + // Test list_firewalls + println!("\n📋 Testing list_firewalls..."); + let firewall_list = ctx + .client + .list_firewalls() + .await + .expect("Failed to list firewalls"); + + let found_firewall = firewall_list + .items + .iter() + .find(|f| f.name.as_ref() == Some(&firewall_name)); + assert!(found_firewall.is_some(), "Firewall should be in list"); + println!("✅ Firewall found in list"); + + // ========================================================================= + // Step 5: Patch Router (update NAT config) + // ========================================================================= + println!("\n📦 Step 5: Patching router NAT configuration"); + + let updated_router = Router::builder() + .nats(vec![RouterNat::builder() + .name("alien-test-nat".to_string()) + .source_subnetwork_ip_ranges_to_nat( + SourceSubnetworkIpRangesToNat::AllSubnetworksAllIpRanges, + ) + .nat_ip_allocate_option(NatIpAllocateOption::AutoOnly) + .enable_endpoint_independent_mapping(false) + .enable_dynamic_port_allocation(true) + .min_ports_per_vm(128) // Changed from 64 to 128 + .max_ports_per_vm(65536) + .build()]) + .build(); + + let patch_router_op = ctx + .client + .patch_router(ctx.region.clone(), router_name.clone(), updated_router) + .await + .expect("Failed to patch router"); + + assert!( + patch_router_op.name.is_some(), + "Patch router operation should have a name" + ); + println!("✅ Router patch initiated"); + + // Wait for patch to complete + ctx.wait_for_region_operation(&ctx.region, patch_router_op.name.as_ref().unwrap(), 120) + .await + .expect("Router patch operation timed out"); + + // Verify router was updated + let updated_router_check = ctx + .client + .get_router(ctx.region.clone(), router_name.clone()) + .await + .expect("Failed to get updated router"); + + let nat_config = &updated_router_check.nats[0]; + assert_eq!( + nat_config.min_ports_per_vm, + Some(128), + "NAT min_ports_per_vm should be updated to 128" + ); + println!("✅ Router NAT configuration updated successfully"); + + // ========================================================================= + // Step 6: Clean up in reverse order + // ========================================================================= + println!("\n🧹 Step 6: Cleaning up resources"); + + // Delete firewall + println!(" Deleting firewall..."); + let delete_firewall_op = ctx + .client + .delete_firewall(firewall_name.clone()) + .await + .expect("Failed to delete firewall"); + + ctx.wait_for_global_operation(delete_firewall_op.name.as_ref().unwrap(), 120) + .await + .expect("Firewall deletion operation timed out"); + ctx.untrack_firewall(&firewall_name); + println!(" ✅ Firewall deleted"); + + // Delete router + println!(" Deleting router..."); + let delete_router_op = ctx + .client + .delete_router(ctx.region.clone(), router_name.clone()) + .await + .expect("Failed to delete router"); + + ctx.wait_for_region_operation(&ctx.region, delete_router_op.name.as_ref().unwrap(), 120) + .await + .expect("Router deletion operation timed out"); + ctx.untrack_router(&ctx.region, &router_name); + println!(" ✅ Router deleted"); + + // Delete subnetwork + println!(" Deleting subnetwork..."); + let delete_subnet_op = ctx + .client + .delete_subnetwork(ctx.region.clone(), subnetwork_name.clone()) + .await + .expect("Failed to delete subnetwork"); + + ctx.wait_for_region_operation(&ctx.region, delete_subnet_op.name.as_ref().unwrap(), 120) + .await + .expect("Subnetwork deletion operation timed out"); + ctx.untrack_subnetwork(&ctx.region, &subnetwork_name); + println!(" ✅ Subnetwork deleted"); + + // Delete network + println!(" Deleting network..."); + ctx.delete_network_with_retry(&network_name, NETWORK_DELETE_TIMEOUT_SECONDS) + .await + .expect("Failed to delete network"); + ctx.untrack_network(&network_name); + println!(" ✅ Network deleted"); + + println!("\n🎉 Comprehensive VPC lifecycle test completed successfully!"); + println!(" - VPC network created and deleted: ✅"); + println!(" - Subnetwork created and deleted: ✅"); + println!(" - Router with NAT created, patched, and deleted: ✅"); + println!(" - Firewall rule created and deleted: ✅"); + println!(" - List operations verified: ✅"); +} diff --git a/crates/alien-helm/src/generator.rs b/crates/alien-helm/src/generator.rs deleted file mode 100644 index db5e5938a..000000000 --- a/crates/alien-helm/src/generator.rs +++ /dev/null @@ -1,4647 +0,0 @@ -//! Top-level Helm chart generator. -//! -//! Drives per-resource [`HelmEmitter`]s through the [`HelmRegistry`] and -//! assembles the chart shell — `Chart.yaml`, the templates, and the -//! values + schema for both bootstrap paths (`registered setup` when -//! `management.deploymentId` is set; external-bindings initialize otherwise). - -use crate::{ - emitter::{HelmFragment, InfrastructureValue}, - registry::HelmRegistry, -}; -use alien_core::{ - import::EmitContext, AzureResourceGroupOutputs, Container, ContainerCode, Daemon, DaemonCode, - ErrorData, KubernetesCluster, KubernetesClusterOutputs, KubernetesClusterOwnership, - KubernetesClusterProvider, Platform, RemoteStackManagementOutputs, ResourceLifecycle, Result, - ServiceAccount, ServiceAccountOutputs, Stack, StackSettings, Worker, WorkerCode, -}; -use alien_error::{AlienError, Context, IntoAlienError}; -use indexmap::IndexMap; -use serde::Serialize; -use std::collections::{BTreeMap, BTreeSet}; - -/// Generated Helm chart files. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HelmChart { - pub name: String, - pub files: IndexMap, -} - -/// Options for Helm chart generation. -pub struct HelmOptions<'a> { - /// Per-`(ResourceType, Platform)` emitter dispatch. Most callers - /// pass [`HelmRegistry::built_in()`]; plugin-aware callers extend it - /// before passing. - pub registry: &'a HelmRegistry, - pub stack_settings: StackSettings, - pub chart_name: String, -} - -/// Inputs for rendering `values.yaml` from registered setup state. -pub struct ManagerFetchHelmValuesOptions<'a> { - pub deployment_id: &'a str, - pub deployment_name: &'a str, - pub manager_url: &'a str, - pub deployment_token: &'a str, - pub runtime_encryption_key: &'a str, - pub stack: &'a Stack, - pub stack_state: &'a alien_core::StackState, - pub stack_settings: &'a StackSettings, - pub base_platform: Option, - pub region: Option<&'a str>, - pub gcp_project_id: Option<&'a str>, - pub azure_location: Option<&'a str>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OperatorPermission { - /// Namespaced, read-only workload observation. - Observe, -} - -impl OperatorPermission { - fn as_str(self) -> &'static str { - match self { - Self::Observe => "observe", - } - } -} - -/// How the rendered operator documents are meant to be consumed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OperatorOutputFormat { - /// Flat multi-document manifest for `kubectl apply` to a single cluster. - /// Namespace and environment name are concrete literals. - RawManifest, - /// Helm-templated documents to paste into an existing chart's `templates/`. - /// Namespace resolves to `.Release.Namespace` and the per-environment name - /// to `.Values.alien.environmentName`, so one file serves every install. - HelmTemplate, -} - -/// How much of the cluster the operator manages. This is the single decision -/// that flips a namespaced `Role` to a cluster-wide `ClusterRole` and widens -/// what the operator observes from one namespace to all of them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OperatorScope { - /// Manage only the namespace the operator is installed in. Grants a - /// namespaced `Role`/`RoleBinding`. - Namespace, - /// Manage the **whole cluster** (spans namespaces). Grants a - /// `ClusterRole`/`ClusterRoleBinding`. - Cluster, -} - -impl OperatorScope { - /// Whether this scope requires cluster-wide (cluster-scoped) RBAC. - fn is_cluster_wide(self) -> bool { - matches!(self, OperatorScope::Cluster) - } -} - -pub struct OperatorManifestOptions<'a> { - pub manager_url: &'a str, - pub group_token: &'a str, - pub encryption_key: &'a str, - pub image: &'a str, - pub log_collector: Option>, - /// Names the Kubernetes objects and labels. Stable per app/project — the - /// same across every customer install. (Formerly `release_name`.) - pub project_name: &'a str, - /// The per-environment identity reported as `OPERATOR_NAME`. Required for - /// `RawManifest`; ignored for `HelmTemplate`, which sources it from - /// `.Values.alien.environmentName` so each install is distinct. - pub environment_name: Option<&'a str>, - /// The namespace the operator installs into. Required (non-empty) for - /// `RawManifest`; ignored for `HelmTemplate`, which uses `.Release.Namespace`. - /// In `Namespace` scope this is also the namespace observed. - pub install_namespace: Option<&'a str>, - pub scope: OperatorScope, - /// Optional Kubernetes label selector that narrows what the operator manages, - /// applied on top of `scope`. Independent of namespace vs cluster scope: a - /// cluster-scoped operator can still filter to labeled resources, and a - /// namespaced one can filter within its namespace. `None` manages everything - /// in scope. - pub label_selector: Option<&'a str>, - pub permission: OperatorPermission, - pub format: OperatorOutputFormat, -} - -pub struct OperatorLogCollectorOptions<'a> { - pub image: &'a str, - pub token: &'a str, -} - -/// Generate a Helm chart for `stack`. -pub fn generate_helm_chart(stack: &Stack, options: HelmOptions<'_>) -> Result { - let chart_name = sanitize_chart_name(&options.chart_name); - let analysis = ChartAnalysis::from_stack(stack, options.registry)?; - - let stack_json = to_stable_pretty_json(stack).context(ErrorData::JsonSerializationFailed { - reason: "failed to serialize stack into chart metadata".to_string(), - })?; - let stack_settings_json = to_stable_pretty_json(&options.stack_settings).context( - ErrorData::JsonSerializationFailed { - reason: "failed to serialize stack settings into chart metadata".to_string(), - }, - )?; - - let mut files = IndexMap::new(); - files.insert("Chart.yaml".to_string(), chart_yaml(&chart_name, stack)); - files.insert( - "values.yaml".to_string(), - values_yaml(&analysis, &options.stack_settings)?, - ); - files.insert("values.schema.json".to_string(), values_schema_json()); - files.insert("templates/_helpers.tpl".to_string(), helpers_tpl()); - files.insert( - "templates/serviceaccount.yaml".to_string(), - serviceaccount_tpl(), - ); - files.insert("templates/role.yaml".to_string(), role_tpl()); - files.insert("templates/rolebinding.yaml".to_string(), rolebinding_tpl()); - files.insert("templates/clusterrole.yaml".to_string(), clusterrole_tpl()); - files.insert( - "templates/clusterrolebinding.yaml".to_string(), - clusterrolebinding_tpl(), - ); - files.insert("templates/secret.yaml".to_string(), secret_tpl()); - files.insert("templates/configmap.yaml".to_string(), configmap_tpl()); - files.insert("templates/deployment.yaml".to_string(), deployment_tpl()); - files.insert( - "templates/whitelabeled-log-collector-serviceaccount.yaml".to_string(), - whitelabeled_log_collector_serviceaccount_tpl(), - ); - files.insert( - "templates/whitelabeled-log-collector-role.yaml".to_string(), - whitelabeled_log_collector_role_tpl(), - ); - files.insert( - "templates/whitelabeled-log-collector-rolebinding.yaml".to_string(), - whitelabeled_log_collector_rolebinding_tpl(), - ); - files.insert( - "templates/whitelabeled-log-collector-configmap.yaml".to_string(), - whitelabeled_log_collector_configmap_tpl(), - ); - files.insert( - "templates/whitelabeled-log-collector-daemonset.yaml".to_string(), - whitelabeled_log_collector_daemonset_tpl(), - ); - files.insert("templates/pvc.yaml".to_string(), pvc_tpl()); - files.insert("templates/service.yaml".to_string(), service_tpl()); - files.insert("templates/cleanup-job.yaml".to_string(), cleanup_job_tpl()); - files.insert("templates/app-service.yaml".to_string(), app_service_tpl()); - files.insert( - "templates/cluster-bootstrap.yaml".to_string(), - cluster_bootstrap_tpl(), - ); - files.insert( - "templates/poddisruptionbudget.yaml".to_string(), - poddisruptionbudget_tpl(), - ); - files.insert( - "templates/networkpolicy.yaml".to_string(), - networkpolicy_tpl(), - ); - - // Per-resource extra templates contributed by emitters. - for (path, contents) in &analysis.extra_templates { - files.insert(format!("templates/{path}"), contents.clone()); - } - - files.insert( - "examples/eks.yaml".to_string(), - eks_values_example(&analysis), - ); - files.insert( - "examples/gke.yaml".to_string(), - gke_values_example(&analysis), - ); - files.insert( - "examples/aks.yaml".to_string(), - aks_values_example(&analysis), - ); - files.insert( - "examples/onprem.yaml".to_string(), - onprem_values_example(&analysis), - ); - files.insert("README.md".to_string(), readme_md(&chart_name, stack)); - - files.insert( - "files/stack.json".to_string(), - ensure_trailing_newline(stack_json), - ); - files.insert( - "files/stack-settings.json".to_string(), - ensure_trailing_newline(stack_settings_json), - ); - - Ok(HelmChart { - name: chart_name, - files, - }) -} - -pub fn generate_operator_manifest(options: OperatorManifestOptions<'_>) -> Result { - validate_runtime_encryption_key(options.encryption_key)?; - validate_operator_options(&options)?; - - let base_name = sanitize_chart_name(options.project_name); - let operator_name = format!("{base_name}-operator"); - let identity_pvc_name = format!("{operator_name}-identity"); - - // The install namespace: where every operator object lives, the binding - // subject's namespace, and (in `Namespace` scope) the observed namespace. A - // Helm template defers it to `.Release.Namespace`; a raw manifest pins it to - // a concrete value so `kubectl apply` and binding subjects resolve. - let namespace_expr = match options.format { - OperatorOutputFormat::HelmTemplate => "{{ .Release.Namespace }}".to_string(), - OperatorOutputFormat::RawManifest => options - .install_namespace - .expect("validated by validate_operator_options") - .to_string(), - }; - let namespace = namespace_expr.as_str(); - - // OPERATOR_NAME is the per-environment identity. A raw manifest carries the - // concrete name; a Helm template sources it per install from values so one - // file registers every customer environment distinctly. - let environment_name_expr = match options.format { - OperatorOutputFormat::HelmTemplate => "{{ .Values.alien.environmentName }}".to_string(), - OperatorOutputFormat::RawManifest => options - .environment_name - .expect("validated by validate_operator_options") - .to_string(), - }; - - let labels = operator_labels(&base_name); - let cluster_wide = options.scope.is_cluster_wide(); - - let mut docs = Vec::new(); - docs.push(operator_service_account_doc( - namespace, - &operator_name, - &labels, - )); - // Cluster-wide (label) scope needs cluster-scoped read RBAC; namespace scope - // stays a namespaced Role. Both grant only get/list/watch. - if cluster_wide { - docs.push(operator_clusterrole_doc(&operator_name, &labels)); - docs.push(operator_clusterrolebinding_doc( - namespace, - &operator_name, - &labels, - )); - } else { - docs.push(operator_role_doc(namespace, &operator_name, &labels)); - docs.push(operator_rolebinding_doc(namespace, &operator_name, &labels)); - } - docs.push(operator_secret_doc( - namespace, - &operator_name, - options.group_token, - options.encryption_key, - options - .log_collector - .as_ref() - .map(|collector| collector.token), - &labels, - )); - docs.push(operator_identity_pvc_doc( - namespace, - &identity_pvc_name, - &labels, - )); - docs.push(operator_deployment_doc( - namespace, - &operator_name, - &identity_pvc_name, - &options, - namespace, - &environment_name_expr, - options.label_selector, - &labels, - )); - if let Some(log_collector) = options.log_collector.as_ref() { - let mut collector_labels = labels.clone(); - collector_labels.insert( - "app.kubernetes.io/component".to_string(), - "whitelabeled-log-collector".to_string(), - ); - docs.push(operator_service_doc(namespace, &operator_name, &labels)); - docs.push(operator_log_collector_service_account_doc( - namespace, - &operator_name, - &collector_labels, - )); - docs.push(operator_log_collector_role_doc( - namespace, - &operator_name, - &collector_labels, - )); - docs.push(operator_log_collector_role_binding_doc( - namespace, - &operator_name, - &collector_labels, - )); - docs.push(operator_log_collector_configmap_doc( - namespace, - &operator_name, - namespace, - &collector_labels, - )); - docs.push(operator_log_collector_daemonset_doc( - namespace, - &operator_name, - log_collector.image, - &collector_labels, - )); - } - - Ok(ensure_trailing_newline(docs.join("---\n"))) -} - -/// Render one complete values file from registered deployment state. -pub fn render_manager_fetch_values(options: ManagerFetchHelmValuesOptions<'_>) -> Result { - validate_runtime_encryption_key(options.runtime_encryption_key)?; - - let registry = HelmRegistry::built_in(); - let analysis = ChartAnalysis::from_stack(options.stack, ®istry)?; - let mut yaml = String::new(); - - yaml.push_str("management:\n"); - yaml.push_str(&format!( - " token: {}\n", - yaml_string(options.deployment_token) - )); - yaml.push_str(&format!( - " name: {}\n", - yaml_string(options.deployment_name) - )); - yaml.push_str(&format!(" url: {}\n", yaml_string(options.manager_url))); - yaml.push_str(&format!( - " deploymentId: {}\n", - yaml_string(options.deployment_id) - )); - yaml.push_str(&format!( - " updates: {}\n", - yaml_string(updates_mode_value(options.stack_settings.updates)) - )); - yaml.push_str(&format!( - " telemetry: {}\n", - yaml_string(telemetry_mode_value(options.stack_settings.telemetry)) - )); - yaml.push_str(&format!( - " healthChecks: {}\n\n", - yaml_string(heartbeats_mode_value(options.stack_settings.heartbeats)) - )); - - yaml.push_str("runtime:\n"); - yaml.push_str(" encryption:\n"); - yaml.push_str(&format!( - " key: {}\n\n", - yaml_string(options.runtime_encryption_key) - )); - - append_stack_settings(&mut yaml, options.stack_settings)?; - yaml.push_str("\ninfrastructure: null\n\n"); - - match options.base_platform { - Some(platform) => yaml.push_str(&format!( - "basePlatform: {}\n", - yaml_string(platform.as_str()) - )), - None => yaml.push_str("basePlatform: null\n"), - } - yaml.push_str("basePlatformConfig:\n"); - yaml.push_str(" gcp:\n"); - yaml.push_str(&format!( - " projectId: {}\n", - yaml_string(options.gcp_project_id.unwrap_or("")) - )); - yaml.push_str(&format!( - " region: {}\n", - yaml_string(options.region.unwrap_or("")) - )); - yaml.push_str(" aws:\n"); - yaml.push_str(&format!( - " region: {}\n", - yaml_string(options.region.unwrap_or("")) - )); - yaml.push_str(" azure:\n"); - yaml.push_str(&format!( - " location: {}\n", - yaml_string(options.azure_location.or(options.region).unwrap_or("")) - )); - if let Some(azure_config) = - azure_base_platform_config(options.stack_state, options.base_platform)? - { - yaml.push_str(&format!( - " subscriptionId: {}\n", - yaml_string(&azure_config.subscription_id) - )); - if let Some(tenant_id) = azure_config.tenant_id { - yaml.push_str(&format!(" tenantId: {}\n", yaml_string(&tenant_id))); - } - } - yaml.push_str(&format!( - "serviceAccountPrefix: {}\n", - yaml_string(&options.stack_state.resource_prefix) - )); - yaml.push_str("logCollector:\n"); - yaml.push_str(" scope:\n"); - yaml.push_str(&format!( - " deploymentLabelValue: {}\n", - yaml_string(&options.stack_state.resource_prefix) - )); - yaml.push('\n'); - - append_manager_service_account(&mut yaml, options.stack_state, options.base_platform)?; - append_registered_service_accounts( - &mut yaml, - &analysis, - options.stack_state, - options.base_platform, - ); - append_runtime_cloud_identity(&mut yaml, options.base_platform); - append_cluster_bootstrap( - &mut yaml, - options.stack, - options.stack_state, - options.base_platform, - ); - append_services(&mut yaml, &analysis); - yaml.push_str("\npublicEndpoints: {}\n"); - - Ok(yaml) -} - -fn validate_runtime_encryption_key(key: &str) -> Result<()> { - if key.len() == 64 && key.chars().all(|c| c.is_ascii_hexdigit()) { - return Ok(()); - } - - Err(AlienError::new(ErrorData::GenericError { - message: "runtime encryption key must be exactly 64 hex characters".to_string(), - })) -} - -fn validate_operator_options(options: &OperatorManifestOptions<'_>) -> Result<()> { - let invalid = |message: &str| { - Err(AlienError::new(ErrorData::GenericError { - message: message.to_string(), - })) - }; - - // A label selector, when given, must be non-empty. - if let Some(selector) = options.label_selector { - if selector.trim().is_empty() { - return invalid("operator label selector must not be empty"); - } - } - - // Raw manifests are applied to one concrete cluster, so the install namespace - // and per-environment identity must be concrete. Helm defers both to install. - if options.format == OperatorOutputFormat::RawManifest { - if options - .install_namespace - .map(|ns| ns.trim().is_empty()) - .unwrap_or(true) - { - return invalid("raw manifests require an install namespace"); - } - if options - .environment_name - .map(|name| name.trim().is_empty()) - .unwrap_or(true) - { - return invalid("raw manifests require an environment name"); - } - } - - Ok(()) -} - -fn operator_labels(base_name: &str) -> BTreeMap { - BTreeMap::from([ - ("app.kubernetes.io/name".to_string(), "operator".to_string()), - ( - "app.kubernetes.io/instance".to_string(), - base_name.to_string(), - ), - ( - "app.kubernetes.io/component".to_string(), - "operator".to_string(), - ), - ( - "app.kubernetes.io/managed-by".to_string(), - "kubectl".to_string(), - ), - ]) -} - -fn operator_service_account_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_metadata_doc("v1", "ServiceAccount", namespace, operator_name, labels); - yaml.push_str("automountServiceAccountToken: true\n"); - yaml -} - -fn operator_role_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_metadata_doc( - "rbac.authorization.k8s.io/v1", - "Role", - namespace, - operator_name, - labels, - ); - yaml.push_str(OPERATOR_OBSERVE_RULES); - yaml -} - -fn operator_rolebinding_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_metadata_doc( - "rbac.authorization.k8s.io/v1", - "RoleBinding", - namespace, - operator_name, - labels, - ); - yaml.push_str(&format!( - r#"subjects: - - kind: ServiceAccount - name: {} - namespace: {} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: {} -"#, - yaml_string(operator_name), - yaml_string(namespace), - yaml_string(operator_name) - )); - yaml -} - -/// Read-only observe rules, shared by the namespaced `Role` and the cluster-wide -/// `ClusterRole`. Only `get/list/watch`; never `secrets` or `pods/log`. -const OPERATOR_OBSERVE_RULES: &str = r#"rules: - - apiGroups: [""] - resources: ["pods", "services", "configmaps", "persistentvolumeclaims", "events", "endpoints"] - verbs: ["get", "list", "watch"] - - apiGroups: ["apps"] - resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] - verbs: ["get", "list", "watch"] - - apiGroups: ["batch"] - resources: ["jobs", "cronjobs"] - verbs: ["get", "list", "watch"] - - apiGroups: ["metrics.k8s.io"] - resources: ["pods"] - verbs: ["get", "list", "watch"] -"#; - -/// Cluster-scoped metadata header (no `metadata.namespace`) for `ClusterRole` and -/// `ClusterRoleBinding`, which are not namespaced objects. -fn operator_cluster_metadata_doc( - kind: &str, - name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = String::new(); - yaml.push_str("apiVersion: rbac.authorization.k8s.io/v1\n"); - yaml.push_str(&format!("kind: {}\n", yaml_string(kind))); - yaml.push_str("metadata:\n"); - yaml.push_str(&format!(" name: {}\n", yaml_string(name))); - yaml.push_str(" labels:\n"); - append_operator_labels(&mut yaml, labels, 4); - yaml -} - -fn operator_clusterrole_doc(operator_name: &str, labels: &BTreeMap) -> String { - let mut yaml = operator_cluster_metadata_doc("ClusterRole", operator_name, labels); - yaml.push_str(OPERATOR_OBSERVE_RULES); - yaml -} - -fn operator_clusterrolebinding_doc( - subject_namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_cluster_metadata_doc("ClusterRoleBinding", operator_name, labels); - yaml.push_str(&format!( - r#"subjects: - - kind: ServiceAccount - name: {} - namespace: {} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: {} -"#, - yaml_string(operator_name), - yaml_string(subject_namespace), - yaml_string(operator_name) - )); - yaml -} - -fn operator_secret_doc( - namespace: &str, - operator_name: &str, - group_token: &str, - encryption_key: &str, - collector_token: Option<&str>, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_metadata_doc("v1", "Secret", namespace, operator_name, labels); - yaml.push_str("type: Opaque\n"); - yaml.push_str("stringData:\n"); - yaml.push_str(&format!(" sync-token: {}\n", yaml_string(group_token))); - yaml.push_str(&format!( - " encryption-key: {}\n", - yaml_string(encryption_key) - )); - if let Some(collector_token) = collector_token { - yaml.push_str(&format!( - " collector-token: {}\n", - yaml_string(collector_token) - )); - } - yaml -} - -fn operator_identity_pvc_doc( - namespace: &str, - pvc_name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = - operator_metadata_doc("v1", "PersistentVolumeClaim", namespace, pvc_name, labels); - yaml.push_str( - r#"spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: "1Gi" -"#, - ); - yaml -} - -#[allow(clippy::too_many_arguments)] -fn operator_deployment_doc( - namespace: &str, - operator_name: &str, - identity_pvc_name: &str, - options: &OperatorManifestOptions<'_>, - observed_namespace: &str, - environment_name: &str, - label_selector: Option<&str>, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_metadata_doc("apps/v1", "Deployment", namespace, operator_name, labels); - yaml.push_str("spec:\n"); - yaml.push_str(" replicas: 1\n"); - yaml.push_str(" selector:\n"); - yaml.push_str(" matchLabels:\n"); - append_operator_selector_labels(&mut yaml, labels, 6); - yaml.push_str(" template:\n"); - yaml.push_str(" metadata:\n"); - yaml.push_str(" labels:\n"); - append_operator_labels(&mut yaml, labels, 8); - yaml.push_str(" spec:\n"); - yaml.push_str(&format!( - " serviceAccountName: {}\n", - yaml_string(operator_name) - )); - yaml.push_str(" automountServiceAccountToken: true\n"); - yaml.push_str(" securityContext:\n"); - yaml.push_str(" runAsNonRoot: true\n"); - yaml.push_str(" runAsUser: 1000\n"); - yaml.push_str(" runAsGroup: 1000\n"); - yaml.push_str(" fsGroup: 1000\n"); - yaml.push_str(" seccompProfile:\n"); - yaml.push_str(" type: RuntimeDefault\n"); - yaml.push_str(" containers:\n"); - yaml.push_str(" - name: operator\n"); - yaml.push_str(&format!( - " image: {}\n", - yaml_string(options.image) - )); - yaml.push_str(" imagePullPolicy: IfNotPresent\n"); - yaml.push_str(" securityContext:\n"); - yaml.push_str(" allowPrivilegeEscalation: false\n"); - yaml.push_str(" readOnlyRootFilesystem: true\n"); - yaml.push_str(" capabilities:\n"); - yaml.push_str(" drop: [\"ALL\"]\n"); - yaml.push_str(" env:\n"); - append_env_value(&mut yaml, "PLATFORM", "kubernetes"); - append_env_value(&mut yaml, "SYNC_URL", options.manager_url); - append_env_value(&mut yaml, "OPERATOR_NAME", environment_name); - append_env_value(&mut yaml, "KUBERNETES_NAMESPACE", namespace); - append_env_value(&mut yaml, "OPERATOR_SCOPE", observed_namespace); - // Cluster scope observes every namespace; namespace scope stays in its own. - // The selector (if any) filters within whichever scope is chosen. - if options.scope.is_cluster_wide() { - append_env_value(&mut yaml, "OPERATOR_OBSERVE_ALL_NAMESPACES", "true"); - } - if let Some(label_selector) = label_selector { - append_env_value(&mut yaml, "OPERATOR_LABEL_SELECTOR", label_selector); - } - // Helm distributions surface the running app version as a value, so each install - // reports the release it's on. Raw manifests omit it; the vendor sets - // OPERATOR_RELEASE_VERSION themselves if they want version/rollout visibility. - if options.format == OperatorOutputFormat::HelmTemplate { - append_env_value( - &mut yaml, - "OPERATOR_RELEASE_VERSION", - "{{ .Values.alien.version }}", - ); - } - append_env_value( - &mut yaml, - "OPERATOR_PERMISSION", - options.permission.as_str(), - ); - append_env_value(&mut yaml, "OPERATOR_INITIAL_DESIRED_RELEASE", "none"); - append_env_value(&mut yaml, "OPERATOR_SETUP_METHOD", "manual"); - append_env_value(&mut yaml, "DATA_DIR", "/var/lib/operator"); - if options.log_collector.is_some() { - append_env_value(&mut yaml, "OTLP_HOST", "0.0.0.0"); - append_env_value(&mut yaml, "OTLP_PORT", "8080"); - append_env_value( - &mut yaml, - "COLLECTOR_TOKEN_FILE", - "/etc/operator/secrets/collector-token", - ); - } - append_env_value( - &mut yaml, - "SYNC_TOKEN_FILE", - "/etc/operator/secrets/sync-token", - ); - append_env_value( - &mut yaml, - "OPERATOR_ENCRYPTION_KEY_FILE", - "/etc/operator/secrets/encryption-key", - ); - append_env_value(&mut yaml, "SYNC_INTERVAL", "30"); - if options.log_collector.is_some() { - yaml.push_str(" ports:\n"); - yaml.push_str(" - name: http\n"); - yaml.push_str(" containerPort: 8080\n"); - } - yaml.push_str(" volumeMounts:\n"); - yaml.push_str(" - name: credentials\n"); - yaml.push_str(" mountPath: /etc/operator/secrets\n"); - yaml.push_str(" readOnly: true\n"); - yaml.push_str(" - name: identity\n"); - yaml.push_str(" mountPath: /var/lib/operator\n"); - yaml.push_str(" - name: tmp\n"); - yaml.push_str(" mountPath: /tmp\n"); - yaml.push_str(" resources:\n"); - yaml.push_str(" requests:\n"); - yaml.push_str(" cpu: 50m\n"); - yaml.push_str(" memory: 128Mi\n"); - yaml.push_str(" limits:\n"); - yaml.push_str(" cpu: 500m\n"); - yaml.push_str(" memory: 512Mi\n"); - yaml.push_str(" volumes:\n"); - yaml.push_str(" - name: credentials\n"); - yaml.push_str(" secret:\n"); - yaml.push_str(&format!( - " secretName: {}\n", - yaml_string(operator_name) - )); - yaml.push_str(" defaultMode: 384\n"); - yaml.push_str(" - name: identity\n"); - yaml.push_str(" persistentVolumeClaim:\n"); - yaml.push_str(&format!( - " claimName: {}\n", - yaml_string(identity_pvc_name) - )); - yaml.push_str(" - name: tmp\n"); - yaml.push_str(" emptyDir:\n"); - yaml.push_str(" sizeLimit: 64Mi\n"); - yaml -} - -fn operator_service_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let mut yaml = operator_metadata_doc("v1", "Service", namespace, operator_name, labels); - yaml.push_str("spec:\n"); - yaml.push_str(" type: ClusterIP\n"); - yaml.push_str(" selector:\n"); - append_operator_selector_labels(&mut yaml, labels, 4); - yaml.push_str(" ports:\n"); - yaml.push_str(" - name: http\n"); - yaml.push_str(" port: 8080\n"); - yaml.push_str(" targetPort: http\n"); - yaml -} - -fn operator_log_collector_service_account_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let name = format!("{operator_name}-whitelabeled-log-collector"); - let mut yaml = operator_metadata_doc("v1", "ServiceAccount", namespace, &name, labels); - yaml.push_str("automountServiceAccountToken: true\n"); - yaml -} - -fn operator_log_collector_role_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let name = format!("{operator_name}-whitelabeled-log-collector"); - let mut yaml = operator_metadata_doc( - "rbac.authorization.k8s.io/v1", - "Role", - namespace, - &name, - labels, - ); - yaml.push_str("rules:\n"); - yaml.push_str(" - apiGroups: [\"\"]\n"); - yaml.push_str(" resources: [\"pods\"]\n"); - yaml.push_str(" verbs: [\"get\", \"list\", \"watch\"]\n"); - yaml -} - -fn operator_log_collector_role_binding_doc( - namespace: &str, - operator_name: &str, - labels: &BTreeMap, -) -> String { - let name = format!("{operator_name}-whitelabeled-log-collector"); - let mut yaml = operator_metadata_doc( - "rbac.authorization.k8s.io/v1", - "RoleBinding", - namespace, - &name, - labels, - ); - yaml.push_str("roleRef:\n"); - yaml.push_str(" apiGroup: rbac.authorization.k8s.io\n"); - yaml.push_str(" kind: Role\n"); - yaml.push_str(&format!(" name: {}\n", yaml_string(&name))); - yaml.push_str("subjects:\n"); - yaml.push_str(" - kind: ServiceAccount\n"); - yaml.push_str(&format!(" name: {}\n", yaml_string(&name))); - yaml.push_str(&format!(" namespace: {}\n", yaml_string(namespace))); - yaml -} - -fn operator_log_collector_configmap_doc( - namespace: &str, - operator_name: &str, - observed_namespace: &str, - labels: &BTreeMap, -) -> String { - let name = format!("{operator_name}-whitelabeled-log-collector"); - let mut yaml = operator_metadata_doc("v1", "ConfigMap", namespace, &name, labels); - yaml.push_str("data:\n"); - yaml.push_str(" collector.conf: |\n"); - yaml.push_str(" [SERVICE]\n"); - yaml.push_str(" Flush 2\n"); - yaml.push_str(" Log_Level info\n"); - yaml.push_str(" Parsers_File parsers.conf\n"); - yaml.push_str(" storage.path /buffers\n"); - yaml.push_str(" storage.sync normal\n"); - yaml.push_str(" storage.backlog.mem_limit 64M\n\n"); - yaml.push_str(" [INPUT]\n"); - yaml.push_str(" Name tail\n"); - yaml.push_str(&format!( - " Path /var/log/pods/{}_*/*/*.log\n", - observed_namespace - )); - yaml.push_str(&format!( - " Exclude_Path /var/log/pods/{}_{}-*/*/*.log\n", - observed_namespace, operator_name - )); - yaml.push_str(" Path_Key filename\n"); - // Built-in multiline parsers auto-detect the runtime log format: `cri` for - // containerd (EKS/GKE/AKS, k8s >=1.24) and `docker` for the docker-json format - // used by Docker-runtime clusters (Docker Desktop, OrbStack, legacy on-prem). - yaml.push_str(" multiline.parser docker, cri\n"); - yaml.push_str(" Tag kube.*\n"); - yaml.push_str(&format!( - " DB /buffers/{operator_name}-whitelabeled-log-collector.db\n" - )); - yaml.push_str(" Mem_Buf_Limit 64MB\n"); - yaml.push_str(" Skip_Long_Lines On\n"); - yaml.push_str(" Read_from_Head On\n"); - yaml.push_str(" Refresh_Interval 5\n"); - yaml.push_str(" storage.type filesystem\n\n"); - yaml.push_str(" [FILTER]\n"); - yaml.push_str(" Name kubernetes\n"); - yaml.push_str(" Match kube.*\n"); - yaml.push_str(" Merge_Log Off\n"); - yaml.push_str(" Keep_Log On\n"); - yaml.push_str(" Labels On\n"); - yaml.push_str(" Annotations Off\n\n"); - yaml.push_str(" [OUTPUT]\n"); - yaml.push_str(" Name http\n"); - // Without a Match the router never routes the tailed kube.* records to this - // output ("NO match for http.0 output instance"), so no pod logs are shipped. - yaml.push_str(" Match kube.*\n"); - yaml.push_str(&format!( - " Host {operator_name}.{namespace}.svc.cluster.local\n" - )); - yaml.push_str(" Port 8080\n"); - yaml.push_str(" URI /internal/logs\n"); - yaml.push_str(" Format json\n"); - yaml.push_str(" Json_Date_Key observed_at\n"); - yaml.push_str(" Header Authorization Bearer ${COLLECTOR_TOKEN}\n\n"); - yaml.push_str(" parsers.conf: |\n"); - yaml.push_str(" [PARSER]\n"); - yaml.push_str(" Name cri\n"); - yaml.push_str(" Format regex\n"); - yaml.push_str( - " Regex ^(?