From a3c0891850cc706cb3af6d446c40b288321513ce Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 09:27:52 +0900 Subject: [PATCH 01/18] refactor: split gcp worker controller into modules Break the 7,000-line worker/gcp.rs into a directory module so each concern can be read and reviewed in isolation: - gcp/mod.rs: controller struct, compute-operation tracking, and the #[controller] handler impl (kept whole for macro state generation) - gcp/support.rs: naming helpers, error classifiers, heartbeat emission, and GcsNotificationTracker - gcp/helpers.rs: the plain helper-method impl block - gcp/tests.rs: the controller test module Pure code motion: moved items are bumped to pub(super) where needed; external paths are unchanged via the existing worker::gcp re-exports. --- crates/alien-infra/src/worker/gcp/helpers.rs | 1422 ++++++++ .../src/worker/{gcp.rs => gcp/mod.rs} | 3103 +---------------- crates/alien-infra/src/worker/gcp/support.rs | 248 ++ crates/alien-infra/src/worker/gcp/tests.rs | 1441 ++++++++ 4 files changed, 3120 insertions(+), 3094 deletions(-) create mode 100644 crates/alien-infra/src/worker/gcp/helpers.rs rename crates/alien-infra/src/worker/{gcp.rs => gcp/mod.rs} (55%) create mode 100644 crates/alien-infra/src/worker/gcp/support.rs create mode 100644 crates/alien-infra/src/worker/gcp/tests.rs diff --git a/crates/alien-infra/src/worker/gcp/helpers.rs b/crates/alien-infra/src/worker/gcp/helpers.rs new file mode 100644 index 000000000..9c96b8e18 --- /dev/null +++ b/crates/alien-infra/src/worker/gcp/helpers.rs @@ -0,0 +1,1422 @@ +use std::collections::HashMap; +use tracing::{info, warn}; + +use crate::core::{EnvironmentVariableBuilder, ResourcePermissionsHelper}; + +use crate::core::ResourceControllerContext; +use crate::error::{ErrorData, Result}; +use alien_core::{Network, ResourceDefinition, ResourceRef, Worker}; +use alien_error::{AlienError, Context, ContextError}; +use alien_gcp_clients::cloudrun::{ + Ingress as CloudRunIngress, NetworkInterface, RevisionTemplate, Service, TrafficTarget, + TrafficTargetAllocationType, VpcAccess, VpcEgress, +}; +use alien_gcp_clients::gcs::GcsNotification; +use alien_gcp_clients::iam::{Binding, IamPolicy}; +use alien_gcp_clients::pubsub::{OidcToken, PushConfig, Subscription, Topic}; + +use super::support::*; +use super::GcpWorkerController; +#[cfg(feature = "test-utils")] +use super::GcpWorkerState; + +// Separate impl block for helper methods +impl GcpWorkerController { + // ─────────────── HELPER METHODS ──────────────────────────── + + pub(super) async fn apply_command_topic_management_permissions( + &self, + ctx: &ResourceControllerContext<'_>, + topic_name: &str, + ) -> Result<()> { + let config = ctx.desired_resource_config::()?; + let command_refs: Vec<_> = ctx + .desired_stack + .management() + .profile() + .and_then(|management_profile| management_profile.0.get(&config.id)) + .into_iter() + .flat_map(|refs| refs.iter()) + .filter(|permission_set_ref| permission_set_ref.id() == "worker/dispatch-command") + .cloned() + .collect(); + + let gcp_config = ctx.get_gcp_config()?; + let mut permission_context = alien_permissions::PermissionContext::new() + .with_project_name(gcp_config.project_id.clone()) + .with_region(gcp_config.region.clone()) + .with_stack_prefix(ctx.resource_prefix.to_string()) + .with_resource_name(topic_name.to_string()); + if let Some(deployment_name) = ctx.deployment_name_for_metadata() { + permission_context = + permission_context.with_deployment_name(deployment_name.to_string()); + } + if let Some(ref project_number) = gcp_config.project_number { + permission_context = permission_context.with_project_number(project_number.clone()); + } + + let generator = alien_permissions::generators::GcpRuntimePermissionsGenerator::new(); + let mut all_bindings = Vec::new(); + ResourcePermissionsHelper::collect_gcp_management_bindings_for( + ctx, + &config.id, + topic_name, + &command_refs, + &generator, + &permission_context, + alien_permissions::generators::GcpBindingTargetScope::CurrentResource, + &mut all_bindings, + ) + .await?; + + let iam_policy = IamPolicy { + version: Some(3), + bindings: all_bindings, + etag: None, + kind: None, + resource_id: None, + }; + let bindings_count = iam_policy.bindings.len(); + + let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; + pubsub_client + .set_topic_iam_policy(topic_name.to_string(), iam_policy) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to apply management command permissions to Pub/Sub topic '{}'", + topic_name + ), + resource_id: Some(config.id.clone()), + })?; + + info!( + worker = %config.id, + topic = %topic_name, + bindings_count, + "Reconciled management command permissions on Pub/Sub topic" + ); + + Ok(()) + } + + /// Resolve domain information for a public worker. + /// Returns either custom domain config or auto-generated domain from metadata. + pub(super) fn resolve_domain_info( + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + ) -> Result { + let stack_settings = &ctx.deployment_config.stack_settings; + + // Check for custom domain configuration + if let Some(custom) = stack_settings + .domains + .as_ref() + .and_then(|domains| domains.custom_domains.as_ref()) + .and_then(|domains| domains.get(resource_id)) + { + let ssl_cert_name = custom + .certificate + .gcp + .as_ref() + .map(|cert| cert.certificate_name.clone()) + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Custom domain requires a GCP SSL certificate name".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + return Ok(DomainInfo { + fqdn: custom.domain.clone(), + certificate_id: None, + ssl_certificate_name: Some(ssl_cert_name), + uses_custom_domain: true, + }); + } + + // Use auto-generated domain from domain metadata + let metadata = ctx + .deployment_config + .domain_metadata + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Domain metadata missing for public resource".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + let resource = metadata.resources.get(resource_id).ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Domain metadata missing for resource".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + Ok(DomainInfo { + fqdn: resource.fqdn.clone(), + certificate_id: Some(resource.certificate_id.clone()), + ssl_certificate_name: None, + uses_custom_domain: false, + }) + } + + pub(super) fn ensure_domain_info( + &mut self, + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + ) -> Result { + if self.fqdn.is_some() + && (self.certificate_id.is_some() + || self.ssl_certificate_name.is_some() + || self.uses_custom_domain) + { + return Ok(true); + } + + match Self::resolve_domain_info(ctx, resource_id) { + Ok(domain_info) => { + self.fqdn = Some(domain_info.fqdn.clone()); + self.certificate_id = domain_info.certificate_id; + self.ssl_certificate_name = domain_info.ssl_certificate_name; + self.uses_custom_domain = domain_info.uses_custom_domain; + if self.url.is_none() { + self.url = ctx + .deployment_config + .public_endpoints + .as_ref() + .and_then(|resources| resources.get(resource_id)) + .and_then(|endpoints| endpoints.values().next().cloned()) + .or_else(|| Some(format!("https://{}", domain_info.fqdn))); + } + Ok(true) + } + Err(_) => Ok(false), + } + } + + pub(super) fn unexpected_update_wrapper_state( + resource_id: &str, + handler: &str, + state: GcpWorkerState, + ) -> AlienError { + AlienError::new(ErrorData::ResourceControllerConfigError { + resource_id: resource_id.to_string(), + message: format!("{handler} returned unexpected state during update: {state:?}"), + }) + } + + pub(super) async fn ensure_global_address_ip( + &mut self, + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + address_name: &str, + ) -> Result { + if let Some(ip_address) = &self.global_address_ip { + return Ok(ip_address.clone()); + } + + let gcp_config = ctx.get_gcp_config()?; + let compute_client = ctx.service_provider.get_gcp_compute_client(gcp_config)?; + let address = compute_client + .get_global_address(address_name.to_string()) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to get global address".to_string(), + resource_id: Some(resource_id.to_string()), + })?; + + let ip_address = address.address.ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "Global address has no IP".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + self.global_address_ip = Some(ip_address.clone()); + Ok(ip_address) + } + + pub(super) async fn build_cloud_run_service( + &self, + service_name: &str, + cfg: &Worker, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + use alien_gcp_clients::cloudrun::{ + Container, ContainerPort, EnvVar, ResourceRequirements, Service, + }; + + // Get the ServiceAccount for this worker's permission profile + let service_account_id = format!("{}-sa", cfg.get_permissions()); + let service_account_ref = ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + + // Get the ServiceAccount's email + let service_account_state = ctx + .require_dependency::( + &service_account_ref, + )?; + + let service_account = service_account_state + .service_account_email + .as_deref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: cfg.id().to_string(), + dependency_id: service_account_id.to_string(), + }) + })? + .to_string(); + let service_account = Some(service_account); + + // Extract container image + let image = match &cfg.code { + alien_core::WorkerCode::Image { image } => image.clone(), + alien_core::WorkerCode::Source { .. } => { + return Err(AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!( + "Worker '{}' is configured with source code, but only pre-built images are supported in alien-infra.", + cfg.id + ), + resource_id: Some(cfg.id.clone()), + })); + } + }; + + // Resolve proxy URIs to native GAR URIs. Cloud Run can only pull from GAR. + let image = if let Some(ref native_host) = ctx.deployment_config.native_image_host { + alien_core::image_rewrite::resolve_native_image_uri(&image, native_host) + .unwrap_or(image) + } else { + image + }; + + // Prepare environment variables + let env_vars = self + .prepare_environment_variables(&cfg.environment, &cfg.links, ctx, service_name) + .await?; + + let env: Vec = env_vars + .into_iter() + .map(|(name, value)| EnvVar { + name, + value: Some(value), + value_source: None, + }) + .collect(); + + // Cloud Run gen2 requires `memory >= 512 Mi`; that's enforced by the + // `WorkerMemoryCheck` preflight, so we trust cfg.memory_mb here. + let mut limits = HashMap::new(); + limits.insert("memory".to_string(), format!("{}Mi", cfg.memory_mb)); + // Cloud Run automatically allocates CPU based on memory + + let resources = ResourceRequirements { + limits: Some(limits), + cpu_idle: Some(true), // Allow CPU throttling when idle + startup_cpu_boost: Some(true), // Boost CPU during startup + }; + + // Build container port + let ports = vec![ContainerPort { + name: Some("http1".to_string()), + // NOTE: This must match the alien-worker-runtime port on alien-build/src/lib.rs + container_port: Some(8080), + }]; + + // Build container + let container = Container::builder() + .name("worker".to_string()) + .image(image) + .env(env) + .resources(resources) + .ports(ports) + .build(); + + let ingress = if cfg.public_endpoints.is_empty() { + CloudRunIngress::IngressTrafficInternal + } else { + CloudRunIngress::IngressTrafficAll + }; + + // Get VPC access configuration if a Network resource exists + let vpc_access = self.get_vpc_access(ctx)?; + if vpc_access.is_some() { + info!(name=%service_name, "Configuring Cloud Run service with Direct VPC Egress"); + } + + // Build revision template + let mut revision_labels = HashMap::from([("worker".to_string(), cfg.id.clone())]); + if self.image_pull_permission_retries > 0 { + revision_labels.insert( + "alien-image-pull-retry".to_string(), + self.image_pull_permission_retries.to_string(), + ); + } + + let template = RevisionTemplate::builder() + .labels(revision_labels) + .scaling( + alien_gcp_clients::cloudrun::RevisionScaling::builder() + .min_instance_count(0) // Scale to zero + .maybe_max_instance_count(cfg.concurrency_limit.map(|c| c as i32)) + .build(), + ) + .timeout(format!("{}s", cfg.timeout_seconds)) + .maybe_service_account(service_account) + .containers(vec![container]) + .execution_environment( + alien_gcp_clients::cloudrun::ExecutionEnvironment::ExecutionEnvironmentGen2, + ) + .max_instance_request_concurrency(1000) // Cloud Run default + .maybe_vpc_access(vpc_access) + .build(); + + // Build traffic target + let traffic = vec![TrafficTarget::builder() + .r#type(TrafficTargetAllocationType::TrafficTargetAllocationTypeLatest) + .percent(100) + .build()]; + + // Build service + // When ingress is public, disable the IAM invoker check instead of adding + // allUsers to IAM policy. This works even when the GCP organization has + // domain-restricted sharing enabled (which blocks allUsers in IAM). + let is_public = !cfg.public_endpoints.is_empty(); + let service = Service::builder() + .description(format!("Runtime worker: {}", cfg.id)) + .labels(HashMap::from([ + ("resource-type".to_string(), "worker".to_string()), + ("resource".to_string(), cfg.id.clone()), + ("deployment".to_string(), ctx.resource_prefix.to_string()), + ])) + .ingress(ingress) + .template(template) + .traffic(traffic) + .invoker_iam_disabled(is_public) + .build(); + + Ok(service) + } + + /// Gets VPC access configuration from the Network resource if one exists in the stack. + /// + /// If a Network resource exists (ID: "default-network"), this method retrieves + /// the network name and subnetwork name from the Network controller to configure + /// the Cloud Run service with Direct VPC Egress. + /// + /// Returns `None` if no Network resource exists in the stack. + fn get_vpc_access(&self, ctx: &ResourceControllerContext<'_>) -> Result> { + // Check if the stack has a Network resource + let network_id = "default-network"; + if !ctx.desired_stack.resources.contains_key(network_id) { + return Ok(None); + } + + // Get the Network controller state via require_dependency + let network_ref = ResourceRef::new(Network::RESOURCE_TYPE, network_id.to_string()); + let network_state = + ctx.require_dependency::(&network_ref)?; + + // Only configure VPC access if we have network and subnetwork names + let network_name = match &network_state.network_name { + Some(name) => name.clone(), + None => return Ok(None), + }; + + let subnetwork_name = match &network_state.subnetwork_name { + Some(name) => name.clone(), + None => return Ok(None), + }; + + // Build Direct VPC Egress configuration using network interfaces + let network_interface = NetworkInterface::builder() + .network(network_name) + .subnetwork(subnetwork_name) + .build(); + + Ok(Some( + VpcAccess::builder() + .egress(VpcEgress::AllTraffic) + .network_interfaces(vec![network_interface]) + .build(), + )) + } + + async fn prepare_environment_variables( + &self, + initial_env: &HashMap, + links: &[ResourceRef], + ctx: &ResourceControllerContext<'_>, + function_name_for_error_logging: &str, + ) -> Result> { + use crate::core::ResourceController; + let worker_config = ctx.desired_resource_config::()?; + + // Get the worker's own binding params (may be None during initial creation) + let self_binding_params = self.get_binding_params()?; + + let env_vars = EnvironmentVariableBuilder::try_new(initial_env)? + .add_worker_runtime_env_vars(ctx, &worker_config.id, worker_config.timeout_seconds)? + .add_linked_resources(links, ctx, function_name_for_error_logging) + .await? + .add_self_worker_binding(&worker_config.id, self_binding_params.as_ref())? + .build(); + + Ok(env_vars) + } + + /// Applies consolidated IAM policy (resource-scoped permissions + public access) in a single operation + pub(super) async fn apply_consolidated_iam_policy( + &self, + ctx: &ResourceControllerContext<'_>, + service_name: &str, + enable_public_access: bool, + ) -> Result<()> { + use alien_gcp_clients::iam::Binding; + + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + let client = ctx.service_provider.get_gcp_cloudrun_client(gcp_config)?; + + // Get existing IAM policy to preserve any existing bindings + let mut policy = client + .get_service_iam_policy(gcp_config.region.clone(), service_name.to_string()) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to get IAM policy for Cloud Run service '{}' before applying bindings. Refusing to proceed to avoid overwriting existing bindings.", service_name), + resource_id: Some(config.id.clone()), + })?; + + // Step 1: Apply resource-scoped permissions from the stack + let mut resource_bindings = Vec::new(); + self.collect_resource_scoped_bindings(ctx, service_name, &mut resource_bindings) + .await?; + + // Step 2: Add public access binding if needed + if enable_public_access { + info!(service_name = %service_name, "Adding public access to IAM policy"); + let invoker_role = "roles/run.invoker".to_string(); + let all_users_member = "allUsers".to_string(); + + // Check if binding already exists + let binding_exists = policy + .bindings + .iter() + .any(|b| b.role == invoker_role && b.members.contains(&all_users_member)); + + if !binding_exists { + // Find existing binding or create new one + if let Some(binding) = policy.bindings.iter_mut().find(|b| b.role == invoker_role) { + if !binding.members.contains(&all_users_member) { + binding.members.push(all_users_member); + } + } else { + policy.bindings.push( + Binding::builder() + .role(invoker_role) + .members(vec![all_users_member]) + .build(), + ); + } + } + } + + // Step 3: Add resource-scoped bindings + if !resource_bindings.is_empty() { + info!( + service_name = %service_name, + bindings_count = resource_bindings.len(), + "Adding resource-scoped permissions to IAM policy" + ); + policy.bindings.extend(resource_bindings); + } + + // Step 4: Apply the consolidated policy in one operation + client + .set_service_iam_policy(gcp_config.region.clone(), service_name.to_string(), policy) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to apply consolidated IAM policy to Cloud Run service '{}'", + service_name + ), + resource_id: Some(config.id.clone()), + })?; + + info!(service_name = %service_name, "Consolidated IAM policy applied successfully"); + Ok(()) + } + + /// Collect resource-scoped bindings without applying them + async fn collect_resource_scoped_bindings( + &self, + ctx: &ResourceControllerContext<'_>, + service_name: &str, + all_bindings: &mut Vec, + ) -> Result<()> { + use alien_permissions::{generators::GcpRuntimePermissionsGenerator, PermissionContext}; + + let config = ctx.desired_resource_config::()?; + let gcp_config = ctx.get_gcp_config()?; + + // Build permission context for this specific worker resource + let mut permission_context = PermissionContext::new() + .with_project_name(gcp_config.project_id.clone()) + .with_region(gcp_config.region.clone()) + .with_stack_prefix(ctx.resource_prefix.to_string()) + .with_resource_name(service_name.to_string()); + if let Some(deployment_name) = ctx.deployment_name_for_metadata() { + permission_context = + permission_context.with_deployment_name(deployment_name.to_string()); + } + if let Some(ref project_number) = gcp_config.project_number { + permission_context = permission_context.with_project_number(project_number.clone()); + } + + let generator = GcpRuntimePermissionsGenerator::new(); + let type_prefix = "worker/"; + + // Process each permission profile in the stack + for (profile_name, profile) in &ctx.desired_stack.permissions.profiles { + // Combine resource-specific permissions with matching wildcard permissions + let mut combined_refs: Vec = + Vec::new(); + + if let Some(permission_set_refs) = profile.0.get(&config.id) { + combined_refs.extend( + permission_set_refs + .iter() + .filter(|r| r.id() != "worker/dispatch-command") + .cloned(), + ); + } + + if let Some(wildcard_refs) = profile.0.get("*") { + combined_refs.extend( + wildcard_refs + .iter() + .filter(|r| r.id().starts_with(type_prefix)) + .filter(|r| r.id() != "worker/dispatch-command") + .cloned(), + ); + } + + if !combined_refs.is_empty() { + info!( + service_name = %service_name, + profile = %profile_name, + permission_sets = ?combined_refs.iter().map(|r| r.id()).collect::>(), + "Processing resource-scoped permissions for worker" + ); + + self.process_profile_permissions( + ctx, + profile_name, + &combined_refs, + &generator, + &permission_context, + all_bindings, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to process permissions for profile '{}' on worker '{}'", + profile_name, service_name + ), + resource_id: Some(config.id.clone()), + })?; + } + } + + // Process management SA permissions matching the worker resource type + if let Some(management_profile) = ctx.desired_stack.management().profile() { + let mut management_refs: Vec = + Vec::new(); + + if let Some(permission_set_refs) = management_profile.0.get(&config.id) { + management_refs.extend( + permission_set_refs + .iter() + .filter(|r| r.id().starts_with(type_prefix)) + .filter(|r| r.id() != "worker/dispatch-command") + .cloned(), + ); + } + + if let Some(wildcard_refs) = management_profile.0.get("*") { + management_refs.extend( + wildcard_refs + .iter() + .filter(|r| r.id().starts_with(type_prefix)) + .filter(|r| r.id() != "worker/dispatch-command") + .cloned(), + ); + } + + if !management_refs.is_empty() { + use crate::core::ResourcePermissionsHelper; + ResourcePermissionsHelper::collect_gcp_management_bindings_for( + ctx, + &config.id, + service_name, + &management_refs, + &generator, + &permission_context, + alien_permissions::generators::GcpBindingTargetScope::CurrentResource, + all_bindings, + ) + .await?; + } + } + + Ok(()) + } + + /// Process permissions for a specific profile + async fn process_profile_permissions( + &self, + ctx: &ResourceControllerContext<'_>, + profile_name: &str, + permission_set_refs: &[alien_core::permissions::PermissionSetReference], + generator: &alien_permissions::generators::GcpRuntimePermissionsGenerator, + permission_context: &alien_permissions::PermissionContext, + all_bindings: &mut Vec, + ) -> Result<()> { + use alien_gcp_clients::iam::{Binding, Expr}; + use alien_permissions::BindingTarget; + + // Get the service account email for this profile + let service_account_email = + self.get_service_account_email_for_profile(ctx, profile_name)?; + + // Process each permission set for this resource + for permission_set_ref in permission_set_refs { + let permission_set = permission_set_ref + .resolve(|name| alien_permissions::get_permission_set(name).cloned()) + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!("Permission set '{}' not found", permission_set_ref.id()), + resource_id: Some(profile_name.to_string()), + }) + })?; + + let grant_plan = generator + .generate_grant_plan(&permission_set, BindingTarget::Resource, permission_context) + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to generate bindings for permission set '{}'", + permission_set.id + ), + resource_id: Some(profile_name.to_string()), + })?; + let selected_bindings = grant_plan.bindings_for_target( + alien_permissions::generators::GcpBindingTargetScope::CurrentResource, + ); + + // Convert and add bindings + let member = format!("serviceAccount:{}", service_account_email); + for binding in selected_bindings { + all_bindings.push(Binding { + role: binding.role, + members: vec![member.clone()], + condition: binding.condition.map(|cond| Expr { + title: Some(cond.title), + description: Some(cond.description), + expression: cond.expression, + location: None, + }), + }); + } + } + + Ok(()) + } + + /// Get the service account email for a permission profile + fn get_service_account_email_for_profile( + &self, + ctx: &ResourceControllerContext<'_>, + profile_name: &str, + ) -> Result { + let service_account_id = format!("{}-sa", profile_name); + let service_account_resource = ctx + .desired_stack + .resources + .get(&service_account_id) + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!( + "Service account resource '{}' not found for profile '{}'", + service_account_id, profile_name + ), + resource_id: Some(profile_name.to_string()), + }) + })?; + + let service_account_controller = ctx + .require_dependency::( + &(&service_account_resource.config).into(), + )?; + + service_account_controller + .service_account_email + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: "worker".to_string(), + dependency_id: profile_name.to_string(), + }) + }) + } + + /// Creates a Pub/Sub push subscription for a queue trigger + pub(super) async fn create_push_subscription( + &mut self, + ctx: &ResourceControllerContext<'_>, + gcp_config: &alien_gcp_clients::GcpClientConfig, + _service_name: &str, + worker_config: &alien_core::Worker, + queue_ref: &alien_core::ResourceRef, + ) -> Result<()> { + let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; + + // Get queue controller to access the topic name + let queue_controller = + ctx.require_dependency::(queue_ref)?; + let topic_name = queue_controller.topic_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: queue_ref.id.clone(), + }) + })?; + let topic_full_name = format!("projects/{}/topics/{}", gcp_config.project_id, topic_name); + + // Generate push subscription name: stack-prefix-worker-id-queue-id + let subscription_name = format!( + "{}-{}-{}", + ctx.resource_prefix, worker_config.id, queue_ref.id + ); + + // Get the service URL for push endpoint + let service_url = self.url.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceControllerConfigError { + resource_id: worker_config.id.clone(), + message: "Service URL not available for push subscription".to_string(), + }) + })?; + + // Build push endpoint URL (Cloud Run service URL) + let push_endpoint = format!("{}/", service_url.trim_end_matches('/')); + + // Get service account email for OIDC authentication + let service_account_id = format!("{}-sa", worker_config.get_permissions()); + let service_account_ref = ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + + let service_account_state = ctx + .require_dependency::( + &service_account_ref, + )?; + let service_account_email = service_account_state + .service_account_email + .as_deref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id().to_string(), + dependency_id: service_account_id.to_string(), + }) + })? + .to_string(); + + // Create push config with OIDC authentication + let oidc_token = OidcToken { + service_account_email: service_account_email.clone(), + audience: Some(push_endpoint.clone()), + }; + + let push_config = PushConfig { + push_endpoint: Some(push_endpoint.clone()), + attributes: Some(std::collections::HashMap::new()), + oidc_token: Some(oidc_token), + pubsub_wrapper: None, + no_wrapper: None, + }; + + let subscription = Subscription { + name: Some(subscription_name.clone()), + topic: Some(topic_full_name.clone()), + push_config: Some(push_config), + ack_deadline_seconds: Some(worker_config.timeout_seconds as i32), + retain_acked_messages: Some(false), + message_retention_duration: None, + labels: Some(std::collections::HashMap::from([ + ("worker".to_string(), worker_config.id.clone()), + ("deployment".to_string(), ctx.resource_prefix.to_string()), + ])), + enable_message_ordering: Some(false), + expiration_policy: None, + filter: None, + dead_letter_policy: None, + retry_policy: None, + detached: Some(false), + state: None, + analytics_hub_subscription_info: None, + bigquery_config: None, + cloud_storage_config: None, + }; + + info!( + worker=%worker_config.id, + topic=%topic_full_name, + subscription=%subscription_name, + endpoint=%push_endpoint, + "Creating Pub/Sub push subscription" + ); + + match pubsub_client + .create_subscription(subscription_name.clone(), subscription) + .await + { + Ok(_) => {} + Err(e) if is_remote_resource_conflict(&e) => { + info!( + worker=%worker_config.id, + subscription=%subscription_name, + "Pub/Sub push subscription already exists; treating as created" + ); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create push subscription '{}' for queue '{}'", + subscription_name, queue_ref.id + ), + resource_id: Some(worker_config.id.clone()), + })); + } + } + + if !self.push_subscriptions.contains(&subscription_name) { + self.push_subscriptions.push(subscription_name.clone()); + } + + info!( + worker=%worker_config.id, + subscription=%subscription_name, + "Successfully created Pub/Sub push subscription" + ); + + Ok(()) + } + + /// Deletes all push subscriptions using best-effort approach + pub(super) async fn delete_all_push_subscriptions( + &mut self, + ctx: &ResourceControllerContext<'_>, + gcp_config: &alien_gcp_clients::GcpClientConfig, + ) -> Result<()> { + if self.push_subscriptions.is_empty() { + return Ok(()); + } + + let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; + let worker_config = ctx.desired_resource_config::()?; + + for subscription_name in &self.push_subscriptions.clone() { + match pubsub_client + .delete_subscription(subscription_name.clone()) + .await + { + Ok(_) => { + info!( + worker=%worker_config.id, + subscription=%subscription_name, + "Push subscription deleted successfully" + ); + } + Err(e) + if matches!( + e.error, + Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!( + worker=%worker_config.id, + subscription=%subscription_name, + "Push subscription was already deleted (not found)" + ); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to delete push subscription '{}'", + subscription_name + ), + resource_id: Some(worker_config.id.clone()), + })); + } + } + } + + self.push_subscriptions.clear(); + Ok(()) + } + + /// Gets the service account email for the worker's permission profile. + pub(super) fn get_service_account_email( + &self, + ctx: &ResourceControllerContext<'_>, + worker_config: &alien_core::Worker, + ) -> Result { + let service_account_id = format!("{}-sa", worker_config.get_permissions()); + let service_account_ref = ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + + let service_account_state = ctx + .require_dependency::( + &service_account_ref, + )?; + + service_account_state.service_account_email.ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id().to_string(), + dependency_id: service_account_id, + }) + }) + } + + /// Creates storage trigger infrastructure: Pub/Sub topic, GCS notification, and push subscription. + pub(super) async fn create_storage_trigger( + &mut self, + ctx: &ResourceControllerContext<'_>, + gcp_config: &alien_gcp_clients::GcpClientConfig, + _service_name: &str, + worker_config: &alien_core::Worker, + storage_ref: &alien_core::ResourceRef, + events: &[String], + ) -> Result<()> { + let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; + let gcs_client = ctx.service_provider.get_gcp_gcs_client(gcp_config)?; + + // Get bucket name from the storage controller dependency + let storage_controller = + ctx.require_dependency::(storage_ref)?; + let bucket_name = storage_controller.bucket_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: storage_ref.id.clone(), + }) + })?; + + // 1. Create a dedicated Pub/Sub topic for this storage notification + let topic_short_name = format!( + "{}-{}-{}-notif", + ctx.resource_prefix, worker_config.id, storage_ref.id + ); + let topic_full_name = format!( + "projects/{}/topics/{}", + gcp_config.project_id, topic_short_name + ); + + info!( + worker=%worker_config.id, + storage=%storage_ref.id, + topic=%topic_full_name, + "Creating Pub/Sub topic for storage notifications" + ); + + match pubsub_client + .create_topic(topic_short_name.clone(), Topic::default()) + .await + { + Ok(_) => {} + Err(e) if is_remote_resource_conflict(&e) => { + info!( + worker=%worker_config.id, + topic=%topic_short_name, + "Storage notification topic already exists; treating as created" + ); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create storage notification topic '{}'", + topic_short_name + ), + resource_id: Some(worker_config.id.clone()), + })); + } + } + + if !self.storage_notification_topics.contains(&topic_short_name) { + self.storage_notification_topics + .push(topic_short_name.clone()); + } + + // 2. Ask Cloud Storage for its managed service account before granting it + // publish permissions. Deriving the email from the project number does + // not ensure that the service account has been provisioned yet. + let gcs_project_service_account = gcs_client.get_project_service_account().await.context( + ErrorData::CloudPlatformError { + message: format!( + "Failed to get the Cloud Storage service account for project '{}'", + gcp_config.project_id + ), + resource_id: Some(worker_config.id.clone()), + }, + )?; + let gcs_service_agent = format!( + "serviceAccount:{}", + gcs_project_service_account.email_address + ); + + let iam_policy = alien_gcp_clients::iam::IamPolicy::builder() + .version(1) + .bindings(vec![Binding { + role: "roles/pubsub.publisher".to_string(), + members: vec![gcs_service_agent], + condition: None, + }]) + .build(); + + pubsub_client + .set_topic_iam_policy(topic_short_name.clone(), iam_policy) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to set IAM policy on storage notification topic '{}'", + topic_short_name + ), + resource_id: Some(worker_config.id.clone()), + })?; + + // 3. Create GCS notification on the bucket pointing to the topic + let gcs_event_types: Vec = events + .iter() + .map(|event| { + match event.as_str() { + "created" => "OBJECT_FINALIZE".to_string(), + "deleted" => "OBJECT_DELETE".to_string(), + "archived" => "OBJECT_ARCHIVE".to_string(), + "metadataUpdated" => "OBJECT_METADATA_UPDATE".to_string(), + other => other.to_string(), // Pass through unknown events as-is + } + }) + .collect(); + + let notification = GcsNotification { + id: None, + topic: Some(topic_full_name.clone()), + event_types: gcs_event_types, + payload_format: Some("JSON_API_V1".to_string()), + object_name_prefix: None, + custom_attributes: std::collections::HashMap::new(), + }; + + let existing_notification = gcs_client + .list_notifications(bucket_name.clone()) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to list GCS notifications on bucket '{}' for worker '{}'", + bucket_name, worker_config.id + ), + resource_id: Some(worker_config.id.clone()), + })? + .items + .into_iter() + .find(|existing| gcs_notification_matches_existing(existing, ¬ification)); + + let created_notification = if let Some(existing_notification) = existing_notification { + info!( + worker=%worker_config.id, + storage=%storage_ref.id, + bucket=%bucket_name, + notification_id=?existing_notification.id, + "GCS notification already exists; treating as created" + ); + existing_notification + } else { + gcs_client + .insert_notification(bucket_name.clone(), notification) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create GCS notification on bucket '{}' for worker '{}'", + bucket_name, worker_config.id + ), + resource_id: Some(worker_config.id.clone()), + })? + }; + + if let Some(notification_id) = &created_notification.id { + if !self.gcs_notification_ids.iter().any(|tracker| { + tracker.bucket_name == *bucket_name && tracker.notification_id == *notification_id + }) { + self.gcs_notification_ids.push(GcsNotificationTracker { + bucket_name: bucket_name.clone(), + notification_id: notification_id.clone(), + }); + } + } + + // 4. Create a push subscription to the Cloud Run URL + let service_url = self.url.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceControllerConfigError { + resource_id: worker_config.id.clone(), + message: "Service URL not available for storage trigger push subscription" + .to_string(), + }) + })?; + + let push_endpoint = format!("{}/", service_url.trim_end_matches('/')); + + // Get service account email for OIDC authentication + let service_account_email = self.get_service_account_email(ctx, worker_config)?; + + let oidc_token = OidcToken { + service_account_email, + audience: Some(push_endpoint.clone()), + }; + + let subscription_name = format!( + "{}-{}-{}-notif-sub", + ctx.resource_prefix, worker_config.id, storage_ref.id + ); + + let push_config = PushConfig { + push_endpoint: Some(push_endpoint), + attributes: Some(std::collections::HashMap::new()), + oidc_token: Some(oidc_token), + pubsub_wrapper: None, + no_wrapper: None, + }; + + let subscription = Subscription { + name: Some(subscription_name.clone()), + topic: Some(topic_full_name.clone()), + push_config: Some(push_config), + ack_deadline_seconds: Some(worker_config.timeout_seconds as i32), + retain_acked_messages: Some(false), + message_retention_duration: None, + labels: Some(std::collections::HashMap::from([ + ("worker".to_string(), worker_config.id.clone()), + ("deployment".to_string(), ctx.resource_prefix.to_string()), + ("storage".to_string(), storage_ref.id.clone()), + ])), + enable_message_ordering: Some(false), + expiration_policy: None, + filter: None, + dead_letter_policy: None, + retry_policy: None, + detached: Some(false), + state: None, + analytics_hub_subscription_info: None, + bigquery_config: None, + cloud_storage_config: None, + }; + + info!( + worker=%worker_config.id, + storage=%storage_ref.id, + subscription=%subscription_name, + "Creating Pub/Sub push subscription for storage trigger" + ); + + match pubsub_client + .create_subscription(subscription_name.clone(), subscription) + .await + { + Ok(_) => {} + Err(e) if is_remote_resource_conflict(&e) => { + info!( + worker=%worker_config.id, + subscription=%subscription_name, + "Storage trigger push subscription already exists; treating as created" + ); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create push subscription '{}' for storage trigger '{}'", + subscription_name, storage_ref.id + ), + resource_id: Some(worker_config.id.clone()), + })); + } + } + + if !self.push_subscriptions.contains(&subscription_name) { + self.push_subscriptions.push(subscription_name); + } + + info!( + worker=%worker_config.id, + storage=%storage_ref.id, + "Successfully created storage trigger infrastructure" + ); + + Ok(()) + } + + /// Deletes all GCS notifications (best-effort) + pub(super) async fn delete_all_storage_notifications( + &mut self, + ctx: &ResourceControllerContext<'_>, + gcp_config: &alien_gcp_clients::GcpClientConfig, + ) -> Result<()> { + if self.gcs_notification_ids.is_empty() { + return Ok(()); + } + + let gcs_client = ctx.service_provider.get_gcp_gcs_client(gcp_config)?; + let worker_config = ctx.desired_resource_config::()?; + + for tracker in &self.gcs_notification_ids.clone() { + match gcs_client + .delete_notification(tracker.bucket_name.clone(), tracker.notification_id.clone()) + .await + { + Ok(_) => { + info!( + worker=%worker_config.id, + bucket=%tracker.bucket_name, + notification_id=%tracker.notification_id, + "GCS notification deleted successfully" + ); + } + Err(e) => { + warn!( + worker=%worker_config.id, + bucket=%tracker.bucket_name, + notification_id=%tracker.notification_id, + error=%e, + "Failed to delete GCS notification (best-effort, continuing)" + ); + } + } + } + + self.gcs_notification_ids.clear(); + Ok(()) + } + + /// Deletes all storage notification Pub/Sub topics (best-effort) + pub(super) async fn delete_all_storage_notification_topics( + &mut self, + ctx: &ResourceControllerContext<'_>, + gcp_config: &alien_gcp_clients::GcpClientConfig, + ) -> Result<()> { + if self.storage_notification_topics.is_empty() { + return Ok(()); + } + + let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; + let worker_config = ctx.desired_resource_config::()?; + + for topic_name in &self.storage_notification_topics.clone() { + match pubsub_client.delete_topic(topic_name.clone()).await { + Ok(_) => { + info!( + worker=%worker_config.id, + topic=%topic_name, + "Storage notification topic deleted successfully" + ); + } + Err(e) => { + warn!( + worker=%worker_config.id, + topic=%topic_name, + error=%e, + "Failed to delete storage notification topic (best-effort, continuing)" + ); + } + } + } + + self.storage_notification_topics.clear(); + Ok(()) + } + + /// Deletes all Cloud Scheduler jobs (best-effort) + pub(super) async fn delete_all_scheduler_jobs( + &mut self, + ctx: &ResourceControllerContext<'_>, + gcp_config: &alien_gcp_clients::GcpClientConfig, + ) -> Result<()> { + if self.scheduler_job_names.is_empty() { + return Ok(()); + } + + let scheduler_client = ctx + .service_provider + .get_gcp_cloud_scheduler_client(gcp_config)?; + let worker_config = ctx.desired_resource_config::()?; + + for job_name in &self.scheduler_job_names.clone() { + match scheduler_client.delete_job(job_name.clone()).await { + Ok(_) => { + info!( + worker=%worker_config.id, + job=%job_name, + "Cloud Scheduler job deleted successfully" + ); + } + Err(e) => { + warn!( + worker=%worker_config.id, + job=%job_name, + error=%e, + "Failed to delete Cloud Scheduler job (best-effort, continuing)" + ); + } + } + } + + self.scheduler_job_names.clear(); + Ok(()) + } + + /// Creates a controller in a ready state with mock values for testing purposes. + #[cfg(feature = "test-utils")] + pub fn mock_ready(function_name: &str) -> Self { + Self { + state: GcpWorkerState::Ready, + service_name: Some(function_name.to_string()), + url: Some(format!("https://{}-abcd1234-uc.a.run.app", function_name)), + operation_name: None, + image_pull_permission_retries: 0, + compute_operation_name: None, + compute_operation_region: None, + push_subscriptions: Vec::new(), + storage_notification_topics: Vec::new(), + gcs_notification_ids: Vec::new(), + scheduler_job_names: Vec::new(), + fqdn: None, + certificate_id: None, + ssl_certificate_name: None, + uses_custom_domain: false, + certificate_issued_at: None, + serverless_neg_name: None, + backend_service_name: None, + url_map_name: None, + target_https_proxy_name: None, + global_address_name: None, + global_address_ip: None, + forwarding_rule_name: None, + project_id: Some("test-project".to_string()), + region: Some("us-central1".to_string()), + commands_topic_name: None, + commands_subscription_name: None, + _internal_stay_count: None, + } + } +} diff --git a/crates/alien-infra/src/worker/gcp.rs b/crates/alien-infra/src/worker/gcp/mod.rs similarity index 55% rename from crates/alien-infra/src/worker/gcp.rs rename to crates/alien-infra/src/worker/gcp/mod.rs index 01fb1b9d6..2f6c8b5d4 100644 --- a/crates/alien-infra/src/worker/gcp.rs +++ b/crates/alien-infra/src/worker/gcp/mod.rs @@ -1,17 +1,10 @@ -use std::collections::{HashMap, HashSet}; use std::time::Duration; use tracing::{debug, info, warn}; -use crate::core::{EnvironmentVariableBuilder, ResourcePermissionsHelper}; - use crate::core::ResourceControllerContext; use crate::error::{ErrorData, Result}; use crate::worker::{run_readiness_probe, READINESS_PROBE_MAX_ATTEMPTS}; use alien_client_core::ErrorData as CloudClientErrorData; -use alien_gcp_clients::cloudrun::{ - Ingress as CloudRunIngress, NetworkInterface, RevisionTemplate, Service, TrafficTarget, - TrafficTargetAllocationType, VpcAccess, VpcEgress, -}; use alien_gcp_clients::cloudscheduler::{HttpTarget, SchedulerJob, SchedulerOidcToken}; use alien_gcp_clients::compute::{ Address, AddressType, Backend, BackendService, BackendServiceProtocol, BalancingMode, @@ -19,250 +12,24 @@ use alien_gcp_clients::compute::{ NetworkEndpointGroupCloudRun, NetworkEndpointType, Operation as ComputeOperation, SslCertificate, SslCertificateSelfManaged, TargetHttpsProxy, UrlMap, }; -use alien_gcp_clients::gcs::GcsNotification; -use alien_gcp_clients::iam::{Binding, IamPolicy}; use alien_gcp_clients::longrunning::OperationResult; use alien_gcp_clients::pubsub::{OidcToken, PushConfig, Subscription, Topic}; // Note: Role controller removed - workers now use ServiceAccount and permission profiles use alien_core::{ - CertificateStatus, DnsRecordStatus, GcpCloudRunWorkerHeartbeatData, HeartbeatBackend, Network, - ObservedHealth, Platform, ProviderLifecycleState, ResourceDefinition, ResourceHeartbeat, - ResourceHeartbeatData, ResourceOutputs, ResourceRef, ResourceStatus, Worker, - WorkerHeartbeatData, WorkerOutputs, WorkloadHeartbeatStatus, + CertificateStatus, DnsRecordStatus, ResourceDefinition, ResourceOutputs, ResourceRef, + ResourceStatus, Worker, WorkerOutputs, }; -use alien_error::{AlienError, Context, ContextError, GenericError, IntoAlienError}; +use alien_error::{AlienError, Context, ContextError, IntoAlienError}; use alien_macros::controller; -use chrono::Utc; -use sha2::{Digest, Sha256}; - -const CLOUD_RUN_SERVICE_NAME_MAX_LEN: usize = 49; -const GCP_RESOURCE_NAME_MAX_LEN: usize = 63; -const GCP_RESOURCE_NAME_HASH_LEN: usize = 8; -const MAX_IMAGE_PULL_PERMISSION_RETRIES: u8 = 4; - -fn is_cross_project_image_pull_permission_error(message: &str) -> bool { - message.contains("serverless-robot-prod.iam.gserviceaccount.com") - && message.contains("artifactregistry.repositories.downloadArtifacts") -} - -fn image_pull_permission_retry_delay(attempt: u8) -> Duration { - Duration::from_secs(10 * (1_u64 << attempt.saturating_sub(1).min(3))) -} - -fn is_remote_resource_conflict(error: &AlienError) -> bool { - matches!( - &error.error, - Some(CloudClientErrorData::RemoteResourceConflict { .. }) - ) -} - -fn error_chain_contains_resource_in_use( - code: &str, - message: &str, - source: Option<&AlienError>, -) -> bool { - (code == "INVALID_INPUT" || code == "HTTP_RESPONSE_ERROR") - && (message.contains("being used by") || message.contains("resourceInUseByAnotherResource")) - || source.is_some_and(|source| { - error_chain_contains_resource_in_use( - &source.code, - &source.message, - source.source.as_deref(), - ) - }) -} - -fn is_gcp_resource_in_use(error: &AlienError) -> bool { - error_chain_contains_resource_in_use(&error.code, &error.message, error.source.as_deref()) -} - -fn same_unordered_strings(left: &[String], right: &[String]) -> bool { - left.iter().collect::>() == right.iter().collect::>() -} - -fn gcs_notification_matches_existing( - existing: &GcsNotification, - desired: &GcsNotification, -) -> bool { - existing.topic == desired.topic - && same_unordered_strings(&existing.event_types, &desired.event_types) - && existing.payload_format == desired.payload_format - && existing.object_name_prefix == desired.object_name_prefix - && existing.custom_attributes == desired.custom_attributes -} - -/// Generates the Cloud Run service name from stack prefix and worker ID -fn get_cloudrun_service_name(prefix: &str, name: &str) -> String { - let raw = format!("{}-{}", prefix, name); - let sanitized = sanitize_gcp_resource_name(&raw); - - if sanitized == raw && sanitized.len() <= CLOUD_RUN_SERVICE_NAME_MAX_LEN { - return sanitized; - } - - stable_hashed_gcp_resource_name(&raw, &sanitized, CLOUD_RUN_SERVICE_NAME_MAX_LEN) -} - -fn get_gcp_worker_resource_name(prefix: &str, worker_id: &str, suffix: &str) -> String { - let raw = format!("{prefix}-{worker_id}-{suffix}"); - let sanitized = sanitize_gcp_resource_name(&raw); - - if sanitized == raw && sanitized.len() <= GCP_RESOURCE_NAME_MAX_LEN { - return sanitized; - } - - stable_hashed_gcp_resource_name(&raw, &sanitized, GCP_RESOURCE_NAME_MAX_LEN) -} - -fn sanitize_gcp_resource_name(raw: &str) -> String { - let mut name = String::with_capacity(raw.len()); - let mut last_was_dash = false; - - for ch in raw.chars() { - let normalized = match ch { - 'a'..='z' | '0'..='9' => Some(ch), - 'A'..='Z' => Some(ch.to_ascii_lowercase()), - '-' => Some('-'), - _ => Some('-'), - }; - if let Some(ch) = normalized { - if ch == '-' { - if !last_was_dash && !name.is_empty() { - name.push(ch); - } - last_was_dash = true; - } else { - name.push(ch); - last_was_dash = false; - } - } - } - - while name.ends_with('-') { - name.pop(); - } - - if !name - .chars() - .next() - .map(|ch| ch.is_ascii_lowercase()) - .unwrap_or(false) - { - name.insert_str(0, "a-"); - } - - name -} - -fn stable_hashed_gcp_resource_name(raw: &str, sanitized: &str, max_len: usize) -> String { - let hash = stable_name_hash(raw); - let max_stem_len = max_len - GCP_RESOURCE_NAME_HASH_LEN - "-".len(); - let mut stem = sanitized - .chars() - .take(max_stem_len) - .collect::() - .trim_end_matches('-') - .to_string(); - - if stem.is_empty() { - stem = "a".to_string(); - } - - format!("{stem}-{hash}") -} - -fn stable_name_hash(raw: &str) -> String { - let digest = Sha256::digest(raw.as_bytes()); - digest - .iter() - .take(GCP_RESOURCE_NAME_HASH_LEN / 2) - .map(|byte| format!("{byte:02x}")) - .collect() -} - -/// Domain information for a worker. -struct DomainInfo { - fqdn: String, - certificate_id: Option, - ssl_certificate_name: Option, - uses_custom_domain: bool, -} +mod helpers; +mod support; +#[cfg(test)] +mod tests; -fn emit_gcp_cloud_run_worker_heartbeat( - ctx: &ResourceControllerContext<'_>, - worker_config: &Worker, - service_name: &str, - service: &Service, -) { - let container = service - .template - .as_ref() - .and_then(|template| template.containers.first()); - let limits = container.and_then(|container| { - container - .resources - .as_ref() - .and_then(|resources| resources.limits.as_ref()) - }); - let scaling = service.scaling.as_ref(); - - ctx.emit_heartbeat(ResourceHeartbeat { - deployment_id: None, - resource_id: worker_config.id.clone(), - resource_type: Worker::RESOURCE_TYPE, - controller_platform: Platform::Gcp, - backend: HeartbeatBackend::Gcp, - observed_at: Utc::now(), - data: ResourceHeartbeatData::Worker(WorkerHeartbeatData::GcpCloudRun( - GcpCloudRunWorkerHeartbeatData { - status: WorkloadHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: Some(format!("Cloud Run service '{service_name}' is ready")), - stale: false, - partial: false, - collection_issues: vec![], - }, - service: service_name.to_string(), - region: Some( - ctx.get_gcp_config() - .map(|config| config.region.clone()) - .unwrap_or_default(), - ), - uri: service.uri.clone(), - urls: service.urls.clone(), - latest_created_revision: service.latest_created_revision.clone(), - latest_ready_revision: service.latest_ready_revision.clone(), - generation: service - .generation - .as_deref() - .and_then(|generation| generation.parse::().ok()), - observed_generation: service - .observed_generation - .as_deref() - .and_then(|generation| generation.parse::().ok()), - traffic_count: service.traffic.len() as u32, - min_instance_count: scaling.and_then(|scaling| scaling.min_instance_count), - max_instance_count: scaling.and_then(|scaling| scaling.max_instance_count), - container_image: container.map(|container| container.image.clone()), - cpu_limit: limits.and_then(|limits| limits.get("cpu").cloned()), - memory_limit: limits.and_then(|limits| limits.get("memory").cloned()), - }, - )), - raw: vec![], - }); -} +use support::*; -/// Tracks a GCS notification configuration for cleanup during deletion. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GcsNotificationTracker { - /// The bucket the notification is attached to - pub bucket_name: String, - /// The server-assigned notification ID - pub notification_id: String, -} +pub use support::GcsNotificationTracker; #[controller] pub struct GcpWorkerController { @@ -4216,2855 +3983,3 @@ impl GcpWorkerController { } } } - -// Separate impl block for helper methods -impl GcpWorkerController { - // ─────────────── HELPER METHODS ──────────────────────────── - - async fn apply_command_topic_management_permissions( - &self, - ctx: &ResourceControllerContext<'_>, - topic_name: &str, - ) -> Result<()> { - let config = ctx.desired_resource_config::()?; - let command_refs: Vec<_> = ctx - .desired_stack - .management() - .profile() - .and_then(|management_profile| management_profile.0.get(&config.id)) - .into_iter() - .flat_map(|refs| refs.iter()) - .filter(|permission_set_ref| permission_set_ref.id() == "worker/dispatch-command") - .cloned() - .collect(); - - let gcp_config = ctx.get_gcp_config()?; - let mut permission_context = alien_permissions::PermissionContext::new() - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_resource_name(topic_name.to_string()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } - - let generator = alien_permissions::generators::GcpRuntimePermissionsGenerator::new(); - let mut all_bindings = Vec::new(); - ResourcePermissionsHelper::collect_gcp_management_bindings_for( - ctx, - &config.id, - topic_name, - &command_refs, - &generator, - &permission_context, - alien_permissions::generators::GcpBindingTargetScope::CurrentResource, - &mut all_bindings, - ) - .await?; - - let iam_policy = IamPolicy { - version: Some(3), - bindings: all_bindings, - etag: None, - kind: None, - resource_id: None, - }; - let bindings_count = iam_policy.bindings.len(); - - let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; - pubsub_client - .set_topic_iam_policy(topic_name.to_string(), iam_policy) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to apply management command permissions to Pub/Sub topic '{}'", - topic_name - ), - resource_id: Some(config.id.clone()), - })?; - - info!( - worker = %config.id, - topic = %topic_name, - bindings_count, - "Reconciled management command permissions on Pub/Sub topic" - ); - - Ok(()) - } - - /// Resolve domain information for a public worker. - /// Returns either custom domain config or auto-generated domain from metadata. - fn resolve_domain_info( - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - ) -> Result { - let stack_settings = &ctx.deployment_config.stack_settings; - - // Check for custom domain configuration - if let Some(custom) = stack_settings - .domains - .as_ref() - .and_then(|domains| domains.custom_domains.as_ref()) - .and_then(|domains| domains.get(resource_id)) - { - let ssl_cert_name = custom - .certificate - .gcp - .as_ref() - .map(|cert| cert.certificate_name.clone()) - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Custom domain requires a GCP SSL certificate name".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - return Ok(DomainInfo { - fqdn: custom.domain.clone(), - certificate_id: None, - ssl_certificate_name: Some(ssl_cert_name), - uses_custom_domain: true, - }); - } - - // Use auto-generated domain from domain metadata - let metadata = ctx - .deployment_config - .domain_metadata - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Domain metadata missing for public resource".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - let resource = metadata.resources.get(resource_id).ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Domain metadata missing for resource".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - Ok(DomainInfo { - fqdn: resource.fqdn.clone(), - certificate_id: Some(resource.certificate_id.clone()), - ssl_certificate_name: None, - uses_custom_domain: false, - }) - } - - fn ensure_domain_info( - &mut self, - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - ) -> Result { - if self.fqdn.is_some() - && (self.certificate_id.is_some() - || self.ssl_certificate_name.is_some() - || self.uses_custom_domain) - { - return Ok(true); - } - - match Self::resolve_domain_info(ctx, resource_id) { - Ok(domain_info) => { - self.fqdn = Some(domain_info.fqdn.clone()); - self.certificate_id = domain_info.certificate_id; - self.ssl_certificate_name = domain_info.ssl_certificate_name; - self.uses_custom_domain = domain_info.uses_custom_domain; - if self.url.is_none() { - self.url = ctx - .deployment_config - .public_endpoints - .as_ref() - .and_then(|resources| resources.get(resource_id)) - .and_then(|endpoints| endpoints.values().next().cloned()) - .or_else(|| Some(format!("https://{}", domain_info.fqdn))); - } - Ok(true) - } - Err(_) => Ok(false), - } - } - - fn unexpected_update_wrapper_state( - resource_id: &str, - handler: &str, - state: GcpWorkerState, - ) -> AlienError { - AlienError::new(ErrorData::ResourceControllerConfigError { - resource_id: resource_id.to_string(), - message: format!("{handler} returned unexpected state during update: {state:?}"), - }) - } - - async fn ensure_global_address_ip( - &mut self, - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - address_name: &str, - ) -> Result { - if let Some(ip_address) = &self.global_address_ip { - return Ok(ip_address.clone()); - } - - let gcp_config = ctx.get_gcp_config()?; - let compute_client = ctx.service_provider.get_gcp_compute_client(gcp_config)?; - let address = compute_client - .get_global_address(address_name.to_string()) - .await - .context(ErrorData::CloudPlatformError { - message: "Failed to get global address".to_string(), - resource_id: Some(resource_id.to_string()), - })?; - - let ip_address = address.address.ok_or_else(|| { - AlienError::new(ErrorData::CloudPlatformError { - message: "Global address has no IP".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - self.global_address_ip = Some(ip_address.clone()); - Ok(ip_address) - } - - async fn build_cloud_run_service( - &self, - service_name: &str, - cfg: &Worker, - ctx: &ResourceControllerContext<'_>, - ) -> Result { - use alien_gcp_clients::cloudrun::{ - Container, ContainerPort, EnvVar, ResourceRequirements, Service, - }; - - // Get the ServiceAccount for this worker's permission profile - let service_account_id = format!("{}-sa", cfg.get_permissions()); - let service_account_ref = ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - - // Get the ServiceAccount's email - let service_account_state = ctx - .require_dependency::( - &service_account_ref, - )?; - - let service_account = service_account_state - .service_account_email - .as_deref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: cfg.id().to_string(), - dependency_id: service_account_id.to_string(), - }) - })? - .to_string(); - let service_account = Some(service_account); - - // Extract container image - let image = match &cfg.code { - alien_core::WorkerCode::Image { image } => image.clone(), - alien_core::WorkerCode::Source { .. } => { - return Err(AlienError::new(ErrorData::ResourceConfigInvalid { - message: format!( - "Worker '{}' is configured with source code, but only pre-built images are supported in alien-infra.", - cfg.id - ), - resource_id: Some(cfg.id.clone()), - })); - } - }; - - // Resolve proxy URIs to native GAR URIs. Cloud Run can only pull from GAR. - let image = if let Some(ref native_host) = ctx.deployment_config.native_image_host { - alien_core::image_rewrite::resolve_native_image_uri(&image, native_host) - .unwrap_or(image) - } else { - image - }; - - // Prepare environment variables - let env_vars = self - .prepare_environment_variables(&cfg.environment, &cfg.links, ctx, service_name) - .await?; - - let env: Vec = env_vars - .into_iter() - .map(|(name, value)| EnvVar { - name, - value: Some(value), - value_source: None, - }) - .collect(); - - // Cloud Run gen2 requires `memory >= 512 Mi`; that's enforced by the - // `WorkerMemoryCheck` preflight, so we trust cfg.memory_mb here. - let mut limits = HashMap::new(); - limits.insert("memory".to_string(), format!("{}Mi", cfg.memory_mb)); - // Cloud Run automatically allocates CPU based on memory - - let resources = ResourceRequirements { - limits: Some(limits), - cpu_idle: Some(true), // Allow CPU throttling when idle - startup_cpu_boost: Some(true), // Boost CPU during startup - }; - - // Build container port - let ports = vec![ContainerPort { - name: Some("http1".to_string()), - // NOTE: This must match the alien-worker-runtime port on alien-build/src/lib.rs - container_port: Some(8080), - }]; - - // Build container - let container = Container::builder() - .name("worker".to_string()) - .image(image) - .env(env) - .resources(resources) - .ports(ports) - .build(); - - let ingress = if cfg.public_endpoints.is_empty() { - CloudRunIngress::IngressTrafficInternal - } else { - CloudRunIngress::IngressTrafficAll - }; - - // Get VPC access configuration if a Network resource exists - let vpc_access = self.get_vpc_access(ctx)?; - if vpc_access.is_some() { - info!(name=%service_name, "Configuring Cloud Run service with Direct VPC Egress"); - } - - // Build revision template - let mut revision_labels = HashMap::from([("worker".to_string(), cfg.id.clone())]); - if self.image_pull_permission_retries > 0 { - revision_labels.insert( - "alien-image-pull-retry".to_string(), - self.image_pull_permission_retries.to_string(), - ); - } - - let template = RevisionTemplate::builder() - .labels(revision_labels) - .scaling( - alien_gcp_clients::cloudrun::RevisionScaling::builder() - .min_instance_count(0) // Scale to zero - .maybe_max_instance_count(cfg.concurrency_limit.map(|c| c as i32)) - .build(), - ) - .timeout(format!("{}s", cfg.timeout_seconds)) - .maybe_service_account(service_account) - .containers(vec![container]) - .execution_environment( - alien_gcp_clients::cloudrun::ExecutionEnvironment::ExecutionEnvironmentGen2, - ) - .max_instance_request_concurrency(1000) // Cloud Run default - .maybe_vpc_access(vpc_access) - .build(); - - // Build traffic target - let traffic = vec![TrafficTarget::builder() - .r#type(TrafficTargetAllocationType::TrafficTargetAllocationTypeLatest) - .percent(100) - .build()]; - - // Build service - // When ingress is public, disable the IAM invoker check instead of adding - // allUsers to IAM policy. This works even when the GCP organization has - // domain-restricted sharing enabled (which blocks allUsers in IAM). - let is_public = !cfg.public_endpoints.is_empty(); - let service = Service::builder() - .description(format!("Runtime worker: {}", cfg.id)) - .labels(HashMap::from([ - ("resource-type".to_string(), "worker".to_string()), - ("resource".to_string(), cfg.id.clone()), - ("deployment".to_string(), ctx.resource_prefix.to_string()), - ])) - .ingress(ingress) - .template(template) - .traffic(traffic) - .invoker_iam_disabled(is_public) - .build(); - - Ok(service) - } - - /// Gets VPC access configuration from the Network resource if one exists in the stack. - /// - /// If a Network resource exists (ID: "default-network"), this method retrieves - /// the network name and subnetwork name from the Network controller to configure - /// the Cloud Run service with Direct VPC Egress. - /// - /// Returns `None` if no Network resource exists in the stack. - fn get_vpc_access(&self, ctx: &ResourceControllerContext<'_>) -> Result> { - // Check if the stack has a Network resource - let network_id = "default-network"; - if !ctx.desired_stack.resources.contains_key(network_id) { - return Ok(None); - } - - // Get the Network controller state via require_dependency - let network_ref = ResourceRef::new(Network::RESOURCE_TYPE, network_id.to_string()); - let network_state = - ctx.require_dependency::(&network_ref)?; - - // Only configure VPC access if we have network and subnetwork names - let network_name = match &network_state.network_name { - Some(name) => name.clone(), - None => return Ok(None), - }; - - let subnetwork_name = match &network_state.subnetwork_name { - Some(name) => name.clone(), - None => return Ok(None), - }; - - // Build Direct VPC Egress configuration using network interfaces - let network_interface = NetworkInterface::builder() - .network(network_name) - .subnetwork(subnetwork_name) - .build(); - - Ok(Some( - VpcAccess::builder() - .egress(VpcEgress::AllTraffic) - .network_interfaces(vec![network_interface]) - .build(), - )) - } - - async fn prepare_environment_variables( - &self, - initial_env: &HashMap, - links: &[ResourceRef], - ctx: &ResourceControllerContext<'_>, - function_name_for_error_logging: &str, - ) -> Result> { - use crate::core::ResourceController; - let worker_config = ctx.desired_resource_config::()?; - - // Get the worker's own binding params (may be None during initial creation) - let self_binding_params = self.get_binding_params()?; - - let env_vars = EnvironmentVariableBuilder::try_new(initial_env)? - .add_worker_runtime_env_vars(ctx, &worker_config.id, worker_config.timeout_seconds)? - .add_linked_resources(links, ctx, function_name_for_error_logging) - .await? - .add_self_worker_binding(&worker_config.id, self_binding_params.as_ref())? - .build(); - - Ok(env_vars) - } - - /// Applies consolidated IAM policy (resource-scoped permissions + public access) in a single operation - async fn apply_consolidated_iam_policy( - &self, - ctx: &ResourceControllerContext<'_>, - service_name: &str, - enable_public_access: bool, - ) -> Result<()> { - use alien_gcp_clients::iam::Binding; - - let config = ctx.desired_resource_config::()?; - let gcp_config = ctx.get_gcp_config()?; - let client = ctx.service_provider.get_gcp_cloudrun_client(gcp_config)?; - - // Get existing IAM policy to preserve any existing bindings - let mut policy = client - .get_service_iam_policy(gcp_config.region.clone(), service_name.to_string()) - .await - .context(ErrorData::CloudPlatformError { - message: format!("Failed to get IAM policy for Cloud Run service '{}' before applying bindings. Refusing to proceed to avoid overwriting existing bindings.", service_name), - resource_id: Some(config.id.clone()), - })?; - - // Step 1: Apply resource-scoped permissions from the stack - let mut resource_bindings = Vec::new(); - self.collect_resource_scoped_bindings(ctx, service_name, &mut resource_bindings) - .await?; - - // Step 2: Add public access binding if needed - if enable_public_access { - info!(service_name = %service_name, "Adding public access to IAM policy"); - let invoker_role = "roles/run.invoker".to_string(); - let all_users_member = "allUsers".to_string(); - - // Check if binding already exists - let binding_exists = policy - .bindings - .iter() - .any(|b| b.role == invoker_role && b.members.contains(&all_users_member)); - - if !binding_exists { - // Find existing binding or create new one - if let Some(binding) = policy.bindings.iter_mut().find(|b| b.role == invoker_role) { - if !binding.members.contains(&all_users_member) { - binding.members.push(all_users_member); - } - } else { - policy.bindings.push( - Binding::builder() - .role(invoker_role) - .members(vec![all_users_member]) - .build(), - ); - } - } - } - - // Step 3: Add resource-scoped bindings - if !resource_bindings.is_empty() { - info!( - service_name = %service_name, - bindings_count = resource_bindings.len(), - "Adding resource-scoped permissions to IAM policy" - ); - policy.bindings.extend(resource_bindings); - } - - // Step 4: Apply the consolidated policy in one operation - client - .set_service_iam_policy(gcp_config.region.clone(), service_name.to_string(), policy) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to apply consolidated IAM policy to Cloud Run service '{}'", - service_name - ), - resource_id: Some(config.id.clone()), - })?; - - info!(service_name = %service_name, "Consolidated IAM policy applied successfully"); - Ok(()) - } - - /// Collect resource-scoped bindings without applying them - async fn collect_resource_scoped_bindings( - &self, - ctx: &ResourceControllerContext<'_>, - service_name: &str, - all_bindings: &mut Vec, - ) -> Result<()> { - use alien_permissions::{generators::GcpRuntimePermissionsGenerator, PermissionContext}; - - let config = ctx.desired_resource_config::()?; - let gcp_config = ctx.get_gcp_config()?; - - // Build permission context for this specific worker resource - let mut permission_context = PermissionContext::new() - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_resource_name(service_name.to_string()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } - - let generator = GcpRuntimePermissionsGenerator::new(); - let type_prefix = "worker/"; - - // Process each permission profile in the stack - for (profile_name, profile) in &ctx.desired_stack.permissions.profiles { - // Combine resource-specific permissions with matching wildcard permissions - let mut combined_refs: Vec = - Vec::new(); - - if let Some(permission_set_refs) = profile.0.get(&config.id) { - combined_refs.extend( - permission_set_refs - .iter() - .filter(|r| r.id() != "worker/dispatch-command") - .cloned(), - ); - } - - if let Some(wildcard_refs) = profile.0.get("*") { - combined_refs.extend( - wildcard_refs - .iter() - .filter(|r| r.id().starts_with(type_prefix)) - .filter(|r| r.id() != "worker/dispatch-command") - .cloned(), - ); - } - - if !combined_refs.is_empty() { - info!( - service_name = %service_name, - profile = %profile_name, - permission_sets = ?combined_refs.iter().map(|r| r.id()).collect::>(), - "Processing resource-scoped permissions for worker" - ); - - self.process_profile_permissions( - ctx, - profile_name, - &combined_refs, - &generator, - &permission_context, - all_bindings, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to process permissions for profile '{}' on worker '{}'", - profile_name, service_name - ), - resource_id: Some(config.id.clone()), - })?; - } - } - - // Process management SA permissions matching the worker resource type - if let Some(management_profile) = ctx.desired_stack.management().profile() { - let mut management_refs: Vec = - Vec::new(); - - if let Some(permission_set_refs) = management_profile.0.get(&config.id) { - management_refs.extend( - permission_set_refs - .iter() - .filter(|r| r.id().starts_with(type_prefix)) - .filter(|r| r.id() != "worker/dispatch-command") - .cloned(), - ); - } - - if let Some(wildcard_refs) = management_profile.0.get("*") { - management_refs.extend( - wildcard_refs - .iter() - .filter(|r| r.id().starts_with(type_prefix)) - .filter(|r| r.id() != "worker/dispatch-command") - .cloned(), - ); - } - - if !management_refs.is_empty() { - use crate::core::ResourcePermissionsHelper; - ResourcePermissionsHelper::collect_gcp_management_bindings_for( - ctx, - &config.id, - service_name, - &management_refs, - &generator, - &permission_context, - alien_permissions::generators::GcpBindingTargetScope::CurrentResource, - all_bindings, - ) - .await?; - } - } - - Ok(()) - } - - /// Process permissions for a specific profile - async fn process_profile_permissions( - &self, - ctx: &ResourceControllerContext<'_>, - profile_name: &str, - permission_set_refs: &[alien_core::permissions::PermissionSetReference], - generator: &alien_permissions::generators::GcpRuntimePermissionsGenerator, - permission_context: &alien_permissions::PermissionContext, - all_bindings: &mut Vec, - ) -> Result<()> { - use alien_gcp_clients::iam::{Binding, Expr}; - use alien_permissions::BindingTarget; - - // Get the service account email for this profile - let service_account_email = - self.get_service_account_email_for_profile(ctx, profile_name)?; - - // Process each permission set for this resource - for permission_set_ref in permission_set_refs { - let permission_set = permission_set_ref - .resolve(|name| alien_permissions::get_permission_set(name).cloned()) - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: format!("Permission set '{}' not found", permission_set_ref.id()), - resource_id: Some(profile_name.to_string()), - }) - })?; - - let grant_plan = generator - .generate_grant_plan(&permission_set, BindingTarget::Resource, permission_context) - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to generate bindings for permission set '{}'", - permission_set.id - ), - resource_id: Some(profile_name.to_string()), - })?; - let selected_bindings = grant_plan.bindings_for_target( - alien_permissions::generators::GcpBindingTargetScope::CurrentResource, - ); - - // Convert and add bindings - let member = format!("serviceAccount:{}", service_account_email); - for binding in selected_bindings { - all_bindings.push(Binding { - role: binding.role, - members: vec![member.clone()], - condition: binding.condition.map(|cond| Expr { - title: Some(cond.title), - description: Some(cond.description), - expression: cond.expression, - location: None, - }), - }); - } - } - - Ok(()) - } - - /// Get the service account email for a permission profile - fn get_service_account_email_for_profile( - &self, - ctx: &ResourceControllerContext<'_>, - profile_name: &str, - ) -> Result { - let service_account_id = format!("{}-sa", profile_name); - let service_account_resource = ctx - .desired_stack - .resources - .get(&service_account_id) - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: format!( - "Service account resource '{}' not found for profile '{}'", - service_account_id, profile_name - ), - resource_id: Some(profile_name.to_string()), - }) - })?; - - let service_account_controller = ctx - .require_dependency::( - &(&service_account_resource.config).into(), - )?; - - service_account_controller - .service_account_email - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: "worker".to_string(), - dependency_id: profile_name.to_string(), - }) - }) - } - - /// Creates a Pub/Sub push subscription for a queue trigger - async fn create_push_subscription( - &mut self, - ctx: &ResourceControllerContext<'_>, - gcp_config: &alien_gcp_clients::GcpClientConfig, - _service_name: &str, - worker_config: &alien_core::Worker, - queue_ref: &alien_core::ResourceRef, - ) -> Result<()> { - let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; - - // Get queue controller to access the topic name - let queue_controller = - ctx.require_dependency::(queue_ref)?; - let topic_name = queue_controller.topic_name.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: queue_ref.id.clone(), - }) - })?; - let topic_full_name = format!("projects/{}/topics/{}", gcp_config.project_id, topic_name); - - // Generate push subscription name: stack-prefix-worker-id-queue-id - let subscription_name = format!( - "{}-{}-{}", - ctx.resource_prefix, worker_config.id, queue_ref.id - ); - - // Get the service URL for push endpoint - let service_url = self.url.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::ResourceControllerConfigError { - resource_id: worker_config.id.clone(), - message: "Service URL not available for push subscription".to_string(), - }) - })?; - - // Build push endpoint URL (Cloud Run service URL) - let push_endpoint = format!("{}/", service_url.trim_end_matches('/')); - - // Get service account email for OIDC authentication - let service_account_id = format!("{}-sa", worker_config.get_permissions()); - let service_account_ref = ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - - let service_account_state = ctx - .require_dependency::( - &service_account_ref, - )?; - let service_account_email = service_account_state - .service_account_email - .as_deref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id().to_string(), - dependency_id: service_account_id.to_string(), - }) - })? - .to_string(); - - // Create push config with OIDC authentication - let oidc_token = OidcToken { - service_account_email: service_account_email.clone(), - audience: Some(push_endpoint.clone()), - }; - - let push_config = PushConfig { - push_endpoint: Some(push_endpoint.clone()), - attributes: Some(std::collections::HashMap::new()), - oidc_token: Some(oidc_token), - pubsub_wrapper: None, - no_wrapper: None, - }; - - let subscription = Subscription { - name: Some(subscription_name.clone()), - topic: Some(topic_full_name.clone()), - push_config: Some(push_config), - ack_deadline_seconds: Some(worker_config.timeout_seconds as i32), - retain_acked_messages: Some(false), - message_retention_duration: None, - labels: Some(std::collections::HashMap::from([ - ("worker".to_string(), worker_config.id.clone()), - ("deployment".to_string(), ctx.resource_prefix.to_string()), - ])), - enable_message_ordering: Some(false), - expiration_policy: None, - filter: None, - dead_letter_policy: None, - retry_policy: None, - detached: Some(false), - state: None, - analytics_hub_subscription_info: None, - bigquery_config: None, - cloud_storage_config: None, - }; - - info!( - worker=%worker_config.id, - topic=%topic_full_name, - subscription=%subscription_name, - endpoint=%push_endpoint, - "Creating Pub/Sub push subscription" - ); - - match pubsub_client - .create_subscription(subscription_name.clone(), subscription) - .await - { - Ok(_) => {} - Err(e) if is_remote_resource_conflict(&e) => { - info!( - worker=%worker_config.id, - subscription=%subscription_name, - "Pub/Sub push subscription already exists; treating as created" - ); - } - Err(e) => { - return Err(e.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create push subscription '{}' for queue '{}'", - subscription_name, queue_ref.id - ), - resource_id: Some(worker_config.id.clone()), - })); - } - } - - if !self.push_subscriptions.contains(&subscription_name) { - self.push_subscriptions.push(subscription_name.clone()); - } - - info!( - worker=%worker_config.id, - subscription=%subscription_name, - "Successfully created Pub/Sub push subscription" - ); - - Ok(()) - } - - /// Deletes all push subscriptions using best-effort approach - async fn delete_all_push_subscriptions( - &mut self, - ctx: &ResourceControllerContext<'_>, - gcp_config: &alien_gcp_clients::GcpClientConfig, - ) -> Result<()> { - if self.push_subscriptions.is_empty() { - return Ok(()); - } - - let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; - let worker_config = ctx.desired_resource_config::()?; - - for subscription_name in &self.push_subscriptions.clone() { - match pubsub_client - .delete_subscription(subscription_name.clone()) - .await - { - Ok(_) => { - info!( - worker=%worker_config.id, - subscription=%subscription_name, - "Push subscription deleted successfully" - ); - } - Err(e) - if matches!( - e.error, - Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) - ) => - { - info!( - worker=%worker_config.id, - subscription=%subscription_name, - "Push subscription was already deleted (not found)" - ); - } - Err(e) => { - return Err(e.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to delete push subscription '{}'", - subscription_name - ), - resource_id: Some(worker_config.id.clone()), - })); - } - } - } - - self.push_subscriptions.clear(); - Ok(()) - } - - /// Gets the service account email for the worker's permission profile. - fn get_service_account_email( - &self, - ctx: &ResourceControllerContext<'_>, - worker_config: &alien_core::Worker, - ) -> Result { - let service_account_id = format!("{}-sa", worker_config.get_permissions()); - let service_account_ref = ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - - let service_account_state = ctx - .require_dependency::( - &service_account_ref, - )?; - - service_account_state.service_account_email.ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id().to_string(), - dependency_id: service_account_id, - }) - }) - } - - /// Creates storage trigger infrastructure: Pub/Sub topic, GCS notification, and push subscription. - async fn create_storage_trigger( - &mut self, - ctx: &ResourceControllerContext<'_>, - gcp_config: &alien_gcp_clients::GcpClientConfig, - _service_name: &str, - worker_config: &alien_core::Worker, - storage_ref: &alien_core::ResourceRef, - events: &[String], - ) -> Result<()> { - let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; - let gcs_client = ctx.service_provider.get_gcp_gcs_client(gcp_config)?; - - // Get bucket name from the storage controller dependency - let storage_controller = - ctx.require_dependency::(storage_ref)?; - let bucket_name = storage_controller.bucket_name.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: storage_ref.id.clone(), - }) - })?; - - // 1. Create a dedicated Pub/Sub topic for this storage notification - let topic_short_name = format!( - "{}-{}-{}-notif", - ctx.resource_prefix, worker_config.id, storage_ref.id - ); - let topic_full_name = format!( - "projects/{}/topics/{}", - gcp_config.project_id, topic_short_name - ); - - info!( - worker=%worker_config.id, - storage=%storage_ref.id, - topic=%topic_full_name, - "Creating Pub/Sub topic for storage notifications" - ); - - match pubsub_client - .create_topic(topic_short_name.clone(), Topic::default()) - .await - { - Ok(_) => {} - Err(e) if is_remote_resource_conflict(&e) => { - info!( - worker=%worker_config.id, - topic=%topic_short_name, - "Storage notification topic already exists; treating as created" - ); - } - Err(e) => { - return Err(e.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create storage notification topic '{}'", - topic_short_name - ), - resource_id: Some(worker_config.id.clone()), - })); - } - } - - if !self.storage_notification_topics.contains(&topic_short_name) { - self.storage_notification_topics - .push(topic_short_name.clone()); - } - - // 2. Ask Cloud Storage for its managed service account before granting it - // publish permissions. Deriving the email from the project number does - // not ensure that the service account has been provisioned yet. - let gcs_project_service_account = gcs_client.get_project_service_account().await.context( - ErrorData::CloudPlatformError { - message: format!( - "Failed to get the Cloud Storage service account for project '{}'", - gcp_config.project_id - ), - resource_id: Some(worker_config.id.clone()), - }, - )?; - let gcs_service_agent = format!( - "serviceAccount:{}", - gcs_project_service_account.email_address - ); - - let iam_policy = alien_gcp_clients::iam::IamPolicy::builder() - .version(1) - .bindings(vec![Binding { - role: "roles/pubsub.publisher".to_string(), - members: vec![gcs_service_agent], - condition: None, - }]) - .build(); - - pubsub_client - .set_topic_iam_policy(topic_short_name.clone(), iam_policy) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to set IAM policy on storage notification topic '{}'", - topic_short_name - ), - resource_id: Some(worker_config.id.clone()), - })?; - - // 3. Create GCS notification on the bucket pointing to the topic - let gcs_event_types: Vec = events - .iter() - .map(|event| { - match event.as_str() { - "created" => "OBJECT_FINALIZE".to_string(), - "deleted" => "OBJECT_DELETE".to_string(), - "archived" => "OBJECT_ARCHIVE".to_string(), - "metadataUpdated" => "OBJECT_METADATA_UPDATE".to_string(), - other => other.to_string(), // Pass through unknown events as-is - } - }) - .collect(); - - let notification = GcsNotification { - id: None, - topic: Some(topic_full_name.clone()), - event_types: gcs_event_types, - payload_format: Some("JSON_API_V1".to_string()), - object_name_prefix: None, - custom_attributes: std::collections::HashMap::new(), - }; - - let existing_notification = gcs_client - .list_notifications(bucket_name.clone()) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to list GCS notifications on bucket '{}' for worker '{}'", - bucket_name, worker_config.id - ), - resource_id: Some(worker_config.id.clone()), - })? - .items - .into_iter() - .find(|existing| gcs_notification_matches_existing(existing, ¬ification)); - - let created_notification = if let Some(existing_notification) = existing_notification { - info!( - worker=%worker_config.id, - storage=%storage_ref.id, - bucket=%bucket_name, - notification_id=?existing_notification.id, - "GCS notification already exists; treating as created" - ); - existing_notification - } else { - gcs_client - .insert_notification(bucket_name.clone(), notification) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create GCS notification on bucket '{}' for worker '{}'", - bucket_name, worker_config.id - ), - resource_id: Some(worker_config.id.clone()), - })? - }; - - if let Some(notification_id) = &created_notification.id { - if !self.gcs_notification_ids.iter().any(|tracker| { - tracker.bucket_name == *bucket_name && tracker.notification_id == *notification_id - }) { - self.gcs_notification_ids.push(GcsNotificationTracker { - bucket_name: bucket_name.clone(), - notification_id: notification_id.clone(), - }); - } - } - - // 4. Create a push subscription to the Cloud Run URL - let service_url = self.url.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::ResourceControllerConfigError { - resource_id: worker_config.id.clone(), - message: "Service URL not available for storage trigger push subscription" - .to_string(), - }) - })?; - - let push_endpoint = format!("{}/", service_url.trim_end_matches('/')); - - // Get service account email for OIDC authentication - let service_account_email = self.get_service_account_email(ctx, worker_config)?; - - let oidc_token = OidcToken { - service_account_email, - audience: Some(push_endpoint.clone()), - }; - - let subscription_name = format!( - "{}-{}-{}-notif-sub", - ctx.resource_prefix, worker_config.id, storage_ref.id - ); - - let push_config = PushConfig { - push_endpoint: Some(push_endpoint), - attributes: Some(std::collections::HashMap::new()), - oidc_token: Some(oidc_token), - pubsub_wrapper: None, - no_wrapper: None, - }; - - let subscription = Subscription { - name: Some(subscription_name.clone()), - topic: Some(topic_full_name.clone()), - push_config: Some(push_config), - ack_deadline_seconds: Some(worker_config.timeout_seconds as i32), - retain_acked_messages: Some(false), - message_retention_duration: None, - labels: Some(std::collections::HashMap::from([ - ("worker".to_string(), worker_config.id.clone()), - ("deployment".to_string(), ctx.resource_prefix.to_string()), - ("storage".to_string(), storage_ref.id.clone()), - ])), - enable_message_ordering: Some(false), - expiration_policy: None, - filter: None, - dead_letter_policy: None, - retry_policy: None, - detached: Some(false), - state: None, - analytics_hub_subscription_info: None, - bigquery_config: None, - cloud_storage_config: None, - }; - - info!( - worker=%worker_config.id, - storage=%storage_ref.id, - subscription=%subscription_name, - "Creating Pub/Sub push subscription for storage trigger" - ); - - match pubsub_client - .create_subscription(subscription_name.clone(), subscription) - .await - { - Ok(_) => {} - Err(e) if is_remote_resource_conflict(&e) => { - info!( - worker=%worker_config.id, - subscription=%subscription_name, - "Storage trigger push subscription already exists; treating as created" - ); - } - Err(e) => { - return Err(e.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create push subscription '{}' for storage trigger '{}'", - subscription_name, storage_ref.id - ), - resource_id: Some(worker_config.id.clone()), - })); - } - } - - if !self.push_subscriptions.contains(&subscription_name) { - self.push_subscriptions.push(subscription_name); - } - - info!( - worker=%worker_config.id, - storage=%storage_ref.id, - "Successfully created storage trigger infrastructure" - ); - - Ok(()) - } - - /// Deletes all GCS notifications (best-effort) - async fn delete_all_storage_notifications( - &mut self, - ctx: &ResourceControllerContext<'_>, - gcp_config: &alien_gcp_clients::GcpClientConfig, - ) -> Result<()> { - if self.gcs_notification_ids.is_empty() { - return Ok(()); - } - - let gcs_client = ctx.service_provider.get_gcp_gcs_client(gcp_config)?; - let worker_config = ctx.desired_resource_config::()?; - - for tracker in &self.gcs_notification_ids.clone() { - match gcs_client - .delete_notification(tracker.bucket_name.clone(), tracker.notification_id.clone()) - .await - { - Ok(_) => { - info!( - worker=%worker_config.id, - bucket=%tracker.bucket_name, - notification_id=%tracker.notification_id, - "GCS notification deleted successfully" - ); - } - Err(e) => { - warn!( - worker=%worker_config.id, - bucket=%tracker.bucket_name, - notification_id=%tracker.notification_id, - error=%e, - "Failed to delete GCS notification (best-effort, continuing)" - ); - } - } - } - - self.gcs_notification_ids.clear(); - Ok(()) - } - - /// Deletes all storage notification Pub/Sub topics (best-effort) - async fn delete_all_storage_notification_topics( - &mut self, - ctx: &ResourceControllerContext<'_>, - gcp_config: &alien_gcp_clients::GcpClientConfig, - ) -> Result<()> { - if self.storage_notification_topics.is_empty() { - return Ok(()); - } - - let pubsub_client = ctx.service_provider.get_gcp_pubsub_client(gcp_config)?; - let worker_config = ctx.desired_resource_config::()?; - - for topic_name in &self.storage_notification_topics.clone() { - match pubsub_client.delete_topic(topic_name.clone()).await { - Ok(_) => { - info!( - worker=%worker_config.id, - topic=%topic_name, - "Storage notification topic deleted successfully" - ); - } - Err(e) => { - warn!( - worker=%worker_config.id, - topic=%topic_name, - error=%e, - "Failed to delete storage notification topic (best-effort, continuing)" - ); - } - } - } - - self.storage_notification_topics.clear(); - Ok(()) - } - - /// Deletes all Cloud Scheduler jobs (best-effort) - async fn delete_all_scheduler_jobs( - &mut self, - ctx: &ResourceControllerContext<'_>, - gcp_config: &alien_gcp_clients::GcpClientConfig, - ) -> Result<()> { - if self.scheduler_job_names.is_empty() { - return Ok(()); - } - - let scheduler_client = ctx - .service_provider - .get_gcp_cloud_scheduler_client(gcp_config)?; - let worker_config = ctx.desired_resource_config::()?; - - for job_name in &self.scheduler_job_names.clone() { - match scheduler_client.delete_job(job_name.clone()).await { - Ok(_) => { - info!( - worker=%worker_config.id, - job=%job_name, - "Cloud Scheduler job deleted successfully" - ); - } - Err(e) => { - warn!( - worker=%worker_config.id, - job=%job_name, - error=%e, - "Failed to delete Cloud Scheduler job (best-effort, continuing)" - ); - } - } - } - - self.scheduler_job_names.clear(); - Ok(()) - } - - /// Creates a controller in a ready state with mock values for testing purposes. - #[cfg(feature = "test-utils")] - pub fn mock_ready(function_name: &str) -> Self { - Self { - state: GcpWorkerState::Ready, - service_name: Some(function_name.to_string()), - url: Some(format!("https://{}-abcd1234-uc.a.run.app", function_name)), - operation_name: None, - image_pull_permission_retries: 0, - compute_operation_name: None, - compute_operation_region: None, - push_subscriptions: Vec::new(), - storage_notification_topics: Vec::new(), - gcs_notification_ids: Vec::new(), - scheduler_job_names: Vec::new(), - fqdn: None, - certificate_id: None, - ssl_certificate_name: None, - uses_custom_domain: false, - certificate_issued_at: None, - serverless_neg_name: None, - backend_service_name: None, - url_map_name: None, - target_https_proxy_name: None, - global_address_name: None, - global_address_ip: None, - forwarding_rule_name: None, - project_id: Some("test-project".to_string()), - region: Some("us-central1".to_string()), - commands_topic_name: None, - commands_subscription_name: None, - _internal_stay_count: None, - } - } -} - -#[cfg(test)] -mod tests { - //! # GCP Worker Controller Tests - //! - //! See `crate::core::controller_test` for a comprehensive guide on testing infrastructure controllers. - - use std::collections::HashMap; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - - use alien_client_core::{ErrorData as CloudClientErrorData, Result as CloudClientResult}; - use alien_core::{ - CertificateStatus, DnsRecordStatus, DomainMetadata, HttpMethod, Platform, - ResourceDomainInfo, ResourceStatus, Worker, WorkerOutputs, - }; - use alien_error::AlienError; - use alien_gcp_clients::cloudrun::{Condition, ConditionState, MockCloudRunApi, Service}; - use alien_gcp_clients::gcp::compute::{Address, MockComputeApi, Operation, OperationStatus}; - use alien_gcp_clients::iam::{IamPolicy, MockIamApi}; - use alien_gcp_clients::longrunning::Operation as LongRunningOperation; - use alien_gcp_clients::longrunning::{OperationResult, Status}; - use alien_gcp_clients::pubsub::MockPubSubApi; - use httpmock::{prelude::*, Mock}; - use rstest::rstest; - - use super::{ - get_cloudrun_service_name, get_gcp_worker_resource_name, - is_cross_project_image_pull_permission_error, CLOUD_RUN_SERVICE_NAME_MAX_LEN, - GCP_RESOURCE_NAME_MAX_LEN, - }; - use crate::core::MockPlatformServiceProvider; - use crate::core::{ - controller_test::{SingleControllerExecutor, SingleControllerExecutorBuilder}, - PlatformServiceProvider, - }; - use crate::worker::readiness_probe::test_utils::create_readiness_probe_mock; - use crate::worker::{fixtures::*, GcpWorkerController}; - use crate::GcpWorkerState; - - #[test] - fn cloudrun_service_name_preserves_valid_short_names() { - assert_eq!( - get_cloudrun_service_name("test-stack", "worker"), - "test-stack-worker" - ); - } - - #[test] - fn image_pull_retry_only_matches_cross_project_gar_permission_denials() { - assert!(is_cross_project_image_pull_permission_error( - "Google Cloud Run Service Agent service-123@serverless-robot-prod.iam.gserviceaccount.com \ - was denied artifactregistry.repositories.downloadArtifacts" - )); - assert!(!is_cross_project_image_pull_permission_error( - "artifactregistry.repositories.downloadArtifacts denied for a user service account" - )); - assert!(!is_cross_project_image_pull_permission_error( - "Cloud Run revision failed its startup probe" - )); - } - - #[test] - fn cloudrun_service_name_caps_long_e2e_names_with_stable_hash() { - let service_name = get_cloudrun_service_name( - "e2e-gcp-terraform-worker-mpfa2f19-15fb", - "test-alien-ts-function", - ); - - assert!(service_name.len() <= CLOUD_RUN_SERVICE_NAME_MAX_LEN); - assert!(service_name.starts_with("e")); - assert!(!service_name.ends_with('-')); - assert!(service_name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')); - assert_eq!( - service_name, - get_cloudrun_service_name( - "e2e-gcp-terraform-worker-mpfa2f19-15fb", - "test-alien-ts-function", - ) - ); - } - - #[test] - fn cloudrun_service_name_sanitizes_invalid_input() { - let service_name = get_cloudrun_service_name("123_Test.Stack", "Worker_Name_"); - - assert!(service_name.len() <= CLOUD_RUN_SERVICE_NAME_MAX_LEN); - assert!(service_name.starts_with('a')); - assert!(!service_name.ends_with('-')); - assert!(service_name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')); - } - - #[test] - fn gcp_worker_resource_name_caps_long_certificate_names_with_stable_hash() { - let cert_name = get_gcp_worker_resource_name( - "e2e-gcp-terraform-worker-mpfgzubr-tux", - "test-alien-ts-function", - "cert", - ); - - assert!(cert_name.len() <= GCP_RESOURCE_NAME_MAX_LEN); - assert!(cert_name.starts_with('e')); - assert!(!cert_name.ends_with('-')); - assert!(cert_name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')); - assert_eq!( - cert_name, - get_gcp_worker_resource_name( - "e2e-gcp-terraform-worker-mpfgzubr-tux", - "test-alien-ts-function", - "cert", - ) - ); - } - - fn create_test_domain_metadata(resource_id: &str) -> DomainMetadata { - let mut resources = HashMap::new(); - resources.insert( - resource_id.to_string(), - ResourceDomainInfo { - fqdn: format!("{}.test.example.com", resource_id), - certificate_id: "test-cert-id".to_string(), - certificate_status: CertificateStatus::Issued, - dns_status: DnsRecordStatus::Active, - dns_error: None, - certificate_chain: Some( - "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n" - .to_string(), - ), - private_key: Some( - "-----BEGIN RSA PRIVATE KEY-----\nMIIBtest\n-----END RSA PRIVATE KEY-----\n" - .to_string(), - ), - endpoints: HashMap::new(), - issued_at: Some("2024-01-01T00:00:00Z".to_string()), - aliases: Vec::new(), - }, - ); - DomainMetadata { - base_domain: "test.example.com".to_string(), - public_subdomain: "test".to_string(), - hosted_zone_id: "Z1234567890ABC".to_string(), - resources, - } - } - - fn create_ssl_compute_mock_for_creation_and_deletion() -> Arc { - fn completed_compute_operation() -> Operation { - Operation { - name: Some("test-compute-operation".to_string()), - status: Some(OperationStatus::Done), - ..Default::default() - } - } - - let mut mock = MockComputeApi::new(); - mock.expect_insert_ssl_certificate() - .returning(|_| Ok(completed_compute_operation())); - mock.expect_insert_region_network_endpoint_group() - .returning(|_, _| Ok(completed_compute_operation())); - mock.expect_insert_backend_service() - .returning(|_| Ok(completed_compute_operation())); - mock.expect_insert_url_map() - .returning(|_| Ok(completed_compute_operation())); - mock.expect_insert_target_https_proxy() - .returning(|_| Ok(completed_compute_operation())); - mock.expect_insert_global_address() - .returning(|_| Ok(completed_compute_operation())); - mock.expect_get_global_address().returning(|_| { - Ok(Address { - address: Some("203.0.113.1".to_string()), - ..Default::default() - }) - }); - mock.expect_insert_global_forwarding_rule() - .returning(|_| Ok(completed_compute_operation())); - mock.expect_delete_global_forwarding_rule() - .returning(|_| Ok(Operation::default())); - mock.expect_delete_target_https_proxy() - .returning(|_| Ok(Operation::default())); - mock.expect_delete_url_map() - .returning(|_| Ok(Operation::default())); - mock.expect_delete_backend_service() - .returning(|_| Ok(Operation::default())); - mock.expect_delete_region_network_endpoint_group() - .returning(|_, _| Ok(Operation::default())); - mock.expect_delete_ssl_certificate() - .returning(|_| Ok(Operation::default())); - mock.expect_delete_global_address() - .returning(|_| Ok(Operation::default())); - Arc::new(mock) - } - - fn resource_in_use_error() -> AlienError { - AlienError::new(CloudClientErrorData::InvalidInput { - message: "The targetHttpsProxy resource is already being used by forwardingRules/test-fwd resourceInUseByAnotherResource".to_string(), - field_name: None, - }) - } - - fn create_successful_service_response(service_name: &str) -> Service { - use alien_gcp_clients::cloudrun::Service; - - Service::builder() - .name(format!( - "projects/test-project/locations/us-central1/services/{}", - service_name - )) - .uri(format!("https://{}-abcd1234-uc.a.run.app", service_name)) - .urls(vec![format!( - "https://{}-abcd1234-uc.a.run.app", - service_name - )]) - .conditions(vec![Condition::builder() - .r#type("Ready".to_string()) - .state(ConditionState::ConditionSucceeded) - .build()]) - .build() - } - - fn create_successful_operation_response(operation_name: &str) -> LongRunningOperation { - LongRunningOperation::builder() - .name(format!( - "projects/test-project/locations/us-central1/operations/{}", - operation_name - )) - .done(false) - .build() - } - - fn create_completed_operation_response(operation_name: &str) -> LongRunningOperation { - LongRunningOperation::builder() - .name(format!("projects/test-project/locations/us-central1/operations/{}", operation_name)) - .done(true) - .result(OperationResult::Response { - response: serde_json::json!({ - "name": format!("projects/test-project/locations/us-central1/services/test-{}", operation_name) - }) - }) - .build() - } - - fn create_image_pull_permission_denied_operation(operation_name: &str) -> LongRunningOperation { - LongRunningOperation::builder() - .name(format!( - "projects/test-project/locations/us-central1/operations/{operation_name}" - )) - .done(true) - .result(OperationResult::Error { - error: Status::builder() - .code(7) - .message( - "Google Cloud Run Service Agent \ - service-123@serverless-robot-prod.iam.gserviceaccount.com must have \ - artifactregistry.repositories.downloadArtifacts permission" - .to_string(), - ) - .build(), - }) - .build() - } - - fn create_empty_iam_policy() -> IamPolicy { - IamPolicy::builder().version(1).bindings(vec![]).build() - } - - fn setup_mock_client_for_creation_and_update( - function_name: &str, - _has_public_access: bool, - ) -> Arc { - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock successful service creation - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - // Mock operation status checks - first pending, then completed - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response(&operation_name_for_get)) - }); - - // Mock service retrieval after creation - let function_name_for_get = function_name.to_string(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))); - - // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock successful updates - let function_name_for_update = function_name.to_string(); - let update_operation_name = format!("update-{}", function_name_for_update); - mock_cloudrun - .expect_patch_service() - .returning(move |_, _, _, _, _, _| { - Ok(create_successful_operation_response(&update_operation_name)) - }); - - Arc::new(mock_cloudrun) - } - - fn setup_mock_client_for_creation_and_deletion( - function_name: &str, - _has_public_access: bool, - ) -> Arc { - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock successful service creation - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - // Mock operation status checks for creation - mock_cloudrun - .expect_get_operation() - .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) - .times(1); // Only for creation flow - - // Mock service retrieval after creation - let function_name_for_get = function_name.to_string(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) - .times(1); // Only for creation flow - - // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock successful service deletion - let function_name_for_delete = function_name.to_string(); - let delete_operation_name = format!("delete-{}", function_name_for_delete); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - // Mock operation status checks for deletion - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - - // Mock service not found during deletion check - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - Arc::new(mock_cloudrun) - } - - fn setup_mock_client_for_best_effort_deletion( - _function_name: &str, - service_missing: bool, - ) -> Arc { - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock service deletion (might fail if service missing) - if service_missing { - mock_cloudrun - .expect_delete_service() - .returning(|_, _, _, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - } else { - let delete_operation_name = "delete-test".to_string(); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - } - - // Always return not found for final status check - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - Arc::new(mock_cloudrun) - } - - fn create_gcp_iam_mock_for_resource_permissions() -> Arc { - Arc::new(MockIamApi::new()) - } - - fn setup_mock_service_provider( - mock_cloudrun: Arc, - mock_compute: Option>, - ) -> Arc { - setup_mock_service_provider_with_pubsub( - mock_cloudrun, - mock_compute, - Arc::new(MockPubSubApi::new()), - ) - } - - fn setup_mock_service_provider_with_pubsub( - mock_cloudrun: Arc, - mock_compute: Option>, - mock_pubsub: Arc, - ) -> Arc { - let mut mock_provider = MockPlatformServiceProvider::new(); - - mock_provider - .expect_get_gcp_cloudrun_client() - .returning(move |_| Ok(mock_cloudrun.clone())); - - if let Some(compute) = mock_compute { - mock_provider - .expect_get_gcp_compute_client() - .returning(move |_| Ok(compute.clone())); - } - - // Mock IAM client for resource-scoped permissions. - let mock_iam = create_gcp_iam_mock_for_resource_permissions(); - mock_provider - .expect_get_gcp_iam_client() - .returning(move |_| Ok(mock_iam.clone())); - - mock_provider - .expect_get_gcp_pubsub_client() - .returning(move |_| Ok(mock_pubsub.clone())); - - Arc::new(mock_provider) - } - - /// Sets up mock CloudRun client and optional readiness probe mock server - /// Returns (cloudrun_mock_provider, optional_mock_server, optional_domain_metadata) - fn setup_mocks_for_function( - worker: &Worker, - function_name: &str, - for_deletion: bool, - ) -> ( - Arc, - Option, - Option, - ) { - let has_public_access = !worker.public_endpoints.is_empty(); - let needs_readiness_probe = has_public_access && worker.readiness_probe.is_some(); - - // Set up mock server for readiness probe if needed - let mock_server = if needs_readiness_probe { - Some(create_readiness_probe_mock(worker)) - } else { - None - }; - - // Set up CloudRun client mock - let cloudrun_mock = if for_deletion { - if let Some(ref _server) = mock_server { - setup_mock_client_for_creation_and_deletion_with_mock_url( - function_name, - has_public_access, - &_server.base_url(), - ) - } else { - setup_mock_client_for_creation_and_deletion(function_name, has_public_access) - } - } else { - if let Some(ref _server) = mock_server { - setup_mock_client_for_creation_and_update_with_mock_url( - function_name, - has_public_access, - &_server.base_url(), - ) - } else { - setup_mock_client_for_creation_and_update(function_name, has_public_access) - } - }; - - // For public workers, also set up compute mock and domain metadata - let (compute_mock, domain_metadata) = if has_public_access { - let dm = create_test_domain_metadata(&worker.id); - let compute = create_ssl_compute_mock_for_creation_and_deletion(); - (Some(compute), Some(dm)) - } else { - (None, None) - }; - - let mock_provider = setup_mock_service_provider(cloudrun_mock, compute_mock); - - (mock_provider, mock_server, domain_metadata) - } - - fn setup_mock_client_for_creation_and_update_with_mock_url( - function_name: &str, - has_public_access: bool, - mock_url: &str, - ) -> Arc { - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock successful service creation - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - // Mock operation status checks - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response(&operation_name_for_get)) - }); - - // Mock service retrieval after creation - use mock URL - let mock_url = mock_url.to_string(); - let function_name_for_get = function_name.to_string(); - mock_cloudrun.expect_get_service().returning(move |_, _| { - let mut service = create_successful_service_response(&function_name_for_get); - service.uri = Some(mock_url.clone()); - service.urls = vec![mock_url.clone()]; - Ok(service) - }); - - // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock successful updates - let function_name_for_update = function_name.to_string(); - let update_operation_name = format!("update-{}", function_name_for_update); - mock_cloudrun - .expect_patch_service() - .returning(move |_, _, _, _, _, _| { - Ok(create_successful_operation_response(&update_operation_name)) - }); - - Arc::new(mock_cloudrun) - } - - fn setup_mock_client_for_creation_and_deletion_with_mock_url( - function_name: &str, - has_public_access: bool, - mock_url: &str, - ) -> Arc { - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock successful service creation - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - // Mock operation status checks for creation - mock_cloudrun - .expect_get_operation() - .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) - .times(1); // Only for creation flow - - // Mock service retrieval after creation - use mock URL - let mock_url = mock_url.to_string(); - let function_name_for_get = function_name.to_string(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| { - let mut service = create_successful_service_response(&function_name_for_get); - service.uri = Some(mock_url.clone()); - service.urls = vec![mock_url.clone()]; - Ok(service) - }) - .times(1); // Only for creation flow - - // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock successful service deletion - let function_name_for_delete = function_name.to_string(); - let delete_operation_name = format!("delete-{}", function_name_for_delete); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - // Mock operation status checks for deletion - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - - // Mock service not found during deletion check - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - Arc::new(mock_cloudrun) - } - - // ─────────────── CREATE AND DELETE FLOW TESTS ──────────────────── - - #[rstest] - #[case::basic(basic_function())] - #[case::env_vars(function_with_env_vars())] - #[case::storage_link(function_with_storage_link())] - #[case::env_and_storage(function_with_env_and_storage())] - #[case::multiple_storages(function_with_multiple_storages())] - #[case::public_ingress(function_public_ingress())] - #[case::private_ingress(function_private_ingress())] - #[case::concurrency(function_with_concurrency())] - #[case::custom_config(function_custom_config())] - #[case::readiness_probe(function_with_readiness_probe())] - #[case::complete_test(function_complete_test())] - #[tokio::test] - async fn test_create_and_delete_flow_succeeds(#[case] worker: Worker) { - let worker_id = worker.id.clone(); - let worker_is_public = !worker.public_endpoints.is_empty(); - let function_name = format!("test-{}", worker.id); - let (mock_provider, _mock_server, domain_metadata) = - setup_mocks_for_function(&worker, &function_name, true); - - let mut builder = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies(); - - if let Some(dm) = domain_metadata { - builder = builder.domain_metadata(dm); - } - - let mut executor = builder.build().await.unwrap(); - - // Run create flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify outputs are available - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.identifier.is_some()); - assert!(function_outputs.worker_name.starts_with("test-")); - if worker_is_public { - let expected_url = format!("https://{}.test.example.com", worker_id); - let endpoint = function_outputs - .public_endpoints - .get("default") - .expect("public endpoint output should exist"); - assert_eq!(endpoint.url, expected_url); - assert_eq!( - endpoint - .load_balancer_endpoint - .as_ref() - .map(|endpoint| endpoint.dns_name.as_str()), - Some("203.0.113.1") - ); - } - - // Delete the worker - executor.delete().unwrap(); - - // Run delete flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } - - #[tokio::test] - async fn retries_cloud_run_revision_after_gar_reader_grant_propagates() { - let worker = basic_function(); - let function_name = format!("test-{}", worker.id); - let operation_checks = Arc::new(AtomicUsize::new(0)); - let operation_checks_for_mock = Arc::clone(&operation_checks); - let patch_calls = Arc::new(AtomicUsize::new(0)); - let patch_calls_for_mock = Arc::clone(&patch_calls); - - let mut mock_cloudrun = MockCloudRunApi::new(); - mock_cloudrun - .expect_create_service() - .times(1) - .returning(|_, _, _, _| Ok(create_successful_operation_response("create-worker"))); - mock_cloudrun - .expect_get_operation() - .times(2) - .returning(move |_, _| { - if operation_checks_for_mock.fetch_add(1, Ordering::SeqCst) == 0 { - Ok(create_image_pull_permission_denied_operation( - "create-worker", - )) - } else { - Ok(create_completed_operation_response("retry-worker")) - } - }); - mock_cloudrun - .expect_patch_service() - .times(1) - .withf(|_, _, service, update_mask, _, allow_missing| { - let has_retry_label = service - .template - .as_ref() - .and_then(|template| template.labels.as_ref()) - .is_some_and(|labels| { - labels.get("alien-image-pull-retry").map(String::as_str) == Some("1") - }); - has_retry_label - && update_mask.as_deref() == Some("template") - && *allow_missing == Some(false) - }) - .returning(move |_, _, _, _, _, _| { - patch_calls_for_mock.fetch_add(1, Ordering::SeqCst); - Ok(create_successful_operation_response("retry-worker")) - }); - let function_name_for_get = function_name.clone(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))); - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - for _ in 0..30 { - if executor.status() == ResourceStatus::Running { - break; - } - executor.step().await.unwrap(); - } - - assert_eq!(executor.status(), ResourceStatus::Running); - assert_eq!(operation_checks.load(Ordering::SeqCst), 2); - assert_eq!(patch_calls.load(Ordering::SeqCst), 1); - } - - // ─────────────── UPDATE FLOW TESTS ──────────────────────────────── - - #[rstest] - #[case::basic_to_env(basic_function(), function_with_env_vars())] - #[case::env_to_storage(function_with_env_vars(), function_with_storage_link())] - #[case::storage_to_custom(function_with_storage_link(), function_custom_config())] - #[case::custom_to_public(function_custom_config(), function_public_ingress())] - #[case::public_to_complete(function_public_ingress(), function_complete_test())] - #[case::complete_to_basic(function_complete_test(), basic_function())] - #[tokio::test] - async fn test_update_flow_succeeds(#[case] from_function: Worker, #[case] to_function: Worker) { - // Ensure both workers have the same ID for valid updates - let worker_id = "test-update-worker".to_string(); - let mut from_function = from_function; - from_function.id = worker_id.clone(); - - let mut to_function = to_function; - to_function.id = worker_id.clone(); - - let function_name = format!("test-{}", worker_id); - let (mock_provider, mock_server, domain_metadata) = - setup_mocks_for_function(&to_function, &function_name, false); - - // Start with the "from" worker in Ready state - let mut ready_controller = GcpWorkerController::mock_ready(&function_name); - - // If the target worker has a readiness probe, update the controller URL to point to mock server - if to_function.readiness_probe.is_some() && !to_function.public_endpoints.is_empty() { - if let Some(ref server) = mock_server { - ready_controller.url = Some(server.base_url()); - } - } - - let mut builder = SingleControllerExecutor::builder() - .resource(from_function) - .controller(ready_controller) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies(); - - if let Some(dm) = domain_metadata { - builder = builder.domain_metadata(dm); - } - - let mut executor = builder.build().await.unwrap(); - - // Ensure we start in Running state - assert_eq!(executor.status(), ResourceStatus::Running); - - // Update to the new worker - let target_is_public = !to_function.public_endpoints.is_empty(); - executor.update(to_function).unwrap(); - - // Run the update flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - if target_is_public { - let expected_url = format!("https://{}.test.example.com", worker_id); - assert_eq!( - function_outputs - .public_endpoints - .get("default") - .map(|endpoint| endpoint.url.as_str()), - Some(expected_url.as_str()) - ); - } - } - - // ─────────────── BEST EFFORT DELETION TESTS ─────────────────────── - - #[rstest] - #[case::basic(basic_function(), false)] - #[case::public_with_missing_service(function_public_ingress(), true)] - #[case::private_with_missing_service(function_private_ingress(), true)] - #[tokio::test] - async fn test_best_effort_deletion_when_resources_missing( - #[case] worker: Worker, - #[case] service_missing: bool, - ) { - let function_name = format!("test-{}", worker.id); - let has_public_access = !worker.public_endpoints.is_empty(); - let mock_cloudrun = - setup_mock_client_for_best_effort_deletion(&function_name, service_missing); - let mock_provider = setup_mock_service_provider(mock_cloudrun, None); - - // Start with a ready controller - let mut ready_controller = GcpWorkerController::mock_ready(&function_name); - if has_public_access { - ready_controller.url = Some("https://example-abcd1234-uc.a.run.app".to_string()); - } - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(ready_controller) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Ensure we start in Running state - assert_eq!(executor.status(), ResourceStatus::Running); - - // Delete the worker - executor.delete().unwrap(); - - // Run the delete flow - it should succeed even when resources are missing - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } - - #[tokio::test] - async fn test_delete_retries_target_proxy_while_forwarding_rule_reference_drains() { - let worker = function_public_ingress(); - let function_name = format!("test-{}", worker.id); - let proxy_delete_attempts = Arc::new(AtomicUsize::new(0)); - let proxy_delete_attempts_for_mock = Arc::clone(&proxy_delete_attempts); - let mock_cloudrun = setup_mock_client_for_creation_and_deletion(&function_name, true); - - let mut mock_compute = MockComputeApi::new(); - mock_compute - .expect_delete_global_forwarding_rule() - .returning(|_| Ok(Operation::default())); - mock_compute - .expect_delete_target_https_proxy() - .returning(move |_| { - if proxy_delete_attempts_for_mock.fetch_add(1, Ordering::SeqCst) == 0 { - Err(resource_in_use_error()) - } else { - Ok(Operation::default()) - } - }); - mock_compute - .expect_delete_url_map() - .returning(|_| Ok(Operation::default())); - mock_compute - .expect_delete_backend_service() - .returning(|_| Ok(Operation::default())); - mock_compute - .expect_delete_region_network_endpoint_group() - .returning(|_, _| Ok(Operation::default())); - mock_compute - .expect_delete_global_address() - .returning(|_| Ok(Operation::default())); - - let mock_provider = - setup_mock_service_provider(mock_cloudrun, Some(Arc::new(mock_compute))); - let mut controller = GcpWorkerController::mock_ready(&function_name); - controller.forwarding_rule_name = Some("test-fwd".to_string()); - controller.target_https_proxy_name = Some("test-proxy".to_string()); - controller.url_map_name = Some("test-url-map".to_string()); - controller.backend_service_name = Some("test-backend".to_string()); - controller.serverless_neg_name = Some("test-neg".to_string()); - controller.global_address_name = Some("test-address".to_string()); - controller.global_address_ip = Some("203.0.113.9".to_string()); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(controller) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.delete().unwrap(); - executor.run_until_terminal().await.unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Deleted); - assert_eq!(proxy_delete_attempts.load(Ordering::SeqCst), 2); - } - - // ─────────────── SPECIFIC VALIDATION TESTS ───────────────── - - #[tokio::test] - async fn queue_push_subscription_uses_fully_qualified_topic_name() { - use crate::queue::gcp::{GcpQueueController, GcpQueueState}; - - let worker = function_with_queue_trigger(); - let function_name = format!("test-{}", worker.id); - let mock_cloudrun = setup_mock_client_for_creation_and_update(&function_name, false); - - let mut mock_pubsub = MockPubSubApi::new(); - mock_pubsub - .expect_create_subscription() - .withf(|subscription_id, subscription| { - subscription_id == "test-queue-func-test-queue" - && subscription.topic.as_deref() - == Some("projects/test-project-123/topics/test-test-queue") - }) - .times(1) - .returning(|_, subscription| Ok(subscription)); - - let mock_provider = - setup_mock_service_provider_with_pubsub(mock_cloudrun, None, Arc::new(mock_pubsub)); - let queue_controller = GcpQueueController { - state: GcpQueueState::Ready, - topic_name: Some("test-test-queue".to_string()), - subscription_name: Some("test-test-queue-sub".to_string()), - _internal_stay_count: None, - }; - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .with_dependency(test_queue(), queue_controller) - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - /// Test that verifies public workers get IAM policy update - #[tokio::test] - async fn test_public_function_sets_iam_policy() { - let worker = function_public_ingress(); - let function_name = format!("test-{}", worker.id); - - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock service creation - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - mock_cloudrun - .expect_get_operation() - .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) - .times(1); - - let function_name_for_get = function_name.clone(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) - .times(1); - - // Validate IAM policy operations are called for resource-scoped permissions - mock_cloudrun - .expect_get_service_iam_policy() - .withf(|location, service_name| { - location == "us-central1" && service_name.starts_with("test-") - }) - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .withf(|location, service_name, _policy| { - location == "us-central1" && service_name.starts_with("test-") - }) - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock deletion - let delete_operation_name = format!("delete-{}", function_name); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - let compute_mock = create_ssl_compute_mock_for_creation_and_deletion(); - let mock_provider = - setup_mock_service_provider(Arc::new(mock_cloudrun), Some(compute_mock)); - let domain_metadata = create_test_domain_metadata(&worker.id); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .domain_metadata(domain_metadata) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify URL is in outputs - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.public_endpoints.contains_key("default")); - } - - /// Test that verifies private workers handle resource-scoped permissions correctly - #[tokio::test] - async fn test_private_function_skips_iam_policy() { - let worker = function_private_ingress(); - let function_name = format!("test-{}", worker.id); - - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Mock service creation - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - mock_cloudrun - .expect_get_operation() - .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) - .times(1); - - let function_name_for_get = function_name.clone(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) - .times(1); - - // IAM policy operations are now called for all workers (for resource-scoped permissions) - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock deletion - let delete_operation_name = format!("delete-{}", function_name); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify URL is still available for private workers (internal access) - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.public_endpoints.contains_key("default")); - } - - /// Test that verifies correct service configuration parameters - #[tokio::test] - async fn test_service_configuration_validation() { - let worker = function_custom_config(); - let function_name = format!("test-{}", worker.id); - - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Validate service creation request has correct parameters - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .withf(|_, _, service, _| { - // Check if the service has the expected configuration - if let Some(template) = &service.template { - let containers = &template.containers; - if let Some(container) = containers.first() { - // Check memory configuration - if let Some(resources) = &container.resources { - if let Some(limits) = &resources.limits { - if let Some(memory) = limits.get("memory") { - return memory == "512Mi"; - } - } - } - } - } - false - }) - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - mock_cloudrun - .expect_get_operation() - .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) - .times(1); - - let function_name_for_get = function_name.clone(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) - .times(1); - - // Mock IAM policy operations for resource-scoped permissions - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock deletion - let delete_operation_name = format!("delete-{}", function_name); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - /// Test that verifies environment variables are correctly passed - #[tokio::test] - async fn test_environment_variable_handling() { - let worker = function_with_env_vars(); - let function_name = format!("test-{}", worker.id); - - let mut mock_cloudrun = MockCloudRunApi::new(); - - // Validate service creation request has environment variables - let operation_name = format!("create-{}", function_name); - let operation_name_for_get = operation_name.clone(); - mock_cloudrun - .expect_create_service() - .withf(|_, _, service, _| { - if let Some(template) = &service.template { - let containers = &template.containers; - if let Some(container) = containers.first() { - // Check environment variables - let env_vars: HashMap = container - .env - .iter() - .filter_map(|env| { - env.value.as_ref().map(|v| (env.name.clone(), v.clone())) - }) - .collect(); - - return env_vars.get("APP_ENV") == Some(&"production".to_string()) - && env_vars.get("LOG_LEVEL") == Some(&"debug".to_string()) - && env_vars.get("DB_NAME") == Some(&"myapp".to_string()); - } - } - false - }) - .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); - - mock_cloudrun - .expect_get_operation() - .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) - .times(1); - - let function_name_for_get = function_name.clone(); - mock_cloudrun - .expect_get_service() - .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) - .times(1); - - // Mock IAM policy operations for resource-scoped permissions - mock_cloudrun - .expect_get_service_iam_policy() - .returning(|_, _| Ok(create_empty_iam_policy())); - - mock_cloudrun - .expect_set_service_iam_policy() - .returning(|_, _, _| Ok(create_empty_iam_policy())); - - // Mock deletion - let delete_operation_name = format!("delete-{}", function_name); - let delete_operation_name_for_get = delete_operation_name.clone(); - mock_cloudrun - .expect_delete_service() - .returning(move |_, _, _, _| { - Ok(create_successful_operation_response(&delete_operation_name)) - }); - - mock_cloudrun.expect_get_operation().returning(move |_, _| { - Ok(create_completed_operation_response( - &delete_operation_name_for_get, - )) - }); - - mock_cloudrun.expect_get_service().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Service".to_string(), - resource_name: "test-service".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(GcpWorkerController::default()) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - /// Test that verifies deletion works when service_name is not set (early creation failure) - #[tokio::test] - async fn test_delete_with_no_service_name_succeeds() { - let worker = basic_function(); - - // Create a controller with no service name set (simulating early creation failure) - let controller = GcpWorkerController { - state: GcpWorkerState::CreateFailed, - service_name: None, // This is the key - no service name set - url: None, - operation_name: None, - image_pull_permission_retries: 0, - compute_operation_name: None, - compute_operation_region: None, - push_subscriptions: Vec::new(), - fqdn: None, - certificate_id: None, - ssl_certificate_name: None, - uses_custom_domain: false, - certificate_issued_at: None, - serverless_neg_name: None, - backend_service_name: None, - url_map_name: None, - target_https_proxy_name: None, - global_address_name: None, - global_address_ip: None, - forwarding_rule_name: None, - commands_topic_name: None, - commands_subscription_name: None, - storage_notification_topics: Vec::new(), - gcs_notification_ids: Vec::new(), - scheduler_job_names: Vec::new(), - project_id: None, - region: None, - _internal_stay_count: None, - }; - - // Mock provider - no expectations since no API calls should be made - let mock_provider = Arc::new(MockPlatformServiceProvider::new()); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(controller) - .platform(Platform::Gcp) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Start in CreateFailed state - assert_eq!(executor.status(), ResourceStatus::ProvisionFailed); - - // Delete the worker - executor.delete().unwrap(); - - // Run the delete flow - should succeed without making any API calls - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } -} diff --git a/crates/alien-infra/src/worker/gcp/support.rs b/crates/alien-infra/src/worker/gcp/support.rs new file mode 100644 index 000000000..0c0bad0f5 --- /dev/null +++ b/crates/alien-infra/src/worker/gcp/support.rs @@ -0,0 +1,248 @@ +use std::collections::HashSet; +use std::time::Duration; + +use crate::core::ResourceControllerContext; +use alien_client_core::ErrorData as CloudClientErrorData; +use alien_core::{ + GcpCloudRunWorkerHeartbeatData, HeartbeatBackend, ObservedHealth, Platform, + ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, Worker, WorkerHeartbeatData, + WorkloadHeartbeatStatus, +}; +use alien_error::{AlienError, GenericError}; +use alien_gcp_clients::cloudrun::Service; +use alien_gcp_clients::gcs::GcsNotification; +use chrono::Utc; +use sha2::{Digest, Sha256}; + +pub(super) const CLOUD_RUN_SERVICE_NAME_MAX_LEN: usize = 49; +pub(super) const GCP_RESOURCE_NAME_MAX_LEN: usize = 63; +pub(super) const GCP_RESOURCE_NAME_HASH_LEN: usize = 8; +pub(super) const MAX_IMAGE_PULL_PERMISSION_RETRIES: u8 = 4; + +pub(super) fn is_cross_project_image_pull_permission_error(message: &str) -> bool { + message.contains("serverless-robot-prod.iam.gserviceaccount.com") + && message.contains("artifactregistry.repositories.downloadArtifacts") +} + +pub(super) fn image_pull_permission_retry_delay(attempt: u8) -> Duration { + Duration::from_secs(10 * (1_u64 << attempt.saturating_sub(1).min(3))) +} + +pub(super) fn is_remote_resource_conflict(error: &AlienError) -> bool { + matches!( + &error.error, + Some(CloudClientErrorData::RemoteResourceConflict { .. }) + ) +} + +pub(super) fn error_chain_contains_resource_in_use( + code: &str, + message: &str, + source: Option<&AlienError>, +) -> bool { + (code == "INVALID_INPUT" || code == "HTTP_RESPONSE_ERROR") + && (message.contains("being used by") || message.contains("resourceInUseByAnotherResource")) + || source.is_some_and(|source| { + error_chain_contains_resource_in_use( + &source.code, + &source.message, + source.source.as_deref(), + ) + }) +} + +pub(super) fn is_gcp_resource_in_use(error: &AlienError) -> bool { + error_chain_contains_resource_in_use(&error.code, &error.message, error.source.as_deref()) +} + +pub(super) fn same_unordered_strings(left: &[String], right: &[String]) -> bool { + left.iter().collect::>() == right.iter().collect::>() +} + +pub(super) fn gcs_notification_matches_existing( + existing: &GcsNotification, + desired: &GcsNotification, +) -> bool { + existing.topic == desired.topic + && same_unordered_strings(&existing.event_types, &desired.event_types) + && existing.payload_format == desired.payload_format + && existing.object_name_prefix == desired.object_name_prefix + && existing.custom_attributes == desired.custom_attributes +} + +/// Generates the Cloud Run service name from stack prefix and worker ID +pub(super) fn get_cloudrun_service_name(prefix: &str, name: &str) -> String { + let raw = format!("{}-{}", prefix, name); + let sanitized = sanitize_gcp_resource_name(&raw); + + if sanitized == raw && sanitized.len() <= CLOUD_RUN_SERVICE_NAME_MAX_LEN { + return sanitized; + } + + stable_hashed_gcp_resource_name(&raw, &sanitized, CLOUD_RUN_SERVICE_NAME_MAX_LEN) +} + +pub(super) fn get_gcp_worker_resource_name(prefix: &str, worker_id: &str, suffix: &str) -> String { + let raw = format!("{prefix}-{worker_id}-{suffix}"); + let sanitized = sanitize_gcp_resource_name(&raw); + + if sanitized == raw && sanitized.len() <= GCP_RESOURCE_NAME_MAX_LEN { + return sanitized; + } + + stable_hashed_gcp_resource_name(&raw, &sanitized, GCP_RESOURCE_NAME_MAX_LEN) +} + +pub(super) fn sanitize_gcp_resource_name(raw: &str) -> String { + let mut name = String::with_capacity(raw.len()); + let mut last_was_dash = false; + + for ch in raw.chars() { + let normalized = match ch { + 'a'..='z' | '0'..='9' => Some(ch), + 'A'..='Z' => Some(ch.to_ascii_lowercase()), + '-' => Some('-'), + _ => Some('-'), + }; + + if let Some(ch) = normalized { + if ch == '-' { + if !last_was_dash && !name.is_empty() { + name.push(ch); + } + last_was_dash = true; + } else { + name.push(ch); + last_was_dash = false; + } + } + } + + while name.ends_with('-') { + name.pop(); + } + + if !name + .chars() + .next() + .map(|ch| ch.is_ascii_lowercase()) + .unwrap_or(false) + { + name.insert_str(0, "a-"); + } + + name +} + +pub(super) fn stable_hashed_gcp_resource_name( + raw: &str, + sanitized: &str, + max_len: usize, +) -> String { + let hash = stable_name_hash(raw); + let max_stem_len = max_len - GCP_RESOURCE_NAME_HASH_LEN - "-".len(); + let mut stem = sanitized + .chars() + .take(max_stem_len) + .collect::() + .trim_end_matches('-') + .to_string(); + + if stem.is_empty() { + stem = "a".to_string(); + } + + format!("{stem}-{hash}") +} + +pub(super) fn stable_name_hash(raw: &str) -> String { + let digest = Sha256::digest(raw.as_bytes()); + digest + .iter() + .take(GCP_RESOURCE_NAME_HASH_LEN / 2) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Domain information for a worker. +pub(super) struct DomainInfo { + pub(super) fqdn: String, + pub(super) certificate_id: Option, + pub(super) ssl_certificate_name: Option, + pub(super) uses_custom_domain: bool, +} + +pub(super) fn emit_gcp_cloud_run_worker_heartbeat( + ctx: &ResourceControllerContext<'_>, + worker_config: &Worker, + service_name: &str, + service: &Service, +) { + let container = service + .template + .as_ref() + .and_then(|template| template.containers.first()); + let limits = container.and_then(|container| { + container + .resources + .as_ref() + .and_then(|resources| resources.limits.as_ref()) + }); + let scaling = service.scaling.as_ref(); + + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: worker_config.id.clone(), + resource_type: Worker::RESOURCE_TYPE, + controller_platform: Platform::Gcp, + backend: HeartbeatBackend::Gcp, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Worker(WorkerHeartbeatData::GcpCloudRun( + GcpCloudRunWorkerHeartbeatData { + status: WorkloadHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: Some(format!("Cloud Run service '{service_name}' is ready")), + stale: false, + partial: false, + collection_issues: vec![], + }, + service: service_name.to_string(), + region: Some( + ctx.get_gcp_config() + .map(|config| config.region.clone()) + .unwrap_or_default(), + ), + uri: service.uri.clone(), + urls: service.urls.clone(), + latest_created_revision: service.latest_created_revision.clone(), + latest_ready_revision: service.latest_ready_revision.clone(), + generation: service + .generation + .as_deref() + .and_then(|generation| generation.parse::().ok()), + observed_generation: service + .observed_generation + .as_deref() + .and_then(|generation| generation.parse::().ok()), + traffic_count: service.traffic.len() as u32, + min_instance_count: scaling.and_then(|scaling| scaling.min_instance_count), + max_instance_count: scaling.and_then(|scaling| scaling.max_instance_count), + container_image: container.map(|container| container.image.clone()), + cpu_limit: limits.and_then(|limits| limits.get("cpu").cloned()), + memory_limit: limits.and_then(|limits| limits.get("memory").cloned()), + }, + )), + raw: vec![], + }); +} + +/// Tracks a GCS notification configuration for cleanup during deletion. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GcsNotificationTracker { + /// The bucket the notification is attached to + pub bucket_name: String, + /// The server-assigned notification ID + pub notification_id: String, +} diff --git a/crates/alien-infra/src/worker/gcp/tests.rs b/crates/alien-infra/src/worker/gcp/tests.rs new file mode 100644 index 000000000..05599910f --- /dev/null +++ b/crates/alien-infra/src/worker/gcp/tests.rs @@ -0,0 +1,1441 @@ +//! # GCP Worker Controller Tests +//! +//! See `crate::core::controller_test` for a comprehensive guide on testing infrastructure controllers. + +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use alien_client_core::{ErrorData as CloudClientErrorData, Result as CloudClientResult}; +use alien_core::{ + CertificateStatus, DnsRecordStatus, DomainMetadata, HttpMethod, Platform, ResourceDomainInfo, + ResourceStatus, Worker, WorkerOutputs, +}; +use alien_error::AlienError; +use alien_gcp_clients::cloudrun::{Condition, ConditionState, MockCloudRunApi, Service}; +use alien_gcp_clients::gcp::compute::{Address, MockComputeApi, Operation, OperationStatus}; +use alien_gcp_clients::iam::{IamPolicy, MockIamApi}; +use alien_gcp_clients::longrunning::Operation as LongRunningOperation; +use alien_gcp_clients::longrunning::{OperationResult, Status}; +use alien_gcp_clients::pubsub::MockPubSubApi; +use httpmock::{prelude::*, Mock}; +use rstest::rstest; + +use super::{ + get_cloudrun_service_name, get_gcp_worker_resource_name, + is_cross_project_image_pull_permission_error, CLOUD_RUN_SERVICE_NAME_MAX_LEN, + GCP_RESOURCE_NAME_MAX_LEN, +}; +use crate::core::MockPlatformServiceProvider; +use crate::core::{ + controller_test::{SingleControllerExecutor, SingleControllerExecutorBuilder}, + PlatformServiceProvider, +}; +use crate::worker::readiness_probe::test_utils::create_readiness_probe_mock; +use crate::worker::{fixtures::*, GcpWorkerController}; +use crate::GcpWorkerState; + +#[test] +fn cloudrun_service_name_preserves_valid_short_names() { + assert_eq!( + get_cloudrun_service_name("test-stack", "worker"), + "test-stack-worker" + ); +} + +#[test] +fn image_pull_retry_only_matches_cross_project_gar_permission_denials() { + assert!(is_cross_project_image_pull_permission_error( + "Google Cloud Run Service Agent service-123@serverless-robot-prod.iam.gserviceaccount.com \ + was denied artifactregistry.repositories.downloadArtifacts" + )); + assert!(!is_cross_project_image_pull_permission_error( + "artifactregistry.repositories.downloadArtifacts denied for a user service account" + )); + assert!(!is_cross_project_image_pull_permission_error( + "Cloud Run revision failed its startup probe" + )); +} + +#[test] +fn cloudrun_service_name_caps_long_e2e_names_with_stable_hash() { + let service_name = get_cloudrun_service_name( + "e2e-gcp-terraform-worker-mpfa2f19-15fb", + "test-alien-ts-function", + ); + + assert!(service_name.len() <= CLOUD_RUN_SERVICE_NAME_MAX_LEN); + assert!(service_name.starts_with("e")); + assert!(!service_name.ends_with('-')); + assert!(service_name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')); + assert_eq!( + service_name, + get_cloudrun_service_name( + "e2e-gcp-terraform-worker-mpfa2f19-15fb", + "test-alien-ts-function", + ) + ); +} + +#[test] +fn cloudrun_service_name_sanitizes_invalid_input() { + let service_name = get_cloudrun_service_name("123_Test.Stack", "Worker_Name_"); + + assert!(service_name.len() <= CLOUD_RUN_SERVICE_NAME_MAX_LEN); + assert!(service_name.starts_with('a')); + assert!(!service_name.ends_with('-')); + assert!(service_name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')); +} + +#[test] +fn gcp_worker_resource_name_caps_long_certificate_names_with_stable_hash() { + let cert_name = get_gcp_worker_resource_name( + "e2e-gcp-terraform-worker-mpfgzubr-tux", + "test-alien-ts-function", + "cert", + ); + + assert!(cert_name.len() <= GCP_RESOURCE_NAME_MAX_LEN); + assert!(cert_name.starts_with('e')); + assert!(!cert_name.ends_with('-')); + assert!(cert_name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')); + assert_eq!( + cert_name, + get_gcp_worker_resource_name( + "e2e-gcp-terraform-worker-mpfgzubr-tux", + "test-alien-ts-function", + "cert", + ) + ); +} + +fn create_test_domain_metadata(resource_id: &str) -> DomainMetadata { + let mut resources = HashMap::new(); + resources.insert( + resource_id.to_string(), + ResourceDomainInfo { + fqdn: format!("{}.test.example.com", resource_id), + certificate_id: "test-cert-id".to_string(), + certificate_status: CertificateStatus::Issued, + dns_status: DnsRecordStatus::Active, + dns_error: None, + certificate_chain: Some( + "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n".to_string(), + ), + private_key: Some( + "-----BEGIN RSA PRIVATE KEY-----\nMIIBtest\n-----END RSA PRIVATE KEY-----\n" + .to_string(), + ), + endpoints: HashMap::new(), + issued_at: Some("2024-01-01T00:00:00Z".to_string()), + aliases: Vec::new(), + }, + ); + DomainMetadata { + base_domain: "test.example.com".to_string(), + public_subdomain: "test".to_string(), + hosted_zone_id: "Z1234567890ABC".to_string(), + resources, + } +} + +fn create_ssl_compute_mock_for_creation_and_deletion() -> Arc { + fn completed_compute_operation() -> Operation { + Operation { + name: Some("test-compute-operation".to_string()), + status: Some(OperationStatus::Done), + ..Default::default() + } + } + + let mut mock = MockComputeApi::new(); + mock.expect_insert_ssl_certificate() + .returning(|_| Ok(completed_compute_operation())); + mock.expect_insert_region_network_endpoint_group() + .returning(|_, _| Ok(completed_compute_operation())); + mock.expect_insert_backend_service() + .returning(|_| Ok(completed_compute_operation())); + mock.expect_insert_url_map() + .returning(|_| Ok(completed_compute_operation())); + mock.expect_insert_target_https_proxy() + .returning(|_| Ok(completed_compute_operation())); + mock.expect_insert_global_address() + .returning(|_| Ok(completed_compute_operation())); + mock.expect_get_global_address().returning(|_| { + Ok(Address { + address: Some("203.0.113.1".to_string()), + ..Default::default() + }) + }); + mock.expect_insert_global_forwarding_rule() + .returning(|_| Ok(completed_compute_operation())); + mock.expect_delete_global_forwarding_rule() + .returning(|_| Ok(Operation::default())); + mock.expect_delete_target_https_proxy() + .returning(|_| Ok(Operation::default())); + mock.expect_delete_url_map() + .returning(|_| Ok(Operation::default())); + mock.expect_delete_backend_service() + .returning(|_| Ok(Operation::default())); + mock.expect_delete_region_network_endpoint_group() + .returning(|_, _| Ok(Operation::default())); + mock.expect_delete_ssl_certificate() + .returning(|_| Ok(Operation::default())); + mock.expect_delete_global_address() + .returning(|_| Ok(Operation::default())); + Arc::new(mock) +} + +fn resource_in_use_error() -> AlienError { + AlienError::new(CloudClientErrorData::InvalidInput { + message: "The targetHttpsProxy resource is already being used by forwardingRules/test-fwd resourceInUseByAnotherResource".to_string(), + field_name: None, + }) +} + +fn create_successful_service_response(service_name: &str) -> Service { + use alien_gcp_clients::cloudrun::Service; + + Service::builder() + .name(format!( + "projects/test-project/locations/us-central1/services/{}", + service_name + )) + .uri(format!("https://{}-abcd1234-uc.a.run.app", service_name)) + .urls(vec![format!( + "https://{}-abcd1234-uc.a.run.app", + service_name + )]) + .conditions(vec![Condition::builder() + .r#type("Ready".to_string()) + .state(ConditionState::ConditionSucceeded) + .build()]) + .build() +} + +fn create_successful_operation_response(operation_name: &str) -> LongRunningOperation { + LongRunningOperation::builder() + .name(format!( + "projects/test-project/locations/us-central1/operations/{}", + operation_name + )) + .done(false) + .build() +} + +fn create_completed_operation_response(operation_name: &str) -> LongRunningOperation { + LongRunningOperation::builder() + .name(format!("projects/test-project/locations/us-central1/operations/{}", operation_name)) + .done(true) + .result(OperationResult::Response { + response: serde_json::json!({ + "name": format!("projects/test-project/locations/us-central1/services/test-{}", operation_name) + }) + }) + .build() +} + +fn create_image_pull_permission_denied_operation(operation_name: &str) -> LongRunningOperation { + LongRunningOperation::builder() + .name(format!( + "projects/test-project/locations/us-central1/operations/{operation_name}" + )) + .done(true) + .result(OperationResult::Error { + error: Status::builder() + .code(7) + .message( + "Google Cloud Run Service Agent \ + service-123@serverless-robot-prod.iam.gserviceaccount.com must have \ + artifactregistry.repositories.downloadArtifacts permission" + .to_string(), + ) + .build(), + }) + .build() +} + +fn create_empty_iam_policy() -> IamPolicy { + IamPolicy::builder().version(1).bindings(vec![]).build() +} + +fn setup_mock_client_for_creation_and_update( + function_name: &str, + _has_public_access: bool, +) -> Arc { + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock successful service creation + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + // Mock operation status checks - first pending, then completed + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))); + + // Mock service retrieval after creation + let function_name_for_get = function_name.to_string(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))); + + // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock successful updates + let function_name_for_update = function_name.to_string(); + let update_operation_name = format!("update-{}", function_name_for_update); + mock_cloudrun + .expect_patch_service() + .returning(move |_, _, _, _, _, _| { + Ok(create_successful_operation_response(&update_operation_name)) + }); + + Arc::new(mock_cloudrun) +} + +fn setup_mock_client_for_creation_and_deletion( + function_name: &str, + _has_public_access: bool, +) -> Arc { + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock successful service creation + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + // Mock operation status checks for creation + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) + .times(1); // Only for creation flow + + // Mock service retrieval after creation + let function_name_for_get = function_name.to_string(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) + .times(1); // Only for creation flow + + // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock successful service deletion + let function_name_for_delete = function_name.to_string(); + let delete_operation_name = format!("delete-{}", function_name_for_delete); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + // Mock operation status checks for deletion + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + + // Mock service not found during deletion check + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + Arc::new(mock_cloudrun) +} + +fn setup_mock_client_for_best_effort_deletion( + _function_name: &str, + service_missing: bool, +) -> Arc { + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock service deletion (might fail if service missing) + if service_missing { + mock_cloudrun + .expect_delete_service() + .returning(|_, _, _, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + } else { + let delete_operation_name = "delete-test".to_string(); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + } + + // Always return not found for final status check + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + Arc::new(mock_cloudrun) +} + +fn create_gcp_iam_mock_for_resource_permissions() -> Arc { + Arc::new(MockIamApi::new()) +} + +fn setup_mock_service_provider( + mock_cloudrun: Arc, + mock_compute: Option>, +) -> Arc { + setup_mock_service_provider_with_pubsub( + mock_cloudrun, + mock_compute, + Arc::new(MockPubSubApi::new()), + ) +} + +fn setup_mock_service_provider_with_pubsub( + mock_cloudrun: Arc, + mock_compute: Option>, + mock_pubsub: Arc, +) -> Arc { + let mut mock_provider = MockPlatformServiceProvider::new(); + + mock_provider + .expect_get_gcp_cloudrun_client() + .returning(move |_| Ok(mock_cloudrun.clone())); + + if let Some(compute) = mock_compute { + mock_provider + .expect_get_gcp_compute_client() + .returning(move |_| Ok(compute.clone())); + } + + // Mock IAM client for resource-scoped permissions. + let mock_iam = create_gcp_iam_mock_for_resource_permissions(); + mock_provider + .expect_get_gcp_iam_client() + .returning(move |_| Ok(mock_iam.clone())); + + mock_provider + .expect_get_gcp_pubsub_client() + .returning(move |_| Ok(mock_pubsub.clone())); + + Arc::new(mock_provider) +} + +/// Sets up mock CloudRun client and optional readiness probe mock server +/// Returns (cloudrun_mock_provider, optional_mock_server, optional_domain_metadata) +fn setup_mocks_for_function( + worker: &Worker, + function_name: &str, + for_deletion: bool, +) -> ( + Arc, + Option, + Option, +) { + let has_public_access = !worker.public_endpoints.is_empty(); + let needs_readiness_probe = has_public_access && worker.readiness_probe.is_some(); + + // Set up mock server for readiness probe if needed + let mock_server = if needs_readiness_probe { + Some(create_readiness_probe_mock(worker)) + } else { + None + }; + + // Set up CloudRun client mock + let cloudrun_mock = if for_deletion { + if let Some(ref _server) = mock_server { + setup_mock_client_for_creation_and_deletion_with_mock_url( + function_name, + has_public_access, + &_server.base_url(), + ) + } else { + setup_mock_client_for_creation_and_deletion(function_name, has_public_access) + } + } else { + if let Some(ref _server) = mock_server { + setup_mock_client_for_creation_and_update_with_mock_url( + function_name, + has_public_access, + &_server.base_url(), + ) + } else { + setup_mock_client_for_creation_and_update(function_name, has_public_access) + } + }; + + // For public workers, also set up compute mock and domain metadata + let (compute_mock, domain_metadata) = if has_public_access { + let dm = create_test_domain_metadata(&worker.id); + let compute = create_ssl_compute_mock_for_creation_and_deletion(); + (Some(compute), Some(dm)) + } else { + (None, None) + }; + + let mock_provider = setup_mock_service_provider(cloudrun_mock, compute_mock); + + (mock_provider, mock_server, domain_metadata) +} + +fn setup_mock_client_for_creation_and_update_with_mock_url( + function_name: &str, + has_public_access: bool, + mock_url: &str, +) -> Arc { + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock successful service creation + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + // Mock operation status checks + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))); + + // Mock service retrieval after creation - use mock URL + let mock_url = mock_url.to_string(); + let function_name_for_get = function_name.to_string(); + mock_cloudrun.expect_get_service().returning(move |_, _| { + let mut service = create_successful_service_response(&function_name_for_get); + service.uri = Some(mock_url.clone()); + service.urls = vec![mock_url.clone()]; + Ok(service) + }); + + // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock successful updates + let function_name_for_update = function_name.to_string(); + let update_operation_name = format!("update-{}", function_name_for_update); + mock_cloudrun + .expect_patch_service() + .returning(move |_, _, _, _, _, _| { + Ok(create_successful_operation_response(&update_operation_name)) + }); + + Arc::new(mock_cloudrun) +} + +fn setup_mock_client_for_creation_and_deletion_with_mock_url( + function_name: &str, + has_public_access: bool, + mock_url: &str, +) -> Arc { + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock successful service creation + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + // Mock operation status checks for creation + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) + .times(1); // Only for creation flow + + // Mock service retrieval after creation - use mock URL + let mock_url = mock_url.to_string(); + let function_name_for_get = function_name.to_string(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| { + let mut service = create_successful_service_response(&function_name_for_get); + service.uri = Some(mock_url.clone()); + service.urls = vec![mock_url.clone()]; + Ok(service) + }) + .times(1); // Only for creation flow + + // Mock IAM policy operations for all workers (resource-scoped permissions + optional public access) + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock successful service deletion + let function_name_for_delete = function_name.to_string(); + let delete_operation_name = format!("delete-{}", function_name_for_delete); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + // Mock operation status checks for deletion + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + + // Mock service not found during deletion check + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + Arc::new(mock_cloudrun) +} + +// ─────────────── CREATE AND DELETE FLOW TESTS ──────────────────── + +#[rstest] +#[case::basic(basic_function())] +#[case::env_vars(function_with_env_vars())] +#[case::storage_link(function_with_storage_link())] +#[case::env_and_storage(function_with_env_and_storage())] +#[case::multiple_storages(function_with_multiple_storages())] +#[case::public_ingress(function_public_ingress())] +#[case::private_ingress(function_private_ingress())] +#[case::concurrency(function_with_concurrency())] +#[case::custom_config(function_custom_config())] +#[case::readiness_probe(function_with_readiness_probe())] +#[case::complete_test(function_complete_test())] +#[tokio::test] +async fn test_create_and_delete_flow_succeeds(#[case] worker: Worker) { + let worker_id = worker.id.clone(); + let worker_is_public = !worker.public_endpoints.is_empty(); + let function_name = format!("test-{}", worker.id); + let (mock_provider, _mock_server, domain_metadata) = + setup_mocks_for_function(&worker, &function_name, true); + + let mut builder = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies(); + + if let Some(dm) = domain_metadata { + builder = builder.domain_metadata(dm); + } + + let mut executor = builder.build().await.unwrap(); + + // Run create flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify outputs are available + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.identifier.is_some()); + assert!(function_outputs.worker_name.starts_with("test-")); + if worker_is_public { + let expected_url = format!("https://{}.test.example.com", worker_id); + let endpoint = function_outputs + .public_endpoints + .get("default") + .expect("public endpoint output should exist"); + assert_eq!(endpoint.url, expected_url); + assert_eq!( + endpoint + .load_balancer_endpoint + .as_ref() + .map(|endpoint| endpoint.dns_name.as_str()), + Some("203.0.113.1") + ); + } + + // Delete the worker + executor.delete().unwrap(); + + // Run delete flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} + +#[tokio::test] +async fn retries_cloud_run_revision_after_gar_reader_grant_propagates() { + let worker = basic_function(); + let function_name = format!("test-{}", worker.id); + let operation_checks = Arc::new(AtomicUsize::new(0)); + let operation_checks_for_mock = Arc::clone(&operation_checks); + let patch_calls = Arc::new(AtomicUsize::new(0)); + let patch_calls_for_mock = Arc::clone(&patch_calls); + + let mut mock_cloudrun = MockCloudRunApi::new(); + mock_cloudrun + .expect_create_service() + .times(1) + .returning(|_, _, _, _| Ok(create_successful_operation_response("create-worker"))); + mock_cloudrun + .expect_get_operation() + .times(2) + .returning(move |_, _| { + if operation_checks_for_mock.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(create_image_pull_permission_denied_operation( + "create-worker", + )) + } else { + Ok(create_completed_operation_response("retry-worker")) + } + }); + mock_cloudrun + .expect_patch_service() + .times(1) + .withf(|_, _, service, update_mask, _, allow_missing| { + let has_retry_label = service + .template + .as_ref() + .and_then(|template| template.labels.as_ref()) + .is_some_and(|labels| { + labels.get("alien-image-pull-retry").map(String::as_str) == Some("1") + }); + has_retry_label + && update_mask.as_deref() == Some("template") + && *allow_missing == Some(false) + }) + .returning(move |_, _, _, _, _, _| { + patch_calls_for_mock.fetch_add(1, Ordering::SeqCst); + Ok(create_successful_operation_response("retry-worker")) + }); + let function_name_for_get = function_name.clone(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))); + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + for _ in 0..30 { + if executor.status() == ResourceStatus::Running { + break; + } + executor.step().await.unwrap(); + } + + assert_eq!(executor.status(), ResourceStatus::Running); + assert_eq!(operation_checks.load(Ordering::SeqCst), 2); + assert_eq!(patch_calls.load(Ordering::SeqCst), 1); +} + +// ─────────────── UPDATE FLOW TESTS ──────────────────────────────── + +#[rstest] +#[case::basic_to_env(basic_function(), function_with_env_vars())] +#[case::env_to_storage(function_with_env_vars(), function_with_storage_link())] +#[case::storage_to_custom(function_with_storage_link(), function_custom_config())] +#[case::custom_to_public(function_custom_config(), function_public_ingress())] +#[case::public_to_complete(function_public_ingress(), function_complete_test())] +#[case::complete_to_basic(function_complete_test(), basic_function())] +#[tokio::test] +async fn test_update_flow_succeeds(#[case] from_function: Worker, #[case] to_function: Worker) { + // Ensure both workers have the same ID for valid updates + let worker_id = "test-update-worker".to_string(); + let mut from_function = from_function; + from_function.id = worker_id.clone(); + + let mut to_function = to_function; + to_function.id = worker_id.clone(); + + let function_name = format!("test-{}", worker_id); + let (mock_provider, mock_server, domain_metadata) = + setup_mocks_for_function(&to_function, &function_name, false); + + // Start with the "from" worker in Ready state + let mut ready_controller = GcpWorkerController::mock_ready(&function_name); + + // If the target worker has a readiness probe, update the controller URL to point to mock server + if to_function.readiness_probe.is_some() && !to_function.public_endpoints.is_empty() { + if let Some(ref server) = mock_server { + ready_controller.url = Some(server.base_url()); + } + } + + let mut builder = SingleControllerExecutor::builder() + .resource(from_function) + .controller(ready_controller) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies(); + + if let Some(dm) = domain_metadata { + builder = builder.domain_metadata(dm); + } + + let mut executor = builder.build().await.unwrap(); + + // Ensure we start in Running state + assert_eq!(executor.status(), ResourceStatus::Running); + + // Update to the new worker + let target_is_public = !to_function.public_endpoints.is_empty(); + executor.update(to_function).unwrap(); + + // Run the update flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + if target_is_public { + let expected_url = format!("https://{}.test.example.com", worker_id); + assert_eq!( + function_outputs + .public_endpoints + .get("default") + .map(|endpoint| endpoint.url.as_str()), + Some(expected_url.as_str()) + ); + } +} + +// ─────────────── BEST EFFORT DELETION TESTS ─────────────────────── + +#[rstest] +#[case::basic(basic_function(), false)] +#[case::public_with_missing_service(function_public_ingress(), true)] +#[case::private_with_missing_service(function_private_ingress(), true)] +#[tokio::test] +async fn test_best_effort_deletion_when_resources_missing( + #[case] worker: Worker, + #[case] service_missing: bool, +) { + let function_name = format!("test-{}", worker.id); + let has_public_access = !worker.public_endpoints.is_empty(); + let mock_cloudrun = setup_mock_client_for_best_effort_deletion(&function_name, service_missing); + let mock_provider = setup_mock_service_provider(mock_cloudrun, None); + + // Start with a ready controller + let mut ready_controller = GcpWorkerController::mock_ready(&function_name); + if has_public_access { + ready_controller.url = Some("https://example-abcd1234-uc.a.run.app".to_string()); + } + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(ready_controller) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Ensure we start in Running state + assert_eq!(executor.status(), ResourceStatus::Running); + + // Delete the worker + executor.delete().unwrap(); + + // Run the delete flow - it should succeed even when resources are missing + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} + +#[tokio::test] +async fn test_delete_retries_target_proxy_while_forwarding_rule_reference_drains() { + let worker = function_public_ingress(); + let function_name = format!("test-{}", worker.id); + let proxy_delete_attempts = Arc::new(AtomicUsize::new(0)); + let proxy_delete_attempts_for_mock = Arc::clone(&proxy_delete_attempts); + let mock_cloudrun = setup_mock_client_for_creation_and_deletion(&function_name, true); + + let mut mock_compute = MockComputeApi::new(); + mock_compute + .expect_delete_global_forwarding_rule() + .returning(|_| Ok(Operation::default())); + mock_compute + .expect_delete_target_https_proxy() + .returning(move |_| { + if proxy_delete_attempts_for_mock.fetch_add(1, Ordering::SeqCst) == 0 { + Err(resource_in_use_error()) + } else { + Ok(Operation::default()) + } + }); + mock_compute + .expect_delete_url_map() + .returning(|_| Ok(Operation::default())); + mock_compute + .expect_delete_backend_service() + .returning(|_| Ok(Operation::default())); + mock_compute + .expect_delete_region_network_endpoint_group() + .returning(|_, _| Ok(Operation::default())); + mock_compute + .expect_delete_global_address() + .returning(|_| Ok(Operation::default())); + + let mock_provider = setup_mock_service_provider(mock_cloudrun, Some(Arc::new(mock_compute))); + let mut controller = GcpWorkerController::mock_ready(&function_name); + controller.forwarding_rule_name = Some("test-fwd".to_string()); + controller.target_https_proxy_name = Some("test-proxy".to_string()); + controller.url_map_name = Some("test-url-map".to_string()); + controller.backend_service_name = Some("test-backend".to_string()); + controller.serverless_neg_name = Some("test-neg".to_string()); + controller.global_address_name = Some("test-address".to_string()); + controller.global_address_ip = Some("203.0.113.9".to_string()); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(controller) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.delete().unwrap(); + executor.run_until_terminal().await.unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Deleted); + assert_eq!(proxy_delete_attempts.load(Ordering::SeqCst), 2); +} + +// ─────────────── SPECIFIC VALIDATION TESTS ───────────────── + +#[tokio::test] +async fn queue_push_subscription_uses_fully_qualified_topic_name() { + use crate::queue::gcp::{GcpQueueController, GcpQueueState}; + + let worker = function_with_queue_trigger(); + let function_name = format!("test-{}", worker.id); + let mock_cloudrun = setup_mock_client_for_creation_and_update(&function_name, false); + + let mut mock_pubsub = MockPubSubApi::new(); + mock_pubsub + .expect_create_subscription() + .withf(|subscription_id, subscription| { + subscription_id == "test-queue-func-test-queue" + && subscription.topic.as_deref() + == Some("projects/test-project-123/topics/test-test-queue") + }) + .times(1) + .returning(|_, subscription| Ok(subscription)); + + let mock_provider = + setup_mock_service_provider_with_pubsub(mock_cloudrun, None, Arc::new(mock_pubsub)); + let queue_controller = GcpQueueController { + state: GcpQueueState::Ready, + topic_name: Some("test-test-queue".to_string()), + subscription_name: Some("test-test-queue-sub".to_string()), + _internal_stay_count: None, + }; + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .with_dependency(test_queue(), queue_controller) + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +/// Test that verifies public workers get IAM policy update +#[tokio::test] +async fn test_public_function_sets_iam_policy() { + let worker = function_public_ingress(); + let function_name = format!("test-{}", worker.id); + + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock service creation + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) + .times(1); + + let function_name_for_get = function_name.clone(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) + .times(1); + + // Validate IAM policy operations are called for resource-scoped permissions + mock_cloudrun + .expect_get_service_iam_policy() + .withf(|location, service_name| { + location == "us-central1" && service_name.starts_with("test-") + }) + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .withf(|location, service_name, _policy| { + location == "us-central1" && service_name.starts_with("test-") + }) + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock deletion + let delete_operation_name = format!("delete-{}", function_name); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + let compute_mock = create_ssl_compute_mock_for_creation_and_deletion(); + let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), Some(compute_mock)); + let domain_metadata = create_test_domain_metadata(&worker.id); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .domain_metadata(domain_metadata) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify URL is in outputs + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.public_endpoints.contains_key("default")); +} + +/// Test that verifies private workers handle resource-scoped permissions correctly +#[tokio::test] +async fn test_private_function_skips_iam_policy() { + let worker = function_private_ingress(); + let function_name = format!("test-{}", worker.id); + + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Mock service creation + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) + .times(1); + + let function_name_for_get = function_name.clone(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) + .times(1); + + // IAM policy operations are now called for all workers (for resource-scoped permissions) + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock deletion + let delete_operation_name = format!("delete-{}", function_name); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify URL is still available for private workers (internal access) + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.public_endpoints.contains_key("default")); +} + +/// Test that verifies correct service configuration parameters +#[tokio::test] +async fn test_service_configuration_validation() { + let worker = function_custom_config(); + let function_name = format!("test-{}", worker.id); + + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Validate service creation request has correct parameters + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .withf(|_, _, service, _| { + // Check if the service has the expected configuration + if let Some(template) = &service.template { + let containers = &template.containers; + if let Some(container) = containers.first() { + // Check memory configuration + if let Some(resources) = &container.resources { + if let Some(limits) = &resources.limits { + if let Some(memory) = limits.get("memory") { + return memory == "512Mi"; + } + } + } + } + } + false + }) + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) + .times(1); + + let function_name_for_get = function_name.clone(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) + .times(1); + + // Mock IAM policy operations for resource-scoped permissions + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock deletion + let delete_operation_name = format!("delete-{}", function_name); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +/// Test that verifies environment variables are correctly passed +#[tokio::test] +async fn test_environment_variable_handling() { + let worker = function_with_env_vars(); + let function_name = format!("test-{}", worker.id); + + let mut mock_cloudrun = MockCloudRunApi::new(); + + // Validate service creation request has environment variables + let operation_name = format!("create-{}", function_name); + let operation_name_for_get = operation_name.clone(); + mock_cloudrun + .expect_create_service() + .withf(|_, _, service, _| { + if let Some(template) = &service.template { + let containers = &template.containers; + if let Some(container) = containers.first() { + // Check environment variables + let env_vars: HashMap = container + .env + .iter() + .filter_map(|env| env.value.as_ref().map(|v| (env.name.clone(), v.clone()))) + .collect(); + + return env_vars.get("APP_ENV") == Some(&"production".to_string()) + && env_vars.get("LOG_LEVEL") == Some(&"debug".to_string()) + && env_vars.get("DB_NAME") == Some(&"myapp".to_string()); + } + } + false + }) + .returning(move |_, _, _, _| Ok(create_successful_operation_response(&operation_name))); + + mock_cloudrun + .expect_get_operation() + .returning(move |_, _| Ok(create_completed_operation_response(&operation_name_for_get))) + .times(1); + + let function_name_for_get = function_name.clone(); + mock_cloudrun + .expect_get_service() + .returning(move |_, _| Ok(create_successful_service_response(&function_name_for_get))) + .times(1); + + // Mock IAM policy operations for resource-scoped permissions + mock_cloudrun + .expect_get_service_iam_policy() + .returning(|_, _| Ok(create_empty_iam_policy())); + + mock_cloudrun + .expect_set_service_iam_policy() + .returning(|_, _, _| Ok(create_empty_iam_policy())); + + // Mock deletion + let delete_operation_name = format!("delete-{}", function_name); + let delete_operation_name_for_get = delete_operation_name.clone(); + mock_cloudrun + .expect_delete_service() + .returning(move |_, _, _, _| { + Ok(create_successful_operation_response(&delete_operation_name)) + }); + + mock_cloudrun.expect_get_operation().returning(move |_, _| { + Ok(create_completed_operation_response( + &delete_operation_name_for_get, + )) + }); + + mock_cloudrun.expect_get_service().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Service".to_string(), + resource_name: "test-service".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_cloudrun), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(GcpWorkerController::default()) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +/// Test that verifies deletion works when service_name is not set (early creation failure) +#[tokio::test] +async fn test_delete_with_no_service_name_succeeds() { + let worker = basic_function(); + + // Create a controller with no service name set (simulating early creation failure) + let controller = GcpWorkerController { + state: GcpWorkerState::CreateFailed, + service_name: None, // This is the key - no service name set + url: None, + operation_name: None, + image_pull_permission_retries: 0, + compute_operation_name: None, + compute_operation_region: None, + push_subscriptions: Vec::new(), + fqdn: None, + certificate_id: None, + ssl_certificate_name: None, + uses_custom_domain: false, + certificate_issued_at: None, + serverless_neg_name: None, + backend_service_name: None, + url_map_name: None, + target_https_proxy_name: None, + global_address_name: None, + global_address_ip: None, + forwarding_rule_name: None, + commands_topic_name: None, + commands_subscription_name: None, + storage_notification_topics: Vec::new(), + gcs_notification_ids: Vec::new(), + scheduler_job_names: Vec::new(), + project_id: None, + region: None, + _internal_stay_count: None, + }; + + // Mock provider - no expectations since no API calls should be made + let mock_provider = Arc::new(MockPlatformServiceProvider::new()); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(controller) + .platform(Platform::Gcp) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Start in CreateFailed state + assert_eq!(executor.status(), ResourceStatus::ProvisionFailed); + + // Delete the worker + executor.delete().unwrap(); + + // Run the delete flow - should succeed without making any API calls + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} From f6d15c2a913bc08040bb9cebea4f8d6b5328114c Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 15:09:23 +0900 Subject: [PATCH 02/18] refactor: move azure worker controller to a directory module Pure git mv of worker/azure.rs to worker/azure/mod.rs with no content changes, so rename detection and git log --follow stay intact ahead of the module carve-out. --- crates/alien-infra/src/worker/{azure.rs => azure/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/alien-infra/src/worker/{azure.rs => azure/mod.rs} (100%) diff --git a/crates/alien-infra/src/worker/azure.rs b/crates/alien-infra/src/worker/azure/mod.rs similarity index 100% rename from crates/alien-infra/src/worker/azure.rs rename to crates/alien-infra/src/worker/azure/mod.rs From fa47682409e89df54d4eb238570daca3236e15d2 Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 15:15:58 +0900 Subject: [PATCH 03/18] refactor: split azure worker controller into modules Break the 6,500-line worker/azure.rs into a directory module so each concern can be read and reviewed in isolation: - azure/mod.rs: controller struct, environment-wake retry tracking, and the #[controller] handler impl (kept whole for macro state generation) - azure/support.rs: naming helpers, wait/delay helpers, error classifiers, heartbeat emission, and AzureStorageTriggerInfrastructure - azure/helpers.rs: the plain helper-method impl block - azure/tests.rs: the controller test module Pure code motion: moved items are bumped to pub(super) where needed; external paths are unchanged via the existing worker::azure re-exports. --- .../alien-infra/src/worker/azure/helpers.rs | 1582 ++++++++ crates/alien-infra/src/worker/azure/mod.rs | 3322 +---------------- .../alien-infra/src/worker/azure/support.rs | 359 ++ crates/alien-infra/src/worker/azure/tests.rs | 1379 +++++++ 4 files changed, 3328 insertions(+), 3314 deletions(-) create mode 100644 crates/alien-infra/src/worker/azure/helpers.rs create mode 100644 crates/alien-infra/src/worker/azure/support.rs create mode 100644 crates/alien-infra/src/worker/azure/tests.rs diff --git a/crates/alien-infra/src/worker/azure/helpers.rs b/crates/alien-infra/src/worker/azure/helpers.rs new file mode 100644 index 000000000..65bc5a012 --- /dev/null +++ b/crates/alien-infra/src/worker/azure/helpers.rs @@ -0,0 +1,1582 @@ +use super::*; + +impl AzureWorkerController { + // ─────────────── HELPER METHODS ──────────────────────────── + + /// Pre-create commands infrastructure (queue, Dapr component, role assignments) + /// before the Container App is created. This ensures the Dapr sidecar starts + /// with the component already defined and RBAC roles already propagating. + pub(super) async fn setup_commands_infrastructure( + &mut self, + ctx: &ResourceControllerContext<'_>, + azure_config: &alien_azure_clients::AzureClientConfig, + func_cfg: &alien_core::Worker, + container_app_name: &str, + ) -> Result { + let env_outputs = get_container_apps_environment_outputs(ctx.state)?; + let env_resource_group_name = env_outputs.resource_group_name.clone(); + let environment_name = env_outputs.environment_name.clone(); + + // Get the Service Bus namespace from the dependent resource + let namespace_ref = alien_core::ResourceRef::new( + alien_core::AzureServiceBusNamespace::RESOURCE_TYPE, + "default-service-bus-namespace", + ); + let namespace_controller = ctx.require_dependency::(&namespace_ref)?; + let namespace_name = namespace_controller + .namespace_name + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: func_cfg.id.clone(), + dependency_id: namespace_ref.id.clone(), + }) + })? + .clone(); + let service_bus_resource_group = namespace_controller.resource_group_name(ctx)?; + + // Create commands queue + let queue_name = format!("{}-rq", container_app_name); + let mgmt = ctx + .service_provider + .get_azure_service_bus_management_client(azure_config)?; + + info!( + worker=%func_cfg.id, + namespace=%namespace_name, + queue=%queue_name, + "Pre-creating commands Service Bus queue (before Container App)" + ); + + mgmt.create_or_update_queue( + service_bus_resource_group.clone(), + namespace_name.clone(), + queue_name.clone(), + alien_azure_clients::models::queue::SbQueueProperties::default(), + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create commands Service Bus queue '{}'", + queue_name + ), + resource_id: Some(func_cfg.id.clone()), + })?; + + // Create Dapr component for commands queue + use alien_azure_clients::models::managed_environments_dapr_components::{ + DaprComponent, DaprComponentProperties, DaprMetadata, + }; + + let ns_fqdn = format!("{}.servicebus.windows.net", namespace_name); + let component_name = format!("servicebus-{}-commands", container_app_name); + + let mut metadata = vec![ + DaprMetadata { + name: Some("namespaceName".into()), + value: Some(ns_fqdn), + secret_ref: None, + }, + DaprMetadata { + name: Some("queueName".into()), + value: Some(queue_name.clone()), + secret_ref: None, + }, + DaprMetadata { + name: Some("direction".into()), + value: Some("input".into()), + secret_ref: None, + }, + ]; + + // Add client ID for user-assigned managed identity + let service_account_id = format!("{}-sa", func_cfg.get_permissions()); + let service_account_ref = alien_core::ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + if let Ok(sa_state) = ctx + .require_dependency::( + &service_account_ref, + ) + { + if let Some(client_id) = &sa_state.identity_client_id { + metadata.push(DaprMetadata { + name: Some("azureClientId".into()), + value: Some(client_id.clone()), + secret_ref: None, + }); + } + } + + let dapr_component = DaprComponent { + name: Some(component_name.clone()), + properties: Some(DaprComponentProperties { + component_type: Some("bindings.azure.servicebusqueues".to_string()), + ignore_errors: false, + init_timeout: None, + version: Some("v1".to_string()), + metadata, + scopes: vec![container_app_name.to_string()], + secret_store_component: None, + secrets: vec![], + }), + id: None, + system_data: None, + type_: None, + }; + + info!( + worker=%func_cfg.id, + component=%component_name, + "Pre-creating commands Dapr Service Bus component (before Container App)" + ); + + let client = ctx + .service_provider + .get_azure_container_apps_client(azure_config)?; + + match client + .create_or_update_dapr_component( + &env_resource_group_name, + &environment_name, + &component_name, + &dapr_component, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create commands Dapr component '{}'", + component_name + ), + resource_id: Some(func_cfg.id.clone()), + })? { + OperationResult::Completed(_) => {} + OperationResult::LongRunning(lro) => { + self.pending_operation_url = Some(lro.url.clone()); + self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); + return Ok(DaprComponentOperation::LongRunning( + lro.retry_after.unwrap_or(Duration::from_secs(15)), + )); + } + } + + self.commands_namespace_name = Some(namespace_name.clone()); + self.commands_queue_name = Some(queue_name); + self.commands_dapr_component = Some(component_name); + + // Command transport RBAC is setup-applied before live worker provisioning. + self.assign_commands_sender_role( + ctx, + azure_config, + &service_bus_resource_group, + &namespace_name, + func_cfg, + ) + .await?; + + info!(worker=%func_cfg.id, "Commands infrastructure pre-created successfully"); + Ok(DaprComponentOperation::Completed) + } + + /// Ensure command transport permissions are represented in the management profile. + /// + /// Azure command transport uses Service Bus data-plane roles. Those grants are + /// setup-owned because both Terraform setup and runtime setup know the stack + /// management and execution identities before live workers are created. + pub(super) async fn assign_commands_sender_role( + &mut self, + ctx: &ResourceControllerContext<'_>, + azure_config: &alien_azure_clients::AzureClientConfig, + resource_group_name: &str, + namespace_name: &str, + func_cfg: &alien_core::Worker, + ) -> Result<()> { + if !management_profile_dispatches_commands(ctx, &func_cfg.id) { + info!( + worker = %func_cfg.id, + "Skipping management command sender role because worker/dispatch-command is not granted" + ); + return Ok(()); + } + + if AzurePermissionsHelper::get_management_uami_principal_id(ctx)?.is_none() { + if self.commands_sender_role_assignment_id.is_some() { + return Ok(()); + } + + let queue_name = self.commands_queue_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: func_cfg.id.clone(), + dependency_id: "commands-queue".to_string(), + }) + })?; + let authorization_client = ctx + .service_provider + .get_azure_authorization_client(azure_config)?; + let principal_id = ctx + .service_provider + .get_azure_caller_principal_id(azure_config) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to resolve Azure command sender principal".to_string(), + resource_id: Some(func_cfg.id.clone()), + })?; + let queue_scope = alien_azure_clients::authorization::Scope::Resource { + resource_group_name: resource_group_name.to_string(), + resource_provider: "Microsoft.ServiceBus".to_string(), + parent_resource_path: Some(format!("namespaces/{namespace_name}")), + resource_type: "queues".to_string(), + resource_name: queue_name.clone(), + }; + let role_assignment_id = uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_OID, + format!( + "deployment:azure:commands-sender:{}:{}:{}:{}:{}", + ctx.resource_prefix, func_cfg.id, principal_id, namespace_name, queue_name + ) + .as_bytes(), + ) + .to_string(); + let full_assignment_id = authorization_client + .build_role_assignment_id(&queue_scope, role_assignment_id.clone()); + let role_definition_id = format!( + "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/69a216fc-b8fb-44d8-bc22-1f3c2cd27a39", + azure_config.subscription_id + ); + + AzurePermissionsHelper::create_role_assignment( + &authorization_client, + azure_config, + &queue_scope, + &role_assignment_id, + &principal_id, + &role_definition_id, + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to grant Azure command sender role".to_string(), + resource_id: Some(func_cfg.id.clone()), + })?; + + self.commands_sender_role_assignment_id = Some(full_assignment_id); + info!( + worker = %func_cfg.id, + principal_id = %principal_id, + namespace = %namespace_name, + queue = %queue_name, + "Granted direct-manager Azure Service Bus command sender role" + ); + return Ok(()); + } + + info!( + worker = %func_cfg.id, + "Using setup-applied Azure Service Bus command permissions" + ); + + Ok(()) + } + + /// Resolve domain information for a public worker. + /// Returns either custom domain config or auto-generated domain from metadata. + pub(super) fn resolve_domain_info( + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + ) -> Result { + let stack_settings = &ctx.deployment_config.stack_settings; + + // Check for custom domain configuration + if let Some(custom) = stack_settings + .domains + .as_ref() + .and_then(|domains| domains.custom_domains.as_ref()) + .and_then(|domains| domains.get(resource_id)) + { + let keyvault_cert_id = custom + .certificate + .azure + .as_ref() + .map(|cert| cert.key_vault_certificate_id.clone()) + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Custom domain requires an Azure Key Vault certificate ID" + .to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + return Ok(DomainInfo { + fqdn: custom.domain.clone(), + certificate_id: None, + keyvault_cert_id: Some(keyvault_cert_id), + container_apps_certificate_id: None, + uses_custom_domain: true, + }); + } + + // Use auto-generated domain from domain metadata + let metadata = ctx + .deployment_config + .domain_metadata + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Domain metadata missing for public resource".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + let resource = metadata.resources.get(resource_id).ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Domain metadata missing for resource".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + Ok(DomainInfo { + fqdn: resource.fqdn.clone(), + certificate_id: Some(resource.certificate_id.clone()), + keyvault_cert_id: None, + container_apps_certificate_id: None, + uses_custom_domain: false, + }) + } + + pub(super) fn ensure_domain_info( + &mut self, + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + ) -> Result { + if self.fqdn.is_some() + && (self.certificate_id.is_some() + || self.keyvault_cert_id.is_some() + || self.uses_custom_domain) + { + return Ok(true); + } + + match Self::resolve_domain_info(ctx, resource_id) { + Ok(domain_info) => { + self.fqdn = Some(domain_info.fqdn.clone()); + self.certificate_id = domain_info.certificate_id; + self.keyvault_cert_id = domain_info.keyvault_cert_id; + self.container_apps_certificate_id = domain_info.container_apps_certificate_id; + self.uses_custom_domain = domain_info.uses_custom_domain; + if self.url.is_none() { + self.url = ctx + .deployment_config + .public_endpoints + .as_ref() + .and_then(|resources| resources.get(resource_id)) + .and_then(|endpoints| endpoints.values().next().cloned()) + .or_else(|| Some(format!("https://{}", domain_info.fqdn))); + } + Ok(true) + } + Err(_) => Ok(false), + } + } + + pub(super) fn clear_all(&mut self) { + self.container_app_name = None; + self.resource_id = None; + self.url = None; + self.container_app_url = None; + self.pending_operation_url = None; + self.pending_operation_retry_after = None; + self.dapr_components.clear(); + self.storage_trigger_infrastructure.clear(); + } + + /// Called whenever provisioning *succeeds* and we have the live resource. + pub(super) fn handle_creation_completed( + &mut self, + ctx: &ResourceControllerContext<'_>, + app: &ContainerApp, + ) { + self.resource_id = app.id.clone(); + + let container_app_url = self.extract_url_from_container_app(app); + + // Capture the ingress host (DNS CNAME target) before `url` is overridden below. + self.container_app_url = container_app_url.clone(); + + // Check for URL override in deployment config, otherwise use Container App URL + if let Ok(config) = ctx.desired_resource_config::() { + self.url = ctx + .deployment_config + .public_endpoints + .as_ref() + .and_then(|resources| resources.get(&config.id)) + .and_then(|endpoints| endpoints.values().next().cloned()) + .or(container_app_url); + } else { + self.url = container_app_url; + } + + self.pending_operation_url = None; + self.pending_operation_retry_after = None; + } + + pub(super) fn set_custom_domain(app: &mut ContainerApp, fqdn: String, certificate_id: String) { + if let Some(props) = &mut app.properties { + if let Some(config) = &mut props.configuration { + if let Some(ingress) = &mut config.ingress { + ingress.custom_domains = vec![CustomDomain { + name: fqdn, + binding_type: Some(CustomDomainBindingType::SniEnabled), + certificate_id: Some(certificate_id), + }]; + } + } + } + } + + pub(super) fn extract_url_from_container_app(&self, app: &ContainerApp) -> Option { + let fqdn = app + .properties + .as_ref()? + .configuration + .as_ref()? + .ingress + .as_ref()? + .fqdn + .clone()?; + + if fqdn.starts_with("http://") || fqdn.starts_with("https://") { + Some(fqdn) + } else { + Some(format!("https://{}", fqdn)) + } + } + + /// Prepare environment variables using the shared logic, then convert to Azure's EnvironmentVar format + pub(super) async fn prepare_environment_variables_azure( + &self, + func: &Worker, + ctx: &ResourceControllerContext<'_>, + ) -> Result> { + // Get the worker's own binding params (may be None during initial creation) + let self_binding_params = self.get_binding_params()?; + + // Build complete environment using shared logic + // IMPORTANT: Start with func.environment which includes injected vars from DeploymentConfig + let complete_env = EnvironmentVariableBuilder::try_new(&func.environment)? + .add_worker_runtime_env_vars(ctx, &func.id, func.timeout_seconds)? + .add_linked_resources(&func.links, ctx, &func.id) + .await? + .add_self_worker_binding(&func.id, self_binding_params.as_ref())? + .build(); + + // Add managed identity environment variable from ServiceAccount + let service_account_id = format!("{}-sa", func.get_permissions()); + let service_account_ref = ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + + let mut env_vars = Vec::new(); + + // Convert all environment variables to Azure format + for (name, value) in complete_env { + env_vars.push(EnvironmentVar { + name: Some(name), + value: Some(value), + secret_ref: None, + }); + } + + // Add Azure-specific managed identity client ID + if let Ok(service_account_state) = ctx + .require_dependency::( + &service_account_ref, + ) + { + if let Some(client_id) = &service_account_state.identity_client_id { + env_vars.push(EnvironmentVar { + name: Some(ENV_AZURE_CLIENT_ID.to_string()), + value: Some(client_id.clone()), + secret_ref: None, + }); + } + } + + Ok(env_vars) + } + + /// Build the full ContainerApps ARM spec for *desired* state. + pub(super) async fn build_container_app( + &self, + func: &Worker, + _environment_name: &str, + container_app_name: &str, + azure_cfg: &AzureClientConfig, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let location = azure_cfg.region.as_deref().unwrap_or("East US"); + + let image = match &func.code { + alien_core::WorkerCode::Image { image } => image.clone(), + alien_core::WorkerCode::Source { .. } => { + return Err(AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!( + "Worker '{}' uses source code, but only pre‑built images are supported on Azure", + func.id + ), + resource_id: Some(func.id.clone()), + })); + } + }; + + // Prepare environment variables using shared logic + let env_vars = self.prepare_environment_variables_azure(func, ctx).await?; + + // Note: Dapr input bindings (bindings.azure.servicebusqueues) auto-deliver + // messages without requiring GET /dapr/subscribe. No subscription env var needed. + + // Azure Container Apps requires specific CPU/memory combinations. + // The ratio is 0.5 Gi per 0.25 CPU (2 Gi per 1 CPU). + let memory_gi = func.memory_mb as f64 / 1024.0; + // Azure Container Apps requires specific CPU/memory pairs where CPU = memory_gi / 2. + // The WorkerMemoryCheck preflight validates that memory_mb is a valid Azure value + // (512, 1024, 1536, 2048, 2560, 3072, 3584, 4096). + let cpu = memory_gi / 2.0; + + let container = Container { + name: Some("main".to_string()), + image: Some(image.clone()), + resources: Some(ContainerResources { + cpu: Some(cpu), + memory: Some(format!("{}Gi", memory_gi)), + ephemeral_storage: None, + }), + env: env_vars, + args: vec![], + command: vec![], + probes: vec![], + volume_mounts: vec![], + }; + + // Tags for traceability + let mut tags = HashMap::new(); + tags.insert("resource-type".to_string(), "worker".to_string()); + tags.insert("resource".to_string(), func.id.clone()); + tags.insert("deployment".to_string(), ctx.resource_prefix.to_string()); + + let _resource_group_name = get_resource_group_name(ctx.state)?; + let environment_id = azure_utils::get_container_apps_environment_resource_id(ctx.state)?; + + let ingress_cfg = if !func.public_endpoints.is_empty() { + Some(alien_azure_clients::models::container_apps::Ingress { + external: true, + target_port: Some(8080), + traffic: vec![TrafficWeight { + weight: Some(100), + latest_revision: true, + revision_name: None, + label: None, + }], + transport: IngressTransport::Auto, + allow_insecure: false, + additional_port_mappings: vec![], + custom_domains: vec![], + ip_security_restrictions: vec![], + cors_policy: None, + client_certificate_mode: None, + exposed_port: None, + sticky_sessions: None, + fqdn: None, + }) + } else { + None + }; + + let mut registries = vec![]; + let mut secrets = vec![]; + + // Managed identity support from ServiceAccounts + // Collect all ServiceAccounts: + // 1. Permission-based ServiceAccount (from permission profile) + // 2. Linked ServiceAccounts (from worker.links) + use alien_azure_clients::models::container_apps::{ + ManagedServiceIdentity, ManagedServiceIdentityType, UserAssignedIdentities, + UserAssignedIdentity, + }; + + let mut identity_map = HashMap::new(); + + // Add permission-based ServiceAccount + let service_account_id = format!("{}-sa", func.get_permissions()); + let service_account_ref = ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + + if let Ok(service_account_state) = ctx + .require_dependency::( + &service_account_ref, + ) + { + if let Some(identity_id) = &service_account_state.identity_resource_id { + identity_map.insert( + identity_id.clone(), + UserAssignedIdentity { + client_id: None, + principal_id: None, + }, + ); + } + } + + // Add linked ServiceAccounts + for link in &func.links { + if link.resource_type() == &alien_core::ServiceAccount::RESOURCE_TYPE { + if let Ok(linked_sa_state) = ctx + .require_dependency::( + link, + ) { + if let Some(identity_id) = &linked_sa_state.identity_resource_id { + identity_map.insert( + identity_id.clone(), + UserAssignedIdentity { + client_id: None, + principal_id: None, + }, + ); + } + } + } + } + + // Configure registry credentials for image pull. + // The image URI points at the manager's registry (proxy URI from release). + // Add Basic auth with the deployment token so the Container App can pull. + let registry_token = ctx.deployment_config.deployment_token.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "deployment_token is required for Azure Container Apps to pull images from the registry proxy".to_string(), + resource_id: Some(func.id.clone()), + }) + })?; + let registry_server = image.split('/').next().unwrap_or_default().to_string(); + let secret_name = "registry-proxy-password"; + secrets.push(Secret { + name: Some(secret_name.to_string()), + value: Some(registry_token.clone()), + identity: None, + key_vault_url: None, + }); + registries.push(RegistryCredentials { + identity: None, + password_secret_ref: Some(secret_name.to_string()), + server: Some(registry_server), + username: Some("deployment".to_string()), + }); + + // Create managed identity spec if we have any identities + let identity_resource_ids: Vec = identity_map.keys().cloned().collect(); + + let managed_identity = if !identity_map.is_empty() { + Some(ManagedServiceIdentity { + principal_id: None, + tenant_id: None, + type_: ManagedServiceIdentityType::UserAssigned, + user_assigned_identities: Some(UserAssignedIdentities(identity_map)), + }) + } else { + None + }; + + // Configure Dapr if the worker uses any triggers or commands. + // Dapr handles delivery for queue (Service Bus), storage (blob), and cron triggers. + let needs_dapr = func.commands_enabled || !func.triggers.is_empty(); + let dapr_config = if needs_dapr { + use alien_azure_clients::models::container_apps::{Dapr, DaprAppProtocol}; + + Some(Dapr { + app_id: Some(container_app_name.to_string()), + app_port: Some(8080), // Port that alien-worker-runtime listens on + app_protocol: DaprAppProtocol::Http, + enable_api_logging: Some(false), + enabled: true, + http_max_request_size: None, + http_read_buffer_size: None, + log_level: None, + }) + } else { + None + }; + + let configuration = Configuration { + active_revisions_mode: ConfigurationActiveRevisionsMode::Single, + dapr: dapr_config, + identity_settings: identity_resource_ids + .iter() + .map(|identity_id| IdentitySettings { + identity: identity_id.clone(), + lifecycle: IdentitySettingsLifecycle::All, + }) + .collect(), + ingress: ingress_cfg, + max_inactive_revisions: None, + registries, + runtime: None, + secrets, + service: None, + }; + + let template = Template { + containers: vec![container], + init_containers: vec![], + revision_suffix: None, + scale: Some(Scale { + cooldown_period: None, + max_replicas: func.concurrency_limit.map(|c| c as i32).unwrap_or(10), + min_replicas: Some(if func.public_endpoints.is_empty() { + 0 + } else { + 1 + }), + polling_interval: None, + rules: vec![], + }), + service_binds: vec![], + termination_grace_period_seconds: None, + volumes: vec![], + }; + + Ok(ContainerApp { + extended_location: None, + id: None, + identity: managed_identity, + location: location.to_string(), + managed_by: None, + name: None, + properties: Some(ContainerAppProperties { + configuration: Some(configuration), + custom_domain_verification_id: None, + environment_id: None, + event_stream_endpoint: None, + latest_ready_revision_name: None, + latest_revision_fqdn: None, + latest_revision_name: None, + managed_environment_id: Some(environment_id), + outbound_ip_addresses: vec![], + provisioning_state: None, + running_status: None, + template: Some(template), + workload_profile_name: None, + }), + system_data: None, + tags, + type_: None, + }) + } + + /// Creates a Dapr Service Bus component for a queue trigger + pub(super) async fn create_dapr_service_bus_component( + &mut self, + ctx: &ResourceControllerContext<'_>, + container_app_name: &str, + worker_config: &alien_core::Worker, + queue_ref: &alien_core::ResourceRef, + ) -> Result { + use alien_azure_clients::models::managed_environments_dapr_components::{ + DaprComponent, DaprComponentProperties, DaprMetadata, + }; + + let azure_config = ctx.get_azure_config()?; + // Dapr components live on the Container Apps Environment, which may be in a + // different resource group than the deployment (shared/external environments). + let env_outputs = get_container_apps_environment_outputs(ctx.state)?; + let resource_group_name = env_outputs.resource_group_name.clone(); + let environment_name = env_outputs.environment_name.clone(); + + // Get queue controller to access Service Bus namespace + let queue_controller = + ctx.require_dependency::(queue_ref)?; + let namespace = queue_controller.namespace_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: queue_ref.id.clone(), + }) + })?; + let ns_fqdn = format!("{}.servicebus.windows.net", namespace); + + // Generate component name: servicebus-{containerAppName}-{queueId} + let component_name = format!("servicebus-{}-{}", container_app_name, queue_ref.id); + + // Use Dapr input binding — the manager/user code sends directly to Service Bus + // via Azure SDK, not through Dapr pubsub. Input bindings auto-deliver from the + // named queue without requiring GET /dapr/subscribe subscriptions. + let queue_name = queue_controller.queue_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: queue_ref.id.clone(), + }) + })?; + + let dapr_component = DaprComponent { + name: Some(component_name.clone()), + properties: Some(DaprComponentProperties { + component_type: Some("bindings.azure.servicebusqueues".to_string()), + ignore_errors: false, + init_timeout: None, + version: Some("v1".to_string()), + metadata: { + let mut metadata = vec![ + DaprMetadata { + name: Some("namespaceName".into()), + value: Some(ns_fqdn), + secret_ref: None, + }, + DaprMetadata { + name: Some("queueName".into()), + value: Some(queue_name.clone()), + secret_ref: None, + }, + DaprMetadata { + name: Some("direction".into()), + value: Some("input".into()), + secret_ref: None, + }, + ]; + + // Add client ID for user-assigned managed identity + let service_account_id = format!("{}-sa", worker_config.get_permissions()); + let service_account_ref = alien_core::ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.to_string(), + ); + + if let Ok(service_account_state) = ctx.require_dependency::(&service_account_ref) { + if let Some(client_id) = &service_account_state.identity_client_id { + metadata.push(DaprMetadata { + name: Some("azureClientId".into()), + value: Some(client_id.clone()), + secret_ref: None + }); + } + } + + metadata + }, + scopes: vec![container_app_name.to_string()], + secret_store_component: None, + secrets: vec![], + }), + id: None, + system_data: None, + type_: None, + }; + + info!( + worker=%worker_config.id, + queue=%queue_ref.id, + component=%component_name, + environment=%environment_name, + "Creating Dapr Service Bus component" + ); + + let client = ctx + .service_provider + .get_azure_container_apps_client(&azure_config)?; + + match client + .create_or_update_dapr_component( + &resource_group_name, + &environment_name, + &component_name, + &dapr_component, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create Dapr component '{}' for queue '{}'", + component_name, queue_ref.id + ), + resource_id: Some(worker_config.id.clone()), + })? { + OperationResult::Completed(_) => {} + OperationResult::LongRunning(lro) => { + self.pending_operation_url = Some(lro.url.clone()); + self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); + return Ok(DaprComponentOperation::LongRunning( + lro.retry_after.unwrap_or(Duration::from_secs(15)), + )); + } + } + + if !self.dapr_components.contains(&component_name) { + self.dapr_components.push(component_name.clone()); + } + + info!( + worker=%worker_config.id, + component=%component_name, + "Successfully created Dapr Service Bus component" + ); + + Ok(DaprComponentOperation::Completed) + } + + /// Creates supported Azure storage-trigger delivery: + /// Event Grid -> dedicated Service Bus queue -> Dapr Service Bus input binding. + pub(super) async fn create_azure_storage_trigger( + &mut self, + ctx: &ResourceControllerContext<'_>, + container_app_name: &str, + worker_config: &alien_core::Worker, + storage_ref: &alien_core::ResourceRef, + events: &[String], + ) -> Result { + use alien_azure_clients::authorization::Scope; + use alien_azure_clients::models::managed_environments_dapr_components::{ + DaprComponent, DaprComponentProperties, DaprMetadata, + }; + + let azure_config = ctx.get_azure_config()?; + let env_outputs = get_container_apps_environment_outputs(ctx.state)?; + let environment_resource_group = env_outputs.resource_group_name.clone(); + let environment_name = env_outputs.environment_name.clone(); + + let storage_controller = + ctx.require_dependency::(storage_ref)?; + let storage_account_name = storage_controller + .storage_account_name + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: storage_ref.id.clone(), + }) + })? + .clone(); + let container_name = storage_controller + .container_name + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: storage_ref.id.clone(), + }) + })? + .clone(); + + let namespace_ref = ResourceRef::new( + alien_core::AzureServiceBusNamespace::RESOURCE_TYPE, + "default-service-bus-namespace", + ); + let namespace_controller = ctx.require_dependency::(&namespace_ref)?; + let namespace_name = namespace_controller + .namespace_name + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: namespace_ref.id.clone(), + }) + })? + .clone(); + let service_bus_resource_group = namespace_controller.resource_group_name(ctx)?; + + let service_account_id = format!("{}-sa", worker_config.get_permissions()); + let service_account_ref = ResourceRef::new( + alien_core::ServiceAccount::RESOURCE_TYPE, + service_account_id.clone(), + ); + let service_account = ctx + .require_dependency::( + &service_account_ref, + )?; + let execution_client_id = service_account + .identity_client_id + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: service_account_id.clone(), + }) + })? + .clone(); + let execution_principal_id = service_account + .identity_principal_id + .as_ref() + .ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: service_account_id, + }) + })? + .clone(); + + let queue_name = format!("{}-storage-{}", container_app_name, storage_ref.id); + let component_name = format!("blobstorage-{}-{}", container_app_name, storage_ref.id); + let event_subscription_name = + get_azure_storage_event_subscription_name(&worker_config.id, &storage_ref.id); + let deployment_resource_group = get_resource_group_name(ctx.state)?; + let source_resource_id = format!( + "/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts/{}", + azure_config.subscription_id, deployment_resource_group, storage_account_name + ); + let queue_resource_id = format!( + "/subscriptions/{}/resourceGroups/{}/providers/Microsoft.ServiceBus/namespaces/{}/queues/{}", + azure_config.subscription_id, + service_bus_resource_group, + namespace_name, + queue_name + ); + + let mgmt = ctx + .service_provider + .get_azure_service_bus_management_client(azure_config)?; + mgmt.create_or_update_queue( + service_bus_resource_group.clone(), + namespace_name.clone(), + queue_name.clone(), + alien_azure_clients::models::queue::SbQueueProperties::default(), + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create storage-trigger Service Bus queue '{}'", + queue_name + ), + resource_id: Some(worker_config.id.clone()), + })?; + + let tracker_index = self + .storage_trigger_infrastructure + .iter() + .position(|item| item.event_subscription_name == event_subscription_name); + let tracker_index = match tracker_index { + Some(index) => { + let tracker = &mut self.storage_trigger_infrastructure[index]; + tracker.source_resource_id = source_resource_id.clone(); + tracker.service_bus_resource_group = service_bus_resource_group.clone(); + tracker.namespace_name = namespace_name.clone(); + tracker.queue_name = queue_name.clone(); + index + } + None => { + self.storage_trigger_infrastructure + .push(AzureStorageTriggerInfrastructure { + source_resource_id: source_resource_id.clone(), + event_subscription_name: event_subscription_name.clone(), + service_bus_resource_group: service_bus_resource_group.clone(), + namespace_name: namespace_name.clone(), + queue_name: queue_name.clone(), + receiver_role_assignment_id: None, + }); + self.storage_trigger_infrastructure.len() - 1 + } + }; + + if self.storage_trigger_infrastructure[tracker_index] + .receiver_role_assignment_id + .is_none() + { + let queue_scope = Scope::Resource { + resource_group_name: service_bus_resource_group.clone(), + resource_provider: "Microsoft.ServiceBus".to_string(), + parent_resource_path: Some(format!("namespaces/{namespace_name}")), + resource_type: "queues".to_string(), + resource_name: queue_name.clone(), + }; + let role_assignment_id = uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_OID, + format!( + "deployment:azure:storage-trigger-receiver:{}:{}:{}:{}", + ctx.resource_prefix, worker_config.id, storage_ref.id, execution_principal_id + ) + .as_bytes(), + ) + .to_string(); + let authorization_client = ctx + .service_provider + .get_azure_authorization_client(azure_config)?; + let full_assignment_id = authorization_client + .build_role_assignment_id(&queue_scope, role_assignment_id.clone()); + let role_definition_id = format!( + "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0", + azure_config.subscription_id + ); + AzurePermissionsHelper::create_role_assignment( + &authorization_client, + azure_config, + &queue_scope, + &role_assignment_id, + &execution_principal_id, + &role_definition_id, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to grant the worker access to storage-trigger queue '{}'", + queue_name + ), + resource_id: Some(worker_config.id.clone()), + })?; + self.storage_trigger_infrastructure[tracker_index].receiver_role_assignment_id = + Some(full_assignment_id); + } + + let metadata = vec![ + DaprMetadata { + name: Some("namespaceName".into()), + value: Some(format!("{}.servicebus.windows.net", namespace_name)), + secret_ref: None, + }, + DaprMetadata { + name: Some("queueName".into()), + value: Some(queue_name.clone()), + secret_ref: None, + }, + DaprMetadata { + name: Some("direction".into()), + value: Some("input".into()), + secret_ref: None, + }, + DaprMetadata { + name: Some("azureClientId".into()), + value: Some(execution_client_id), + secret_ref: None, + }, + ]; + + let dapr_component = DaprComponent { + name: Some(component_name.clone()), + properties: Some(DaprComponentProperties { + component_type: Some("bindings.azure.servicebusqueues".to_string()), + ignore_errors: false, + init_timeout: None, + version: Some("v1".to_string()), + metadata, + scopes: vec![container_app_name.to_string()], + secret_store_component: None, + secrets: vec![], + }), + id: None, + system_data: None, + type_: None, + }; + + let container_apps_client = ctx + .service_provider + .get_azure_container_apps_client(&azure_config)?; + + match container_apps_client + .create_or_update_dapr_component( + &environment_resource_group, + &environment_name, + &component_name, + &dapr_component, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create Dapr component '{}' for storage trigger '{}'", + component_name, storage_ref.id + ), + resource_id: Some(worker_config.id.clone()), + })? { + OperationResult::Completed(_) => {} + OperationResult::LongRunning(lro) => { + self.pending_operation_url = Some(lro.url.clone()); + self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); + return Ok(DaprComponentOperation::LongRunning( + lro.retry_after.unwrap_or(Duration::from_secs(15)), + )); + } + } + + if !self.dapr_components.contains(&component_name) { + self.dapr_components.push(component_name.clone()); + } + + let event_grid_client = ctx + .service_provider + .get_azure_event_grid_client(azure_config)?; + let event_subscription = match event_grid_client + .get_event_subscription(source_resource_id.clone(), event_subscription_name.clone()) + .await + { + Ok(event_subscription) => event_subscription, + Err(error) + if matches!( + error.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + let included_event_types = azure_storage_event_types(events, &worker_config.id)?; + event_grid_client + .create_or_update_event_subscription( + source_resource_id, + event_subscription_name.clone(), + EventSubscriptionRequest { + properties: EventSubscriptionRequestProperties { + destination: ServiceBusQueueDestination { + endpoint_type: "ServiceBusQueue".to_string(), + properties: ServiceBusQueueDestinationProperties { + resource_id: queue_resource_id, + }, + }, + filter: EventSubscriptionFilter { + included_event_types, + subject_begins_with: format!( + "/blobServices/default/containers/{}/blobs/", + container_name + ), + is_subject_case_sensitive: false, + }, + event_delivery_schema: "CloudEventSchemaV1_0".to_string(), + }, + }, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create Event Grid subscription '{}' for storage '{}'", + event_subscription_name, storage_ref.id + ), + resource_id: Some(worker_config.id.clone()), + })? + } + Err(error) => { + return Err(error.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to inspect Event Grid subscription '{}' for storage '{}'", + event_subscription_name, storage_ref.id + ), + resource_id: Some(worker_config.id.clone()), + })); + } + }; + + if let Some(provisioning_state) = event_subscription + .properties + .and_then(|properties| properties.provisioning_state) + { + if !provisioning_state.eq_ignore_ascii_case("Succeeded") { + info!( + worker=%worker_config.id, + subscription=%event_subscription_name, + state=%provisioning_state, + "Waiting for Event Grid storage subscription" + ); + return Ok(DaprComponentOperation::Pending(Duration::from_secs(5))); + } + } + + info!( + worker=%worker_config.id, + component=%component_name, + subscription=%event_subscription_name, + "Azure storage trigger delivery is ready" + ); + + Ok(DaprComponentOperation::Completed) + } + + /// Creates a Dapr cron input binding for a schedule trigger + pub(super) async fn create_dapr_cron_component( + &mut self, + ctx: &ResourceControllerContext<'_>, + container_app_name: &str, + worker_config: &alien_core::Worker, + cron: &str, + index: usize, + ) -> Result { + use alien_azure_clients::models::managed_environments_dapr_components::{ + DaprComponent, DaprComponentProperties, DaprMetadata, + }; + + let azure_config = ctx.get_azure_config()?; + let env_outputs = get_container_apps_environment_outputs(ctx.state)?; + let resource_group_name = env_outputs.resource_group_name.clone(); + let environment_name = env_outputs.environment_name.clone(); + + let component_name = format!("cron-{}-{}", container_app_name, index); + + let dapr_component = DaprComponent { + name: Some(component_name.clone()), + properties: Some(DaprComponentProperties { + component_type: Some("bindings.cron".to_string()), + ignore_errors: false, + init_timeout: None, + version: Some("v1".to_string()), + metadata: vec![ + DaprMetadata { + name: Some("schedule".into()), + value: Some(cron.to_string()), + secret_ref: None, + }, + DaprMetadata { + name: Some("direction".into()), + value: Some("input".into()), + secret_ref: None, + }, + ], + scopes: vec![container_app_name.to_string()], + secret_store_component: None, + secrets: vec![], + }), + id: None, + system_data: None, + type_: None, + }; + + let client = ctx + .service_provider + .get_azure_container_apps_client(&azure_config)?; + + match client + .create_or_update_dapr_component( + &resource_group_name, + &environment_name, + &component_name, + &dapr_component, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create Dapr cron component '{}' with schedule '{}'", + component_name, cron + ), + resource_id: Some(worker_config.id.clone()), + })? { + OperationResult::Completed(_) => {} + OperationResult::LongRunning(lro) => { + self.pending_operation_url = Some(lro.url.clone()); + self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); + return Ok(DaprComponentOperation::LongRunning( + lro.retry_after.unwrap_or(Duration::from_secs(15)), + )); + } + } + + if !self.dapr_components.contains(&component_name) { + self.dapr_components.push(component_name.clone()); + } + + info!( + worker=%worker_config.id, + component=%component_name, + schedule=%cron, + "Successfully created Dapr cron component" + ); + + Ok(DaprComponentOperation::Completed) + } + + pub(super) async fn delete_storage_trigger_infrastructure( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result<()> { + if self.storage_trigger_infrastructure.is_empty() { + return Ok(()); + } + + let azure_config = ctx.get_azure_config()?; + let event_grid_client = ctx + .service_provider + .get_azure_event_grid_client(azure_config)?; + let authorization_client = ctx + .service_provider + .get_azure_authorization_client(azure_config)?; + let service_bus_client = ctx + .service_provider + .get_azure_service_bus_management_client(azure_config)?; + + for infrastructure in self.storage_trigger_infrastructure.clone() { + match event_grid_client + .delete_event_subscription( + infrastructure.source_resource_id.clone(), + infrastructure.event_subscription_name.clone(), + ) + .await + { + Ok(()) => info!( + subscription=%infrastructure.event_subscription_name, + "Deleted Event Grid storage subscription" + ), + Err(error) => { + return Err(error.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to delete Event Grid storage subscription '{}'", + infrastructure.event_subscription_name + ), + resource_id: Some(ctx.desired_config.id().to_string()), + })); + } + } + + if let Some(assignment_id) = infrastructure.receiver_role_assignment_id { + match authorization_client + .delete_role_assignment_by_id(assignment_id.clone()) + .await + { + Ok(_) => info!( + assignment_id=%assignment_id, + "Deleted storage-trigger receiver role assignment" + ), + Err(error) + if matches!( + error.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!( + assignment_id=%assignment_id, + "Storage-trigger receiver role assignment was already deleted" + ); + } + Err(error) => { + return Err(error.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to delete storage-trigger receiver role assignment '{}'", + assignment_id + ), + resource_id: Some(ctx.desired_config.id().to_string()), + })); + } + } + } + + match service_bus_client + .delete_queue( + infrastructure.service_bus_resource_group, + infrastructure.namespace_name, + infrastructure.queue_name.clone(), + ) + .await + { + Ok(()) => info!( + queue=%infrastructure.queue_name, + "Deleted storage-trigger Service Bus queue" + ), + Err(error) + if matches!( + error.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!( + queue=%infrastructure.queue_name, + "Storage-trigger Service Bus queue was already deleted" + ); + } + Err(error) => { + return Err(error.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to delete storage-trigger Service Bus queue '{}'", + infrastructure.queue_name + ), + resource_id: Some(ctx.desired_config.id().to_string()), + })); + } + } + } + + self.storage_trigger_infrastructure.clear(); + Ok(()) + } + + /// Deletes all Dapr components using best-effort approach + pub(super) async fn delete_all_dapr_components( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result<()> { + if self.dapr_components.is_empty() { + return Ok(()); + } + + let azure_config = ctx.get_azure_config()?; + // Dapr components live on the Container Apps Environment, which may be in a + // different resource group than the deployment (shared/external environments). + let env_outputs = get_container_apps_environment_outputs(ctx.state)?; + let resource_group_name = env_outputs.resource_group_name.clone(); + let environment_name = env_outputs.environment_name.clone(); + let worker_config = ctx.desired_resource_config::()?; + + let client = ctx + .service_provider + .get_azure_container_apps_client(&azure_config)?; + + for component_name in &self.dapr_components.clone() { + match client + .delete_dapr_component(&resource_group_name, &environment_name, component_name) + .await + { + Ok(_) => { + info!( + worker=%worker_config.id, + component=%component_name, + "Dapr component delete requested" + ); + } + Err(e) + if matches!( + e.error, + Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!( + worker=%worker_config.id, + component=%component_name, + "Dapr component was already deleted or doesn't exist" + ); + } + Err(e) => { + warn!( + worker=%worker_config.id, + component=%component_name, + error=%e, + "Failed to delete Dapr component during deletion" + ); + } + } + } + + self.dapr_components.clear(); + Ok(()) + } + + /// Creates a controller in a ready state with mock values for testing purposes. + #[cfg(feature = "test-utils")] + pub fn mock_ready(function_name: &str) -> Self { + Self { + state: AzureWorkerState::Ready, + container_app_name: Some(function_name.to_string()), + resource_id: Some(format!( + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", + function_name + )), + url: Some(format!("https://{}.azurecontainerapps.io", function_name)), + container_app_url: None, + pending_operation_url: None, + pending_operation_retry_after: None, + dapr_components: Vec::new(), + storage_trigger_infrastructure: Vec::new(), + fqdn: None, + certificate_id: None, + keyvault_cert_id: None, + container_apps_certificate_id: None, + uses_custom_domain: false, + certificate_issued_at: None, + commands_namespace_name: None, + commands_queue_name: None, + commands_dapr_component: None, + commands_sender_role_assignment_id: None, + commands_receiver_role_assignment_id: None, + commands_infrastructure_auth_wait_until_epoch_secs: None, + container_apps_environment_wake_wait_until_epoch_secs: None, + container_apps_environment_wake_retry_after_epoch_secs: None, + pre_container_app_rbac_wait_until_epoch_secs: None, + ready_rbac_wait_until_epoch_secs: None, + update_rbac_wait_required: false, + update_dapr_components_deleted: false, + _internal_stay_count: None, + } + } +} diff --git a/crates/alien-infra/src/worker/azure/mod.rs b/crates/alien-infra/src/worker/azure/mod.rs index 626a63f90..a5e07f735 100644 --- a/crates/alien-infra/src/worker/azure/mod.rs +++ b/crates/alien-infra/src/worker/azure/mod.rs @@ -17,17 +17,13 @@ use alien_azure_clients::models::container_apps::{ use alien_azure_clients::AzureClientConfig; use alien_client_core::ErrorData as CloudClientErrorData; use alien_core::{ - AzureContainerAppsWorkerHeartbeatData, CertificateStatus, DnsRecordStatus, HeartbeatBackend, - ObservedHealth, Platform, ProviderLifecycleState, RemoteStackManagement, - RemoteStackManagementOutputs, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs, - ResourceRef, ResourceStatus, Worker, WorkerHeartbeatData, WorkerOutputs, - WorkloadHeartbeatStatus, ENV_AZURE_CLIENT_ID, + CertificateStatus, DnsRecordStatus, RemoteStackManagement, RemoteStackManagementOutputs, + ResourceOutputs, ResourceRef, ResourceStatus, Worker, WorkerOutputs, ENV_AZURE_CLIENT_ID, }; use alien_error::{AlienError, Context, ContextError, IntoAlienError}; use base64::Engine; -use chrono::Utc; use std::collections::HashMap; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use tracing::{debug, error, info, warn}; use crate::core::EnvironmentVariableBuilder; @@ -41,344 +37,14 @@ use crate::infra_requirements::azure_utils::{ use crate::worker::readiness_probe::{run_readiness_probe, READINESS_PROBE_MAX_ATTEMPTS}; use alien_macros::controller; -/// Generates a deterministic Azure Container Apps name for a worker. -fn get_azure_container_app_name(prefix: &str, name: &str) -> String { - format!("{}-{}", prefix, name) -} - -fn get_azure_storage_event_subscription_name(worker_id: &str, storage_id: &str) -> String { - let mut stem: String = format!("alien{worker_id}{storage_id}") - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .collect(); - stem.truncate(31); - let suffix = uuid::Uuid::new_v5( - &uuid::Uuid::NAMESPACE_OID, - format!("azure-storage-trigger:{worker_id}:{storage_id}").as_bytes(), - ) - .simple() - .to_string(); - format!("{stem}{suffix}") -} - -fn azure_storage_event_types(events: &[String], worker_id: &str) -> Result> { - events - .iter() - .map(|event| { - let event_type = match event.as_str() { - "created" => "Microsoft.Storage.BlobCreated", - "deleted" => "Microsoft.Storage.BlobDeleted", - "tierChanged" => "Microsoft.Storage.BlobTierChanged", - _ => { - return Err(AlienError::new(ErrorData::ResourceConfigInvalid { - message: format!( - "Azure storage trigger event '{}' is not supported; expected one of: created, deleted, tierChanged", - event - ), - resource_id: Some(worker_id.to_string()), - })); - } - }; - Ok(event_type.to_string()) - }) - .collect() -} - -#[cfg(not(test))] -const AZURE_PRE_CONTAINER_APP_RBAC_WAIT_SECS: u64 = 60; -#[cfg(test)] -const AZURE_PRE_CONTAINER_APP_RBAC_WAIT_SECS: u64 = 0; - -#[cfg(not(test))] -const AZURE_READY_RBAC_WAIT_SECS: u64 = 120; -#[cfg(test)] -const AZURE_READY_RBAC_WAIT_SECS: u64 = 0; - -#[cfg(not(test))] -const AZURE_COMMANDS_INFRASTRUCTURE_AUTH_WAIT_SECS: u64 = 900; -#[cfg(test)] -const AZURE_COMMANDS_INFRASTRUCTURE_AUTH_WAIT_SECS: u64 = 0; - -#[cfg(not(test))] -const AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_WAIT_SECS: u64 = 600; +mod helpers; +mod support; #[cfg(test)] -const AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_WAIT_SECS: u64 = 0; - -const AZURE_RBAC_WAIT_POLL_SECS: u64 = 10; -const AZURE_RBAC_WAIT_MAX_ATTEMPTS: u32 = 1_000; -const AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_POLL_SECS: u64 = 30; - -fn current_unix_timestamp_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -fn ensure_rbac_wait_deadline(wait_until_epoch_secs: &mut Option, wait_secs: u64) -> u64 { - let now = current_unix_timestamp_secs(); - *wait_until_epoch_secs.get_or_insert_with(|| now.saturating_add(wait_secs)) -} - -fn rbac_wait_delay(deadline_epoch_secs: u64) -> Option { - let now = current_unix_timestamp_secs(); - let remaining = deadline_epoch_secs.saturating_sub(now); - - if remaining == 0 { - None - } else { - Some(Duration::from_secs( - remaining.min(AZURE_RBAC_WAIT_POLL_SECS), - )) - } -} - -fn container_apps_environment_wake_delay(deadline_epoch_secs: u64) -> Option { - let now = current_unix_timestamp_secs(); - let remaining = deadline_epoch_secs.saturating_sub(now); - - if remaining == 0 { - None - } else { - Some(Duration::from_secs( - remaining.min(AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_POLL_SECS), - )) - } -} - -fn retry_after_delay(retry_after_epoch_secs: u64) -> Option { - let now = current_unix_timestamp_secs(); - let remaining = retry_after_epoch_secs.saturating_sub(now); - - if remaining == 0 { - None - } else { - Some(Duration::from_secs(remaining)) - } -} - -fn dns_name_from_url(url: &str) -> String { - let without_scheme = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://")) - .unwrap_or(url); - - without_scheme - .split('/') - .next() - .unwrap_or(without_scheme) - .trim_end_matches('.') - .to_string() -} - -fn management_profile_dispatches_commands( - ctx: &ResourceControllerContext<'_>, - worker_id: &str, -) -> bool { - ctx.desired_stack - .management() - .profile() - .and_then(|profile| profile.0.get(worker_id)) - .is_some_and(|refs| refs.iter().any(|r| r.id() == "worker/dispatch-command")) -} - -fn is_azure_container_apps_environment_waking_error(error: &AlienError) -> bool { - fn matches_layer(message: &str, context: Option<&serde_json::Value>) -> bool { - let context_text = context.map(|value| value.to_string()).unwrap_or_default(); - message.contains("ContainerAppEnvironmentDisabled") - || message.contains("environment is stopped due to a long period of inactivity") - || context_text.contains("ContainerAppEnvironmentDisabled") - || context_text.contains("environment is stopped due to a long period of inactivity") - } - - if matches_layer(&error.message, error.context.as_ref()) { - return true; - } - - let mut source = error.source.as_deref(); - while let Some(layer) = source { - if matches_layer(&layer.message, layer.context.as_ref()) { - return true; - } - source = layer.source.as_deref(); - } - - false -} - -fn get_container_apps_certificate_name(prefix: &str, worker_id: &str) -> String { - format!("{}-{}", prefix, worker_id) - .replace('_', "-") - .to_lowercase() -} - -/// Domain information for a worker. -struct DomainInfo { - fqdn: String, - certificate_id: Option, - keyvault_cert_id: Option, - container_apps_certificate_id: Option, - uses_custom_domain: bool, -} - -enum DaprComponentOperation { - Completed, - LongRunning(Duration), - Pending(Duration), -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AzureStorageTriggerInfrastructure { - pub source_resource_id: String, - pub event_subscription_name: String, - pub service_bus_resource_group: String, - pub namespace_name: String, - pub queue_name: String, - pub receiver_role_assignment_id: Option, -} - -fn emit_azure_container_apps_worker_heartbeat( - ctx: &ResourceControllerContext<'_>, - worker_config: &Worker, - container_app_name: &str, - container_app: &ContainerApp, -) { - let properties = container_app.properties.as_ref(); - let template = properties.and_then(|properties| properties.template.as_ref()); - let container = template.and_then(|template| template.containers.first()); - let resources = container.and_then(|container| container.resources.as_ref()); - let scale = template.and_then(|template| template.scale.as_ref()); - let ingress = properties - .and_then(|properties| properties.configuration.as_ref()) - .and_then(|configuration| configuration.ingress.as_ref()); - - ctx.emit_heartbeat(ResourceHeartbeat { - deployment_id: None, - resource_id: worker_config.id.clone(), - resource_type: Worker::RESOURCE_TYPE, - controller_platform: Platform::Azure, - backend: HeartbeatBackend::Azure, - observed_at: Utc::now(), - data: ResourceHeartbeatData::Worker(WorkerHeartbeatData::AzureContainerApps( - AzureContainerAppsWorkerHeartbeatData { - status: WorkloadHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: Some(format!( - "Azure Container App '{container_app_name}' is reachable" - )), - stale: false, - partial: false, - collection_issues: vec![], - }, - app_name: container_app_name.to_string(), - revision: properties.and_then(|properties| properties.latest_revision_name.clone()), - environment_name: properties.and_then(|properties| { - properties - .managed_environment_id - .clone() - .or_else(|| properties.environment_id.clone()) - }), - provisioning_state: properties - .and_then(|properties| properties.provisioning_state.as_ref()) - .map(|state| format!("{state:?}")), - running_status: properties - .and_then(|properties| properties.running_status.as_ref()) - .map(|status| format!("{status:?}")), - ingress_fqdn: ingress.and_then(|ingress| ingress.fqdn.clone()), - min_replicas: scale.and_then(|scale| scale.min_replicas), - max_replicas: scale.map(|scale| scale.max_replicas), - cpu: resources.and_then(|resources| resources.cpu), - memory: resources.and_then(|resources| resources.memory.clone()), - }, - )), - raw: vec![], - }); -} - -/// Converts PEM-encoded private key and certificate chain to PKCS#12 format for Azure Key Vault. -/// Azure Key Vault requires certificates in PKCS#12 (PFX) format. -fn pem_to_pkcs12(private_key_pem: &str, certificate_chain_pem: &str) -> Result> { - use alien_error::IntoAlienError; - - // Parse private key PEM - let key_blocks = pem::parse_many(private_key_pem) - .into_alien_error() - .context(ErrorData::CloudPlatformError { - message: "Failed to parse private key PEM".to_string(), - resource_id: None, - })?; +mod tests; - let key_block = key_blocks - .into_iter() - .find(|p| p.tag().ends_with("PRIVATE KEY")) - .ok_or_else(|| { - AlienError::new(ErrorData::CloudPlatformError { - message: - "No PRIVATE KEY block found in private key PEM (expected BEGIN PRIVATE KEY)" - .to_string(), - resource_id: None, - }) - })?; - - // p12 expects PKCS#8 PrivateKeyInfo DER bytes (BEGIN PRIVATE KEY) - if key_block.tag() != "PRIVATE KEY" { - return Err(AlienError::new(ErrorData::CloudPlatformError { - message: format!( - "Unsupported key type '{}'. Expected 'PRIVATE KEY' (PKCS#8). Convert to PKCS#8 first.", - key_block.tag() - ), - resource_id: None, - })); - } - let key_der = key_block.contents().to_vec(); - - // Parse certificate chain PEM - let cert_blocks = pem::parse_many(certificate_chain_pem) - .into_alien_error() - .context(ErrorData::CloudPlatformError { - message: "Failed to parse certificate chain PEM".to_string(), - resource_id: None, - })?; - - let mut certs: Vec = cert_blocks - .into_iter() - .filter(|p| p.tag().contains("CERTIFICATE")) - .collect(); - - if certs.is_empty() { - return Err(AlienError::new(ErrorData::CloudPlatformError { - message: "No CERTIFICATE blocks found in PEM".to_string(), - resource_id: None, - })); - } - - // Leaf is first, rest are intermediates - let leaf_pem = certs.remove(0); - let leaf_der = leaf_pem.contents().to_vec(); - - let intermediate_ders: Vec> = - certs.into_iter().map(|p| p.contents().to_vec()).collect(); - let intermediate_refs: Vec<&[u8]> = intermediate_ders.iter().map(|v| v.as_slice()).collect(); - - // Build PKCS#12 with empty password - let pfx = p12::PFX::new_with_cas( - &leaf_der, - &key_der, - &intermediate_refs, - "", - "Alien Worker Certificate", - ) - .ok_or_else(|| { - AlienError::new(ErrorData::CloudPlatformError { - message: "Failed to build PKCS#12 (p12::PFX::new_with_cas returned None)".to_string(), - resource_id: None, - }) - })?; +use support::*; - Ok(pfx.to_der()) -} +pub use support::AzureStorageTriggerInfrastructure; // ≡ Controller definition ======================================================= #[controller] @@ -3514,2975 +3180,3 @@ impl AzureWorkerController { } } } - -impl AzureWorkerController { - // ─────────────── HELPER METHODS ──────────────────────────── - - /// Pre-create commands infrastructure (queue, Dapr component, role assignments) - /// before the Container App is created. This ensures the Dapr sidecar starts - /// with the component already defined and RBAC roles already propagating. - async fn setup_commands_infrastructure( - &mut self, - ctx: &ResourceControllerContext<'_>, - azure_config: &alien_azure_clients::AzureClientConfig, - func_cfg: &alien_core::Worker, - container_app_name: &str, - ) -> Result { - let env_outputs = get_container_apps_environment_outputs(ctx.state)?; - let env_resource_group_name = env_outputs.resource_group_name.clone(); - let environment_name = env_outputs.environment_name.clone(); - - // Get the Service Bus namespace from the dependent resource - let namespace_ref = alien_core::ResourceRef::new( - alien_core::AzureServiceBusNamespace::RESOURCE_TYPE, - "default-service-bus-namespace", - ); - let namespace_controller = ctx.require_dependency::(&namespace_ref)?; - let namespace_name = namespace_controller - .namespace_name - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: func_cfg.id.clone(), - dependency_id: namespace_ref.id.clone(), - }) - })? - .clone(); - let service_bus_resource_group = namespace_controller.resource_group_name(ctx)?; - - // Create commands queue - let queue_name = format!("{}-rq", container_app_name); - let mgmt = ctx - .service_provider - .get_azure_service_bus_management_client(azure_config)?; - - info!( - worker=%func_cfg.id, - namespace=%namespace_name, - queue=%queue_name, - "Pre-creating commands Service Bus queue (before Container App)" - ); - - mgmt.create_or_update_queue( - service_bus_resource_group.clone(), - namespace_name.clone(), - queue_name.clone(), - alien_azure_clients::models::queue::SbQueueProperties::default(), - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create commands Service Bus queue '{}'", - queue_name - ), - resource_id: Some(func_cfg.id.clone()), - })?; - - // Create Dapr component for commands queue - use alien_azure_clients::models::managed_environments_dapr_components::{ - DaprComponent, DaprComponentProperties, DaprMetadata, - }; - - let ns_fqdn = format!("{}.servicebus.windows.net", namespace_name); - let component_name = format!("servicebus-{}-commands", container_app_name); - - let mut metadata = vec![ - DaprMetadata { - name: Some("namespaceName".into()), - value: Some(ns_fqdn), - secret_ref: None, - }, - DaprMetadata { - name: Some("queueName".into()), - value: Some(queue_name.clone()), - secret_ref: None, - }, - DaprMetadata { - name: Some("direction".into()), - value: Some("input".into()), - secret_ref: None, - }, - ]; - - // Add client ID for user-assigned managed identity - let service_account_id = format!("{}-sa", func_cfg.get_permissions()); - let service_account_ref = alien_core::ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - if let Ok(sa_state) = ctx - .require_dependency::( - &service_account_ref, - ) - { - if let Some(client_id) = &sa_state.identity_client_id { - metadata.push(DaprMetadata { - name: Some("azureClientId".into()), - value: Some(client_id.clone()), - secret_ref: None, - }); - } - } - - let dapr_component = DaprComponent { - name: Some(component_name.clone()), - properties: Some(DaprComponentProperties { - component_type: Some("bindings.azure.servicebusqueues".to_string()), - ignore_errors: false, - init_timeout: None, - version: Some("v1".to_string()), - metadata, - scopes: vec![container_app_name.to_string()], - secret_store_component: None, - secrets: vec![], - }), - id: None, - system_data: None, - type_: None, - }; - - info!( - worker=%func_cfg.id, - component=%component_name, - "Pre-creating commands Dapr Service Bus component (before Container App)" - ); - - let client = ctx - .service_provider - .get_azure_container_apps_client(azure_config)?; - - match client - .create_or_update_dapr_component( - &env_resource_group_name, - &environment_name, - &component_name, - &dapr_component, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create commands Dapr component '{}'", - component_name - ), - resource_id: Some(func_cfg.id.clone()), - })? { - OperationResult::Completed(_) => {} - OperationResult::LongRunning(lro) => { - self.pending_operation_url = Some(lro.url.clone()); - self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); - return Ok(DaprComponentOperation::LongRunning( - lro.retry_after.unwrap_or(Duration::from_secs(15)), - )); - } - } - - self.commands_namespace_name = Some(namespace_name.clone()); - self.commands_queue_name = Some(queue_name); - self.commands_dapr_component = Some(component_name); - - // Command transport RBAC is setup-applied before live worker provisioning. - self.assign_commands_sender_role( - ctx, - azure_config, - &service_bus_resource_group, - &namespace_name, - func_cfg, - ) - .await?; - - info!(worker=%func_cfg.id, "Commands infrastructure pre-created successfully"); - Ok(DaprComponentOperation::Completed) - } - - /// Ensure command transport permissions are represented in the management profile. - /// - /// Azure command transport uses Service Bus data-plane roles. Those grants are - /// setup-owned because both Terraform setup and runtime setup know the stack - /// management and execution identities before live workers are created. - async fn assign_commands_sender_role( - &mut self, - ctx: &ResourceControllerContext<'_>, - azure_config: &alien_azure_clients::AzureClientConfig, - resource_group_name: &str, - namespace_name: &str, - func_cfg: &alien_core::Worker, - ) -> Result<()> { - if !management_profile_dispatches_commands(ctx, &func_cfg.id) { - info!( - worker = %func_cfg.id, - "Skipping management command sender role because worker/dispatch-command is not granted" - ); - return Ok(()); - } - - if AzurePermissionsHelper::get_management_uami_principal_id(ctx)?.is_none() { - if self.commands_sender_role_assignment_id.is_some() { - return Ok(()); - } - - let queue_name = self.commands_queue_name.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: func_cfg.id.clone(), - dependency_id: "commands-queue".to_string(), - }) - })?; - let authorization_client = ctx - .service_provider - .get_azure_authorization_client(azure_config)?; - let principal_id = ctx - .service_provider - .get_azure_caller_principal_id(azure_config) - .await - .context(ErrorData::CloudPlatformError { - message: "Failed to resolve Azure command sender principal".to_string(), - resource_id: Some(func_cfg.id.clone()), - })?; - let queue_scope = alien_azure_clients::authorization::Scope::Resource { - resource_group_name: resource_group_name.to_string(), - resource_provider: "Microsoft.ServiceBus".to_string(), - parent_resource_path: Some(format!("namespaces/{namespace_name}")), - resource_type: "queues".to_string(), - resource_name: queue_name.clone(), - }; - let role_assignment_id = uuid::Uuid::new_v5( - &uuid::Uuid::NAMESPACE_OID, - format!( - "deployment:azure:commands-sender:{}:{}:{}:{}:{}", - ctx.resource_prefix, func_cfg.id, principal_id, namespace_name, queue_name - ) - .as_bytes(), - ) - .to_string(); - let full_assignment_id = authorization_client - .build_role_assignment_id(&queue_scope, role_assignment_id.clone()); - let role_definition_id = format!( - "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/69a216fc-b8fb-44d8-bc22-1f3c2cd27a39", - azure_config.subscription_id - ); - - AzurePermissionsHelper::create_role_assignment( - &authorization_client, - azure_config, - &queue_scope, - &role_assignment_id, - &principal_id, - &role_definition_id, - ) - .await - .context(ErrorData::CloudPlatformError { - message: "Failed to grant Azure command sender role".to_string(), - resource_id: Some(func_cfg.id.clone()), - })?; - - self.commands_sender_role_assignment_id = Some(full_assignment_id); - info!( - worker = %func_cfg.id, - principal_id = %principal_id, - namespace = %namespace_name, - queue = %queue_name, - "Granted direct-manager Azure Service Bus command sender role" - ); - return Ok(()); - } - - info!( - worker = %func_cfg.id, - "Using setup-applied Azure Service Bus command permissions" - ); - - Ok(()) - } - - /// Resolve domain information for a public worker. - /// Returns either custom domain config or auto-generated domain from metadata. - fn resolve_domain_info( - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - ) -> Result { - let stack_settings = &ctx.deployment_config.stack_settings; - - // Check for custom domain configuration - if let Some(custom) = stack_settings - .domains - .as_ref() - .and_then(|domains| domains.custom_domains.as_ref()) - .and_then(|domains| domains.get(resource_id)) - { - let keyvault_cert_id = custom - .certificate - .azure - .as_ref() - .map(|cert| cert.key_vault_certificate_id.clone()) - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Custom domain requires an Azure Key Vault certificate ID" - .to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - return Ok(DomainInfo { - fqdn: custom.domain.clone(), - certificate_id: None, - keyvault_cert_id: Some(keyvault_cert_id), - container_apps_certificate_id: None, - uses_custom_domain: true, - }); - } - - // Use auto-generated domain from domain metadata - let metadata = ctx - .deployment_config - .domain_metadata - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Domain metadata missing for public resource".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - let resource = metadata.resources.get(resource_id).ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Domain metadata missing for resource".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - Ok(DomainInfo { - fqdn: resource.fqdn.clone(), - certificate_id: Some(resource.certificate_id.clone()), - keyvault_cert_id: None, - container_apps_certificate_id: None, - uses_custom_domain: false, - }) - } - - fn ensure_domain_info( - &mut self, - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - ) -> Result { - if self.fqdn.is_some() - && (self.certificate_id.is_some() - || self.keyvault_cert_id.is_some() - || self.uses_custom_domain) - { - return Ok(true); - } - - match Self::resolve_domain_info(ctx, resource_id) { - Ok(domain_info) => { - self.fqdn = Some(domain_info.fqdn.clone()); - self.certificate_id = domain_info.certificate_id; - self.keyvault_cert_id = domain_info.keyvault_cert_id; - self.container_apps_certificate_id = domain_info.container_apps_certificate_id; - self.uses_custom_domain = domain_info.uses_custom_domain; - if self.url.is_none() { - self.url = ctx - .deployment_config - .public_endpoints - .as_ref() - .and_then(|resources| resources.get(resource_id)) - .and_then(|endpoints| endpoints.values().next().cloned()) - .or_else(|| Some(format!("https://{}", domain_info.fqdn))); - } - Ok(true) - } - Err(_) => Ok(false), - } - } - - fn clear_all(&mut self) { - self.container_app_name = None; - self.resource_id = None; - self.url = None; - self.container_app_url = None; - self.pending_operation_url = None; - self.pending_operation_retry_after = None; - self.dapr_components.clear(); - self.storage_trigger_infrastructure.clear(); - } - - /// Called whenever provisioning *succeeds* and we have the live resource. - fn handle_creation_completed( - &mut self, - ctx: &ResourceControllerContext<'_>, - app: &ContainerApp, - ) { - self.resource_id = app.id.clone(); - - let container_app_url = self.extract_url_from_container_app(app); - - // Capture the ingress host (DNS CNAME target) before `url` is overridden below. - self.container_app_url = container_app_url.clone(); - - // Check for URL override in deployment config, otherwise use Container App URL - if let Ok(config) = ctx.desired_resource_config::() { - self.url = ctx - .deployment_config - .public_endpoints - .as_ref() - .and_then(|resources| resources.get(&config.id)) - .and_then(|endpoints| endpoints.values().next().cloned()) - .or(container_app_url); - } else { - self.url = container_app_url; - } - - self.pending_operation_url = None; - self.pending_operation_retry_after = None; - } - - fn set_custom_domain(app: &mut ContainerApp, fqdn: String, certificate_id: String) { - if let Some(props) = &mut app.properties { - if let Some(config) = &mut props.configuration { - if let Some(ingress) = &mut config.ingress { - ingress.custom_domains = vec![CustomDomain { - name: fqdn, - binding_type: Some(CustomDomainBindingType::SniEnabled), - certificate_id: Some(certificate_id), - }]; - } - } - } - } - - fn extract_url_from_container_app(&self, app: &ContainerApp) -> Option { - let fqdn = app - .properties - .as_ref()? - .configuration - .as_ref()? - .ingress - .as_ref()? - .fqdn - .clone()?; - - if fqdn.starts_with("http://") || fqdn.starts_with("https://") { - Some(fqdn) - } else { - Some(format!("https://{}", fqdn)) - } - } - - /// Prepare environment variables using the shared logic, then convert to Azure's EnvironmentVar format - async fn prepare_environment_variables_azure( - &self, - func: &Worker, - ctx: &ResourceControllerContext<'_>, - ) -> Result> { - // Get the worker's own binding params (may be None during initial creation) - let self_binding_params = self.get_binding_params()?; - - // Build complete environment using shared logic - // IMPORTANT: Start with func.environment which includes injected vars from DeploymentConfig - let complete_env = EnvironmentVariableBuilder::try_new(&func.environment)? - .add_worker_runtime_env_vars(ctx, &func.id, func.timeout_seconds)? - .add_linked_resources(&func.links, ctx, &func.id) - .await? - .add_self_worker_binding(&func.id, self_binding_params.as_ref())? - .build(); - - // Add managed identity environment variable from ServiceAccount - let service_account_id = format!("{}-sa", func.get_permissions()); - let service_account_ref = ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - - let mut env_vars = Vec::new(); - - // Convert all environment variables to Azure format - for (name, value) in complete_env { - env_vars.push(EnvironmentVar { - name: Some(name), - value: Some(value), - secret_ref: None, - }); - } - - // Add Azure-specific managed identity client ID - if let Ok(service_account_state) = ctx - .require_dependency::( - &service_account_ref, - ) - { - if let Some(client_id) = &service_account_state.identity_client_id { - env_vars.push(EnvironmentVar { - name: Some(ENV_AZURE_CLIENT_ID.to_string()), - value: Some(client_id.clone()), - secret_ref: None, - }); - } - } - - Ok(env_vars) - } - - /// Build the full ContainerApps ARM spec for *desired* state. - async fn build_container_app( - &self, - func: &Worker, - _environment_name: &str, - container_app_name: &str, - azure_cfg: &AzureClientConfig, - ctx: &ResourceControllerContext<'_>, - ) -> Result { - let location = azure_cfg.region.as_deref().unwrap_or("East US"); - - let image = match &func.code { - alien_core::WorkerCode::Image { image } => image.clone(), - alien_core::WorkerCode::Source { .. } => { - return Err(AlienError::new(ErrorData::ResourceConfigInvalid { - message: format!( - "Worker '{}' uses source code, but only pre‑built images are supported on Azure", - func.id - ), - resource_id: Some(func.id.clone()), - })); - } - }; - - // Prepare environment variables using shared logic - let env_vars = self.prepare_environment_variables_azure(func, ctx).await?; - - // Note: Dapr input bindings (bindings.azure.servicebusqueues) auto-deliver - // messages without requiring GET /dapr/subscribe. No subscription env var needed. - - // Azure Container Apps requires specific CPU/memory combinations. - // The ratio is 0.5 Gi per 0.25 CPU (2 Gi per 1 CPU). - let memory_gi = func.memory_mb as f64 / 1024.0; - // Azure Container Apps requires specific CPU/memory pairs where CPU = memory_gi / 2. - // The WorkerMemoryCheck preflight validates that memory_mb is a valid Azure value - // (512, 1024, 1536, 2048, 2560, 3072, 3584, 4096). - let cpu = memory_gi / 2.0; - - let container = Container { - name: Some("main".to_string()), - image: Some(image.clone()), - resources: Some(ContainerResources { - cpu: Some(cpu), - memory: Some(format!("{}Gi", memory_gi)), - ephemeral_storage: None, - }), - env: env_vars, - args: vec![], - command: vec![], - probes: vec![], - volume_mounts: vec![], - }; - - // Tags for traceability - let mut tags = HashMap::new(); - tags.insert("resource-type".to_string(), "worker".to_string()); - tags.insert("resource".to_string(), func.id.clone()); - tags.insert("deployment".to_string(), ctx.resource_prefix.to_string()); - - let _resource_group_name = get_resource_group_name(ctx.state)?; - let environment_id = azure_utils::get_container_apps_environment_resource_id(ctx.state)?; - - let ingress_cfg = if !func.public_endpoints.is_empty() { - Some(alien_azure_clients::models::container_apps::Ingress { - external: true, - target_port: Some(8080), - traffic: vec![TrafficWeight { - weight: Some(100), - latest_revision: true, - revision_name: None, - label: None, - }], - transport: IngressTransport::Auto, - allow_insecure: false, - additional_port_mappings: vec![], - custom_domains: vec![], - ip_security_restrictions: vec![], - cors_policy: None, - client_certificate_mode: None, - exposed_port: None, - sticky_sessions: None, - fqdn: None, - }) - } else { - None - }; - - let mut registries = vec![]; - let mut secrets = vec![]; - - // Managed identity support from ServiceAccounts - // Collect all ServiceAccounts: - // 1. Permission-based ServiceAccount (from permission profile) - // 2. Linked ServiceAccounts (from worker.links) - use alien_azure_clients::models::container_apps::{ - ManagedServiceIdentity, ManagedServiceIdentityType, UserAssignedIdentities, - UserAssignedIdentity, - }; - - let mut identity_map = HashMap::new(); - - // Add permission-based ServiceAccount - let service_account_id = format!("{}-sa", func.get_permissions()); - let service_account_ref = ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - - if let Ok(service_account_state) = ctx - .require_dependency::( - &service_account_ref, - ) - { - if let Some(identity_id) = &service_account_state.identity_resource_id { - identity_map.insert( - identity_id.clone(), - UserAssignedIdentity { - client_id: None, - principal_id: None, - }, - ); - } - } - - // Add linked ServiceAccounts - for link in &func.links { - if link.resource_type() == &alien_core::ServiceAccount::RESOURCE_TYPE { - if let Ok(linked_sa_state) = ctx - .require_dependency::( - link, - ) { - if let Some(identity_id) = &linked_sa_state.identity_resource_id { - identity_map.insert( - identity_id.clone(), - UserAssignedIdentity { - client_id: None, - principal_id: None, - }, - ); - } - } - } - } - - // Configure registry credentials for image pull. - // The image URI points at the manager's registry (proxy URI from release). - // Add Basic auth with the deployment token so the Container App can pull. - let registry_token = ctx.deployment_config.deployment_token.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "deployment_token is required for Azure Container Apps to pull images from the registry proxy".to_string(), - resource_id: Some(func.id.clone()), - }) - })?; - let registry_server = image.split('/').next().unwrap_or_default().to_string(); - let secret_name = "registry-proxy-password"; - secrets.push(Secret { - name: Some(secret_name.to_string()), - value: Some(registry_token.clone()), - identity: None, - key_vault_url: None, - }); - registries.push(RegistryCredentials { - identity: None, - password_secret_ref: Some(secret_name.to_string()), - server: Some(registry_server), - username: Some("deployment".to_string()), - }); - - // Create managed identity spec if we have any identities - let identity_resource_ids: Vec = identity_map.keys().cloned().collect(); - - let managed_identity = if !identity_map.is_empty() { - Some(ManagedServiceIdentity { - principal_id: None, - tenant_id: None, - type_: ManagedServiceIdentityType::UserAssigned, - user_assigned_identities: Some(UserAssignedIdentities(identity_map)), - }) - } else { - None - }; - - // Configure Dapr if the worker uses any triggers or commands. - // Dapr handles delivery for queue (Service Bus), storage (blob), and cron triggers. - let needs_dapr = func.commands_enabled || !func.triggers.is_empty(); - let dapr_config = if needs_dapr { - use alien_azure_clients::models::container_apps::{Dapr, DaprAppProtocol}; - - Some(Dapr { - app_id: Some(container_app_name.to_string()), - app_port: Some(8080), // Port that alien-worker-runtime listens on - app_protocol: DaprAppProtocol::Http, - enable_api_logging: Some(false), - enabled: true, - http_max_request_size: None, - http_read_buffer_size: None, - log_level: None, - }) - } else { - None - }; - - let configuration = Configuration { - active_revisions_mode: ConfigurationActiveRevisionsMode::Single, - dapr: dapr_config, - identity_settings: identity_resource_ids - .iter() - .map(|identity_id| IdentitySettings { - identity: identity_id.clone(), - lifecycle: IdentitySettingsLifecycle::All, - }) - .collect(), - ingress: ingress_cfg, - max_inactive_revisions: None, - registries, - runtime: None, - secrets, - service: None, - }; - - let template = Template { - containers: vec![container], - init_containers: vec![], - revision_suffix: None, - scale: Some(Scale { - cooldown_period: None, - max_replicas: func.concurrency_limit.map(|c| c as i32).unwrap_or(10), - min_replicas: Some(if func.public_endpoints.is_empty() { - 0 - } else { - 1 - }), - polling_interval: None, - rules: vec![], - }), - service_binds: vec![], - termination_grace_period_seconds: None, - volumes: vec![], - }; - - Ok(ContainerApp { - extended_location: None, - id: None, - identity: managed_identity, - location: location.to_string(), - managed_by: None, - name: None, - properties: Some(ContainerAppProperties { - configuration: Some(configuration), - custom_domain_verification_id: None, - environment_id: None, - event_stream_endpoint: None, - latest_ready_revision_name: None, - latest_revision_fqdn: None, - latest_revision_name: None, - managed_environment_id: Some(environment_id), - outbound_ip_addresses: vec![], - provisioning_state: None, - running_status: None, - template: Some(template), - workload_profile_name: None, - }), - system_data: None, - tags, - type_: None, - }) - } - - /// Creates a Dapr Service Bus component for a queue trigger - async fn create_dapr_service_bus_component( - &mut self, - ctx: &ResourceControllerContext<'_>, - container_app_name: &str, - worker_config: &alien_core::Worker, - queue_ref: &alien_core::ResourceRef, - ) -> Result { - use alien_azure_clients::models::managed_environments_dapr_components::{ - DaprComponent, DaprComponentProperties, DaprMetadata, - }; - - let azure_config = ctx.get_azure_config()?; - // Dapr components live on the Container Apps Environment, which may be in a - // different resource group than the deployment (shared/external environments). - let env_outputs = get_container_apps_environment_outputs(ctx.state)?; - let resource_group_name = env_outputs.resource_group_name.clone(); - let environment_name = env_outputs.environment_name.clone(); - - // Get queue controller to access Service Bus namespace - let queue_controller = - ctx.require_dependency::(queue_ref)?; - let namespace = queue_controller.namespace_name.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: queue_ref.id.clone(), - }) - })?; - let ns_fqdn = format!("{}.servicebus.windows.net", namespace); - - // Generate component name: servicebus-{containerAppName}-{queueId} - let component_name = format!("servicebus-{}-{}", container_app_name, queue_ref.id); - - // Use Dapr input binding — the manager/user code sends directly to Service Bus - // via Azure SDK, not through Dapr pubsub. Input bindings auto-deliver from the - // named queue without requiring GET /dapr/subscribe subscriptions. - let queue_name = queue_controller.queue_name.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: queue_ref.id.clone(), - }) - })?; - - let dapr_component = DaprComponent { - name: Some(component_name.clone()), - properties: Some(DaprComponentProperties { - component_type: Some("bindings.azure.servicebusqueues".to_string()), - ignore_errors: false, - init_timeout: None, - version: Some("v1".to_string()), - metadata: { - let mut metadata = vec![ - DaprMetadata { - name: Some("namespaceName".into()), - value: Some(ns_fqdn), - secret_ref: None, - }, - DaprMetadata { - name: Some("queueName".into()), - value: Some(queue_name.clone()), - secret_ref: None, - }, - DaprMetadata { - name: Some("direction".into()), - value: Some("input".into()), - secret_ref: None, - }, - ]; - - // Add client ID for user-assigned managed identity - let service_account_id = format!("{}-sa", worker_config.get_permissions()); - let service_account_ref = alien_core::ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.to_string(), - ); - - if let Ok(service_account_state) = ctx.require_dependency::(&service_account_ref) { - if let Some(client_id) = &service_account_state.identity_client_id { - metadata.push(DaprMetadata { - name: Some("azureClientId".into()), - value: Some(client_id.clone()), - secret_ref: None - }); - } - } - - metadata - }, - scopes: vec![container_app_name.to_string()], - secret_store_component: None, - secrets: vec![], - }), - id: None, - system_data: None, - type_: None, - }; - - info!( - worker=%worker_config.id, - queue=%queue_ref.id, - component=%component_name, - environment=%environment_name, - "Creating Dapr Service Bus component" - ); - - let client = ctx - .service_provider - .get_azure_container_apps_client(&azure_config)?; - - match client - .create_or_update_dapr_component( - &resource_group_name, - &environment_name, - &component_name, - &dapr_component, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create Dapr component '{}' for queue '{}'", - component_name, queue_ref.id - ), - resource_id: Some(worker_config.id.clone()), - })? { - OperationResult::Completed(_) => {} - OperationResult::LongRunning(lro) => { - self.pending_operation_url = Some(lro.url.clone()); - self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); - return Ok(DaprComponentOperation::LongRunning( - lro.retry_after.unwrap_or(Duration::from_secs(15)), - )); - } - } - - if !self.dapr_components.contains(&component_name) { - self.dapr_components.push(component_name.clone()); - } - - info!( - worker=%worker_config.id, - component=%component_name, - "Successfully created Dapr Service Bus component" - ); - - Ok(DaprComponentOperation::Completed) - } - - /// Creates supported Azure storage-trigger delivery: - /// Event Grid -> dedicated Service Bus queue -> Dapr Service Bus input binding. - async fn create_azure_storage_trigger( - &mut self, - ctx: &ResourceControllerContext<'_>, - container_app_name: &str, - worker_config: &alien_core::Worker, - storage_ref: &alien_core::ResourceRef, - events: &[String], - ) -> Result { - use alien_azure_clients::authorization::Scope; - use alien_azure_clients::models::managed_environments_dapr_components::{ - DaprComponent, DaprComponentProperties, DaprMetadata, - }; - - let azure_config = ctx.get_azure_config()?; - let env_outputs = get_container_apps_environment_outputs(ctx.state)?; - let environment_resource_group = env_outputs.resource_group_name.clone(); - let environment_name = env_outputs.environment_name.clone(); - - let storage_controller = - ctx.require_dependency::(storage_ref)?; - let storage_account_name = storage_controller - .storage_account_name - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: storage_ref.id.clone(), - }) - })? - .clone(); - let container_name = storage_controller - .container_name - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: storage_ref.id.clone(), - }) - })? - .clone(); - - let namespace_ref = ResourceRef::new( - alien_core::AzureServiceBusNamespace::RESOURCE_TYPE, - "default-service-bus-namespace", - ); - let namespace_controller = ctx.require_dependency::(&namespace_ref)?; - let namespace_name = namespace_controller - .namespace_name - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: namespace_ref.id.clone(), - }) - })? - .clone(); - let service_bus_resource_group = namespace_controller.resource_group_name(ctx)?; - - let service_account_id = format!("{}-sa", worker_config.get_permissions()); - let service_account_ref = ResourceRef::new( - alien_core::ServiceAccount::RESOURCE_TYPE, - service_account_id.clone(), - ); - let service_account = ctx - .require_dependency::( - &service_account_ref, - )?; - let execution_client_id = service_account - .identity_client_id - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: service_account_id.clone(), - }) - })? - .clone(); - let execution_principal_id = service_account - .identity_principal_id - .as_ref() - .ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: service_account_id, - }) - })? - .clone(); - - let queue_name = format!("{}-storage-{}", container_app_name, storage_ref.id); - let component_name = format!("blobstorage-{}-{}", container_app_name, storage_ref.id); - let event_subscription_name = - get_azure_storage_event_subscription_name(&worker_config.id, &storage_ref.id); - let deployment_resource_group = get_resource_group_name(ctx.state)?; - let source_resource_id = format!( - "/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts/{}", - azure_config.subscription_id, deployment_resource_group, storage_account_name - ); - let queue_resource_id = format!( - "/subscriptions/{}/resourceGroups/{}/providers/Microsoft.ServiceBus/namespaces/{}/queues/{}", - azure_config.subscription_id, - service_bus_resource_group, - namespace_name, - queue_name - ); - - let mgmt = ctx - .service_provider - .get_azure_service_bus_management_client(azure_config)?; - mgmt.create_or_update_queue( - service_bus_resource_group.clone(), - namespace_name.clone(), - queue_name.clone(), - alien_azure_clients::models::queue::SbQueueProperties::default(), - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create storage-trigger Service Bus queue '{}'", - queue_name - ), - resource_id: Some(worker_config.id.clone()), - })?; - - let tracker_index = self - .storage_trigger_infrastructure - .iter() - .position(|item| item.event_subscription_name == event_subscription_name); - let tracker_index = match tracker_index { - Some(index) => { - let tracker = &mut self.storage_trigger_infrastructure[index]; - tracker.source_resource_id = source_resource_id.clone(); - tracker.service_bus_resource_group = service_bus_resource_group.clone(); - tracker.namespace_name = namespace_name.clone(); - tracker.queue_name = queue_name.clone(); - index - } - None => { - self.storage_trigger_infrastructure - .push(AzureStorageTriggerInfrastructure { - source_resource_id: source_resource_id.clone(), - event_subscription_name: event_subscription_name.clone(), - service_bus_resource_group: service_bus_resource_group.clone(), - namespace_name: namespace_name.clone(), - queue_name: queue_name.clone(), - receiver_role_assignment_id: None, - }); - self.storage_trigger_infrastructure.len() - 1 - } - }; - - if self.storage_trigger_infrastructure[tracker_index] - .receiver_role_assignment_id - .is_none() - { - let queue_scope = Scope::Resource { - resource_group_name: service_bus_resource_group.clone(), - resource_provider: "Microsoft.ServiceBus".to_string(), - parent_resource_path: Some(format!("namespaces/{namespace_name}")), - resource_type: "queues".to_string(), - resource_name: queue_name.clone(), - }; - let role_assignment_id = uuid::Uuid::new_v5( - &uuid::Uuid::NAMESPACE_OID, - format!( - "deployment:azure:storage-trigger-receiver:{}:{}:{}:{}", - ctx.resource_prefix, worker_config.id, storage_ref.id, execution_principal_id - ) - .as_bytes(), - ) - .to_string(); - let authorization_client = ctx - .service_provider - .get_azure_authorization_client(azure_config)?; - let full_assignment_id = authorization_client - .build_role_assignment_id(&queue_scope, role_assignment_id.clone()); - let role_definition_id = format!( - "/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0", - azure_config.subscription_id - ); - AzurePermissionsHelper::create_role_assignment( - &authorization_client, - azure_config, - &queue_scope, - &role_assignment_id, - &execution_principal_id, - &role_definition_id, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to grant the worker access to storage-trigger queue '{}'", - queue_name - ), - resource_id: Some(worker_config.id.clone()), - })?; - self.storage_trigger_infrastructure[tracker_index].receiver_role_assignment_id = - Some(full_assignment_id); - } - - let metadata = vec![ - DaprMetadata { - name: Some("namespaceName".into()), - value: Some(format!("{}.servicebus.windows.net", namespace_name)), - secret_ref: None, - }, - DaprMetadata { - name: Some("queueName".into()), - value: Some(queue_name.clone()), - secret_ref: None, - }, - DaprMetadata { - name: Some("direction".into()), - value: Some("input".into()), - secret_ref: None, - }, - DaprMetadata { - name: Some("azureClientId".into()), - value: Some(execution_client_id), - secret_ref: None, - }, - ]; - - let dapr_component = DaprComponent { - name: Some(component_name.clone()), - properties: Some(DaprComponentProperties { - component_type: Some("bindings.azure.servicebusqueues".to_string()), - ignore_errors: false, - init_timeout: None, - version: Some("v1".to_string()), - metadata, - scopes: vec![container_app_name.to_string()], - secret_store_component: None, - secrets: vec![], - }), - id: None, - system_data: None, - type_: None, - }; - - let container_apps_client = ctx - .service_provider - .get_azure_container_apps_client(&azure_config)?; - - match container_apps_client - .create_or_update_dapr_component( - &environment_resource_group, - &environment_name, - &component_name, - &dapr_component, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create Dapr component '{}' for storage trigger '{}'", - component_name, storage_ref.id - ), - resource_id: Some(worker_config.id.clone()), - })? { - OperationResult::Completed(_) => {} - OperationResult::LongRunning(lro) => { - self.pending_operation_url = Some(lro.url.clone()); - self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); - return Ok(DaprComponentOperation::LongRunning( - lro.retry_after.unwrap_or(Duration::from_secs(15)), - )); - } - } - - if !self.dapr_components.contains(&component_name) { - self.dapr_components.push(component_name.clone()); - } - - let event_grid_client = ctx - .service_provider - .get_azure_event_grid_client(azure_config)?; - let event_subscription = match event_grid_client - .get_event_subscription(source_resource_id.clone(), event_subscription_name.clone()) - .await - { - Ok(event_subscription) => event_subscription, - Err(error) - if matches!( - error.error, - Some(CloudClientErrorData::RemoteResourceNotFound { .. }) - ) => - { - let included_event_types = azure_storage_event_types(events, &worker_config.id)?; - event_grid_client - .create_or_update_event_subscription( - source_resource_id, - event_subscription_name.clone(), - EventSubscriptionRequest { - properties: EventSubscriptionRequestProperties { - destination: ServiceBusQueueDestination { - endpoint_type: "ServiceBusQueue".to_string(), - properties: ServiceBusQueueDestinationProperties { - resource_id: queue_resource_id, - }, - }, - filter: EventSubscriptionFilter { - included_event_types, - subject_begins_with: format!( - "/blobServices/default/containers/{}/blobs/", - container_name - ), - is_subject_case_sensitive: false, - }, - event_delivery_schema: "CloudEventSchemaV1_0".to_string(), - }, - }, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create Event Grid subscription '{}' for storage '{}'", - event_subscription_name, storage_ref.id - ), - resource_id: Some(worker_config.id.clone()), - })? - } - Err(error) => { - return Err(error.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to inspect Event Grid subscription '{}' for storage '{}'", - event_subscription_name, storage_ref.id - ), - resource_id: Some(worker_config.id.clone()), - })); - } - }; - - if let Some(provisioning_state) = event_subscription - .properties - .and_then(|properties| properties.provisioning_state) - { - if !provisioning_state.eq_ignore_ascii_case("Succeeded") { - info!( - worker=%worker_config.id, - subscription=%event_subscription_name, - state=%provisioning_state, - "Waiting for Event Grid storage subscription" - ); - return Ok(DaprComponentOperation::Pending(Duration::from_secs(5))); - } - } - - info!( - worker=%worker_config.id, - component=%component_name, - subscription=%event_subscription_name, - "Azure storage trigger delivery is ready" - ); - - Ok(DaprComponentOperation::Completed) - } - - /// Creates a Dapr cron input binding for a schedule trigger - async fn create_dapr_cron_component( - &mut self, - ctx: &ResourceControllerContext<'_>, - container_app_name: &str, - worker_config: &alien_core::Worker, - cron: &str, - index: usize, - ) -> Result { - use alien_azure_clients::models::managed_environments_dapr_components::{ - DaprComponent, DaprComponentProperties, DaprMetadata, - }; - - let azure_config = ctx.get_azure_config()?; - let env_outputs = get_container_apps_environment_outputs(ctx.state)?; - let resource_group_name = env_outputs.resource_group_name.clone(); - let environment_name = env_outputs.environment_name.clone(); - - let component_name = format!("cron-{}-{}", container_app_name, index); - - let dapr_component = DaprComponent { - name: Some(component_name.clone()), - properties: Some(DaprComponentProperties { - component_type: Some("bindings.cron".to_string()), - ignore_errors: false, - init_timeout: None, - version: Some("v1".to_string()), - metadata: vec![ - DaprMetadata { - name: Some("schedule".into()), - value: Some(cron.to_string()), - secret_ref: None, - }, - DaprMetadata { - name: Some("direction".into()), - value: Some("input".into()), - secret_ref: None, - }, - ], - scopes: vec![container_app_name.to_string()], - secret_store_component: None, - secrets: vec![], - }), - id: None, - system_data: None, - type_: None, - }; - - let client = ctx - .service_provider - .get_azure_container_apps_client(&azure_config)?; - - match client - .create_or_update_dapr_component( - &resource_group_name, - &environment_name, - &component_name, - &dapr_component, - ) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create Dapr cron component '{}' with schedule '{}'", - component_name, cron - ), - resource_id: Some(worker_config.id.clone()), - })? { - OperationResult::Completed(_) => {} - OperationResult::LongRunning(lro) => { - self.pending_operation_url = Some(lro.url.clone()); - self.pending_operation_retry_after = lro.retry_after.map(|d| d.as_secs()); - return Ok(DaprComponentOperation::LongRunning( - lro.retry_after.unwrap_or(Duration::from_secs(15)), - )); - } - } - - if !self.dapr_components.contains(&component_name) { - self.dapr_components.push(component_name.clone()); - } - - info!( - worker=%worker_config.id, - component=%component_name, - schedule=%cron, - "Successfully created Dapr cron component" - ); - - Ok(DaprComponentOperation::Completed) - } - - async fn delete_storage_trigger_infrastructure( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result<()> { - if self.storage_trigger_infrastructure.is_empty() { - return Ok(()); - } - - let azure_config = ctx.get_azure_config()?; - let event_grid_client = ctx - .service_provider - .get_azure_event_grid_client(azure_config)?; - let authorization_client = ctx - .service_provider - .get_azure_authorization_client(azure_config)?; - let service_bus_client = ctx - .service_provider - .get_azure_service_bus_management_client(azure_config)?; - - for infrastructure in self.storage_trigger_infrastructure.clone() { - match event_grid_client - .delete_event_subscription( - infrastructure.source_resource_id.clone(), - infrastructure.event_subscription_name.clone(), - ) - .await - { - Ok(()) => info!( - subscription=%infrastructure.event_subscription_name, - "Deleted Event Grid storage subscription" - ), - Err(error) => { - return Err(error.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to delete Event Grid storage subscription '{}'", - infrastructure.event_subscription_name - ), - resource_id: Some(ctx.desired_config.id().to_string()), - })); - } - } - - if let Some(assignment_id) = infrastructure.receiver_role_assignment_id { - match authorization_client - .delete_role_assignment_by_id(assignment_id.clone()) - .await - { - Ok(_) => info!( - assignment_id=%assignment_id, - "Deleted storage-trigger receiver role assignment" - ), - Err(error) - if matches!( - error.error, - Some(CloudClientErrorData::RemoteResourceNotFound { .. }) - ) => - { - info!( - assignment_id=%assignment_id, - "Storage-trigger receiver role assignment was already deleted" - ); - } - Err(error) => { - return Err(error.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to delete storage-trigger receiver role assignment '{}'", - assignment_id - ), - resource_id: Some(ctx.desired_config.id().to_string()), - })); - } - } - } - - match service_bus_client - .delete_queue( - infrastructure.service_bus_resource_group, - infrastructure.namespace_name, - infrastructure.queue_name.clone(), - ) - .await - { - Ok(()) => info!( - queue=%infrastructure.queue_name, - "Deleted storage-trigger Service Bus queue" - ), - Err(error) - if matches!( - error.error, - Some(CloudClientErrorData::RemoteResourceNotFound { .. }) - ) => - { - info!( - queue=%infrastructure.queue_name, - "Storage-trigger Service Bus queue was already deleted" - ); - } - Err(error) => { - return Err(error.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to delete storage-trigger Service Bus queue '{}'", - infrastructure.queue_name - ), - resource_id: Some(ctx.desired_config.id().to_string()), - })); - } - } - } - - self.storage_trigger_infrastructure.clear(); - Ok(()) - } - - /// Deletes all Dapr components using best-effort approach - async fn delete_all_dapr_components( - &mut self, - ctx: &ResourceControllerContext<'_>, - ) -> Result<()> { - if self.dapr_components.is_empty() { - return Ok(()); - } - - let azure_config = ctx.get_azure_config()?; - // Dapr components live on the Container Apps Environment, which may be in a - // different resource group than the deployment (shared/external environments). - let env_outputs = get_container_apps_environment_outputs(ctx.state)?; - let resource_group_name = env_outputs.resource_group_name.clone(); - let environment_name = env_outputs.environment_name.clone(); - let worker_config = ctx.desired_resource_config::()?; - - let client = ctx - .service_provider - .get_azure_container_apps_client(&azure_config)?; - - for component_name in &self.dapr_components.clone() { - match client - .delete_dapr_component(&resource_group_name, &environment_name, component_name) - .await - { - Ok(_) => { - info!( - worker=%worker_config.id, - component=%component_name, - "Dapr component delete requested" - ); - } - Err(e) - if matches!( - e.error, - Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) - ) => - { - info!( - worker=%worker_config.id, - component=%component_name, - "Dapr component was already deleted or doesn't exist" - ); - } - Err(e) => { - warn!( - worker=%worker_config.id, - component=%component_name, - error=%e, - "Failed to delete Dapr component during deletion" - ); - } - } - } - - self.dapr_components.clear(); - Ok(()) - } - - /// Creates a controller in a ready state with mock values for testing purposes. - #[cfg(feature = "test-utils")] - pub fn mock_ready(function_name: &str) -> Self { - Self { - state: AzureWorkerState::Ready, - container_app_name: Some(function_name.to_string()), - resource_id: Some(format!( - "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", - function_name - )), - url: Some(format!("https://{}.azurecontainerapps.io", function_name)), - container_app_url: None, - pending_operation_url: None, - pending_operation_retry_after: None, - dapr_components: Vec::new(), - storage_trigger_infrastructure: Vec::new(), - fqdn: None, - certificate_id: None, - keyvault_cert_id: None, - container_apps_certificate_id: None, - uses_custom_domain: false, - certificate_issued_at: None, - commands_namespace_name: None, - commands_queue_name: None, - commands_dapr_component: None, - commands_sender_role_assignment_id: None, - commands_receiver_role_assignment_id: None, - commands_infrastructure_auth_wait_until_epoch_secs: None, - container_apps_environment_wake_wait_until_epoch_secs: None, - container_apps_environment_wake_retry_after_epoch_secs: None, - pre_container_app_rbac_wait_until_epoch_secs: None, - ready_rbac_wait_until_epoch_secs: None, - update_rbac_wait_required: false, - update_dapr_components_deleted: false, - _internal_stay_count: None, - } - } -} - -#[cfg(test)] -mod tests { - //! # Azure Worker Controller Tests - //! - //! See `crate::core::controller_test` for a comprehensive guide on testing infrastructure controllers. - - use std::sync::Arc; - use std::time::Duration; - - use alien_azure_clients::models::container_apps::{ - Configuration, ConfigurationActiveRevisionsMode, ContainerApp, ContainerAppProperties, - ContainerAppPropertiesProvisioningState, TrafficWeight, - }; - use alien_azure_clients::{ - container_apps::MockContainerAppsApi, - long_running_operation::{ - LongRunningOperation, MockLongRunningOperationApi, OperationResult, - }, - }; - use alien_client_core::ErrorData as CloudClientErrorData; - use alien_core::{Platform, ResourceStatus, Worker, WorkerOutputs}; - use alien_error::{AlienError, ContextError}; - use httpmock::MockServer; - use rstest::rstest; - - use super::{ - azure_storage_event_types, current_unix_timestamp_secs, dns_name_from_url, - get_azure_storage_event_subscription_name, AZURE_RBAC_WAIT_POLL_SECS, - }; - use crate::core::{controller_test::SingleControllerExecutor, MockPlatformServiceProvider}; - use crate::error::ErrorData; - use crate::infra_requirements::azure_utils::is_azure_authorization_propagation_error; - use crate::worker::{ - fixtures::*, readiness_probe::test_utils::create_readiness_probe_mock, - AzureWorkerController, - }; - use crate::AzureWorkerState; - - #[test] - fn azure_storage_trigger_maps_only_supported_event_types() { - assert_eq!( - azure_storage_event_types( - &[ - "created".to_string(), - "deleted".to_string(), - "tierChanged".to_string(), - ], - "worker", - ) - .unwrap(), - vec![ - "Microsoft.Storage.BlobCreated", - "Microsoft.Storage.BlobDeleted", - "Microsoft.Storage.BlobTierChanged", - ] - ); - assert!(azure_storage_event_types(&["metadataUpdated".to_string()], "worker").is_err()); - } - - #[test] - fn azure_storage_event_subscription_name_is_stable_and_within_limit() { - let first = get_azure_storage_event_subscription_name( - "worker-with-a-very-long-name-that-needs-truncating", - "storage-with-a-very-long-name-that-needs-truncating", - ); - let second = get_azure_storage_event_subscription_name( - "worker-with-a-very-long-name-that-needs-truncating", - "storage-with-a-very-long-name-that-needs-truncating", - ); - assert_eq!(first, second); - assert!(first.len() <= 64); - assert!(first - .chars() - .all(|character| character.is_ascii_alphanumeric())); - } - - #[test] - fn strips_scheme_and_path_from_dns_endpoint_url() { - assert_eq!( - dns_name_from_url("https://app.example.azurecontainerapps.io/health"), - "app.example.azurecontainerapps.io" - ); - assert_eq!( - dns_name_from_url("app.example.azurecontainerapps.io."), - "app.example.azurecontainerapps.io" - ); - } - - #[test] - fn platform_domain_outputs_target_container_app_host_not_public_fqdn() { - let mut controller = AzureWorkerController::mock_ready("test-worker"); - controller.fqdn = Some("test-worker.public.example.com".to_string()); - controller.certificate_id = Some("cert_123".to_string()); - controller.url = Some("https://test-worker.azurecontainerapps.io".to_string()); - - let outputs = controller.build_outputs().unwrap(); - let worker_outputs = outputs.downcast_ref::().unwrap(); - let endpoint = worker_outputs - .public_endpoints - .get("default") - .expect("default public endpoint"); - - assert_eq!( - endpoint.url.as_str(), - "https://test-worker.azurecontainerapps.io" - ); - assert_eq!( - endpoint - .load_balancer_endpoint - .as_ref() - .map(|endpoint| endpoint.dns_name.as_str()), - Some("test-worker.azurecontainerapps.io") - ); - } - - #[test] - fn dns_target_is_ingress_host_when_url_is_overridden_to_public_fqdn() { - // Regression: when `url` is overridden to the public display FQDN (from `public_urls`), the - // CNAME target must still be the Container App ingress host. Otherwise the record name (the - // public FQDN) and the target collide into a self-referential CNAME, which the DNS provider - // rejects — the bug that deadlocked the Azure worker in `waitingForDns`. - let mut controller = AzureWorkerController::mock_ready("test-worker"); - controller.url = Some("https://test-worker.abc123.dev.vpc.direct".to_string()); - controller.container_app_url = - Some("https://test-worker.kindsky.eastus2.azurecontainerapps.io".to_string()); - - let outputs = controller.build_outputs().unwrap(); - let worker_outputs = outputs.downcast_ref::().unwrap(); - let endpoint = worker_outputs - .public_endpoints - .get("default") - .expect("default public endpoint"); - - // Display URL stays the public FQDN. - assert_eq!( - endpoint.url.as_str(), - "https://test-worker.abc123.dev.vpc.direct" - ); - // The CNAME target is the ingress host — and crucially NOT the record's own public FQDN. - let dns_name = endpoint - .load_balancer_endpoint - .as_ref() - .map(|endpoint| endpoint.dns_name.as_str()); - assert_eq!( - dns_name, - Some("test-worker.kindsky.eastus2.azurecontainerapps.io") - ); - assert_ne!(dns_name, Some("test-worker.abc123.dev.vpc.direct")); - } - - #[tokio::test] - async fn imported_worker_heartbeat_rebuilds_ingress_host_for_dns() { - // Regression for the create-path-only gap: an imported worker starts Ready with - // `container_app_url = None` and `url` = the public display FQDN (the importer skips the - // create flow). The heartbeat must rebuild `container_app_url` from the live Container App, - // so the DNS CNAME targets the ingress host rather than the self-referential public FQDN. - let app_name = "test-imported-worker"; - let mut mock = MockContainerAppsApi::new(); - mock.expect_get_container_app() - .returning(move |_, _| Ok(create_successful_container_app_response(app_name, true))) - .times(0..); - let mock_provider = setup_mock_service_provider(Arc::new(mock), None); - - // Imported shape: ingress host unset, url is the public display FQDN. - let mut controller = AzureWorkerController::mock_ready(app_name); - controller.container_app_url = None; - controller.url = Some("https://test-imported-worker.abc123.dev.vpc.direct".to_string()); - - let mut executor = SingleControllerExecutor::builder() - .resource(basic_function()) - .controller(controller) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - // The heartbeat rebuilt the ingress host… - assert_eq!( - controller.container_app_url.as_deref(), - Some("https://test-imported-worker.azurecontainerapps.io") - ); - // …so build_outputs targets it, NOT the public display FQDN. - let outputs = controller.build_outputs().unwrap(); - let worker_outputs = outputs.downcast_ref::().unwrap(); - let endpoint = worker_outputs - .public_endpoints - .get("default") - .expect("default public endpoint"); - let dns_name = endpoint - .load_balancer_endpoint - .as_ref() - .map(|endpoint| endpoint.dns_name.as_str()); - assert_eq!(dns_name, Some("test-imported-worker.azurecontainerapps.io")); - assert_ne!(dns_name, Some("test-imported-worker.abc123.dev.vpc.direct")); - } - - #[test] - fn detects_azure_authorization_propagation_error_from_http_context() { - let http_error = AlienError::new(CloudClientErrorData::HttpResponseError { - message: "Azure CreateOrUpdateDaprComponent failed: HTTP 403 Forbidden".to_string(), - url: "https://management.azure.com/test".to_string(), - http_status: 403, - http_request_text: None, - http_response_text: Some( - "{\"error\":{\"code\":\"AuthorizationFailed\",\"message\":\"The client does not have authorization to perform action. If access was recently granted, please refresh your credentials.\"}}" - .to_string(), - ), - }); - - let error = http_error.context(ErrorData::CloudPlatformError { - message: "Failed to create commands Dapr component".to_string(), - resource_id: Some("alien-rs-fn".to_string()), - }); - - assert!(is_azure_authorization_propagation_error(&error)); - } - - #[test] - fn ignores_non_authorization_cloud_platform_errors() { - let error = AlienError::new(ErrorData::CloudPlatformError { - message: "Failed to create commands Dapr component".to_string(), - resource_id: Some("alien-rs-fn".to_string()), - }); - - assert!(!is_azure_authorization_propagation_error(&error)); - } - - fn create_successful_container_app_response(app_name: &str, has_url: bool) -> ContainerApp { - let fqdn = if has_url { - Some(format!("{}.azurecontainerapps.io", app_name)) - } else { - None - }; - - let ingress = if has_url { - Some(alien_azure_clients::models::container_apps::Ingress { - external: true, - target_port: Some(8080), - fqdn: fqdn.clone(), - traffic: vec![alien_azure_clients::models::container_apps::TrafficWeight { - latest_revision: true, - weight: Some(100), - revision_name: None, - label: None, - }], - transport: alien_azure_clients::models::container_apps::IngressTransport::Auto, - allow_insecure: false, - additional_port_mappings: vec![], - custom_domains: vec![], - ip_security_restrictions: vec![], - cors_policy: None, - client_certificate_mode: None, - exposed_port: None, - sticky_sessions: None, - }) - } else { - None - }; - - ContainerApp { - id: Some(format!( - "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", - app_name - )), - name: Some(app_name.to_string()), - location: "East US".to_string(), - properties: Some(ContainerAppProperties { - provisioning_state: Some(ContainerAppPropertiesProvisioningState::Succeeded), - configuration: Some(Configuration { - ingress, - active_revisions_mode: ConfigurationActiveRevisionsMode::Single, - identity_settings: vec![], - registries: vec![], - secrets: vec![], - dapr: None, - max_inactive_revisions: None, - runtime: None, - service: None, - }), - outbound_ip_addresses: vec![], - custom_domain_verification_id: None, - environment_id: None, - event_stream_endpoint: None, - latest_ready_revision_name: None, - latest_revision_fqdn: None, - latest_revision_name: None, - managed_environment_id: None, - running_status: None, - template: None, - workload_profile_name: None, - }), - tags: std::collections::HashMap::new(), - extended_location: None, - identity: None, - managed_by: None, - system_data: None, - type_: None, - } - } - - fn create_in_progress_container_app_response(app_name: &str) -> ContainerApp { - ContainerApp { - id: Some(format!( - "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", - app_name - )), - name: Some(app_name.to_string()), - location: "East US".to_string(), - properties: Some(ContainerAppProperties { - provisioning_state: Some(ContainerAppPropertiesProvisioningState::InProgress), - outbound_ip_addresses: vec![], - custom_domain_verification_id: None, - environment_id: None, - event_stream_endpoint: None, - latest_ready_revision_name: None, - latest_revision_fqdn: None, - latest_revision_name: None, - managed_environment_id: None, - running_status: None, - template: None, - workload_profile_name: None, - configuration: None, - }), - tags: std::collections::HashMap::new(), - extended_location: None, - identity: None, - managed_by: None, - system_data: None, - type_: None, - } - } - - fn setup_mock_client_for_creation_and_update( - app_name: &str, - has_url: bool, - ) -> Arc { - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Mock successful app creation - immediate completion - let app_name = app_name.to_string(); - let app_name_for_create = app_name.clone(); - mock_container_apps - .expect_create_or_update_container_app() - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name_for_create, has_url), - )) - }); - - // Mock successful updates - immediate completion - let app_name_for_update = app_name.clone(); - mock_container_apps - .expect_update_container_app() - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name_for_update, has_url), - )) - }); - - // Mock get operations - may be called multiple times during creation and update flows - let app_name_for_get = app_name.clone(); - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| { - Ok(create_successful_container_app_response( - &app_name_for_get, - has_url, - )) - }) - .times(0..); // Allow 0 or more calls - - Arc::new(mock_container_apps) - } - - fn setup_mock_client_for_creation_and_deletion( - app_name: &str, - has_url: bool, - ) -> Arc { - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Mock successful app creation - immediate completion - let app_name = app_name.to_string(); - let app_name_for_create = app_name.clone(); - mock_container_apps - .expect_create_or_update_container_app() - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name_for_create, has_url), - )) - }); - - // Mock successful deletion - immediate completion - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - - // Mock get operations during creation (may be called multiple times) - let app_name_for_get_creation = app_name.clone(); - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| { - Ok(create_successful_container_app_response( - &app_name_for_get_creation, - has_url, - )) - }) - .times(0..); // Allow 0 or more calls during creation - - // Mock get operation failure for deletion verification - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }) - .times(0..); - - Arc::new(mock_container_apps) - } - - fn setup_mock_client_for_long_running_creation( - app_name: &str, - has_url: bool, - ) -> (Arc, Arc) { - let mut mock_container_apps = MockContainerAppsApi::new(); - let mut mock_lro = MockLongRunningOperationApi::new(); - - // Mock creation that starts as long-running - // Use minimal retry_after for fast tests (actual Azure would use seconds) - let app_name = app_name.to_string(); - mock_container_apps - .expect_create_or_update_container_app() - .returning(|_, _, _| { - Ok(OperationResult::LongRunning(LongRunningOperation { - url: "https://management.azure.com/subscriptions/.../operations/test-op" - .to_string(), - retry_after: Some(Duration::from_millis(10)), - location_url: None, - })) - }); - - // Mock LRO polling - first incomplete, then complete - mock_lro - .expect_check_status() - .returning(|_, _, _| Ok(None)) // Still running - .times(1); - - mock_lro - .expect_check_status() - .returning(|_, _, _| Ok(Some("completed".to_string()))) // Completed - .times(1); - - // Mock get operations showing progression - let app_name_for_get1 = app_name.clone(); - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| { - Ok(create_in_progress_container_app_response( - &app_name_for_get1, - )) - }) - .times(1); - - let app_name_for_get2 = app_name.clone(); - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| { - Ok(create_successful_container_app_response( - &app_name_for_get2, - has_url, - )) - }); - - (Arc::new(mock_container_apps), Arc::new(mock_lro)) - } - - fn setup_mock_client_for_best_effort_deletion( - _app_name: &str, - app_missing: bool, - ) -> Arc { - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Mock deletion (might fail if app missing) - if app_missing { - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }); - } else { - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - } - - // Always return not found for final status check - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }); - - Arc::new(mock_container_apps) - } - - fn setup_mock_service_provider( - mock_container_apps: Arc, - mock_lro: Option>, - ) -> Arc { - let mut mock_provider = MockPlatformServiceProvider::new(); - - mock_provider - .expect_get_azure_container_apps_client() - .returning(move |_| Ok(mock_container_apps.clone())); - - if let Some(lro_client) = mock_lro { - mock_provider - .expect_get_azure_long_running_operation_client() - .returning(move |_| Ok(lro_client.clone())); - } - - // Mock Azure authorization client for resource-scoped permissions - mock_provider - .expect_get_azure_authorization_client() - .returning(|_| { - use alien_azure_clients::authorization::MockAuthorizationApi; - let mut mock_auth = MockAuthorizationApi::new(); - mock_auth - .expect_create_or_update_role_definition() - .returning(|_, _, role_def| Ok(role_def.clone())); - mock_auth - .expect_build_role_assignment_id() - .returning(|_, name| { - format!( - "/test/providers/Microsoft.Authorization/roleAssignments/{}", - name - ) - }); - mock_auth - .expect_create_or_update_role_assignment_by_id() - .returning(|_, role_assignment| Ok(role_assignment.clone())); - mock_auth - .expect_delete_role_assignment_by_id() - .returning(|_| Ok(None)); - Ok(Arc::new(mock_auth)) - }); - - Arc::new(mock_provider) - } - - /// Sets up mock Container Apps client and optional readiness probe mock server - /// Returns (container_apps_mock_provider, optional_mock_server) - fn setup_mocks_for_function( - worker: &Worker, - app_name: &str, - for_deletion: bool, - ) -> (Arc, Option) { - let has_url = !worker.public_endpoints.is_empty(); - let needs_readiness_probe = has_url && worker.readiness_probe.is_some(); - - // Set up mock server for readiness probe if needed - let mock_server = if needs_readiness_probe { - Some(create_readiness_probe_mock(worker)) - } else { - None - }; - - // Set up Container Apps client mock - create custom response if we need to override URL - let container_apps_mock = if needs_readiness_probe && mock_server.is_some() { - // Create custom mock that returns the mock server URL - let mock_server_url = mock_server.as_ref().unwrap().base_url(); - setup_mock_client_with_custom_url(app_name, &mock_server_url, for_deletion) - } else if for_deletion { - setup_mock_client_for_creation_and_deletion(app_name, has_url) - } else { - setup_mock_client_for_creation_and_update(app_name, has_url) - }; - - let mock_provider = setup_mock_service_provider(container_apps_mock, None); - - (mock_provider, mock_server) - } - - fn setup_mock_client_with_custom_url( - app_name: &str, - custom_url: &str, - for_deletion: bool, - ) -> Arc { - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Create a container app response with custom URL - let custom_response = create_container_app_with_custom_url(app_name, custom_url); - - // Mock successful app creation - let response_for_create = custom_response.clone(); - mock_container_apps - .expect_create_or_update_container_app() - .returning(move |_, _, _| Ok(OperationResult::Completed(response_for_create.clone()))); - - if for_deletion { - // Mock successful deletion - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - - // Mock get operations during creation (may be called multiple times) - let response_for_get_creation = custom_response.clone(); - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| Ok(response_for_get_creation.clone())) - .times(0..); - - // Mock get operation failure for deletion verification - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }) - .times(0..); - } else { - // Mock successful updates - let response_for_update = custom_response.clone(); - mock_container_apps - .expect_update_container_app() - .returning(move |_, _, _| { - Ok(OperationResult::Completed(response_for_update.clone())) - }); - - // Mock get operations (may be called multiple times) - let response_for_get = custom_response.clone(); - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| Ok(response_for_get.clone())) - .times(0..); - } - - Arc::new(mock_container_apps) - } - - fn create_container_app_with_custom_url(app_name: &str, custom_url: &str) -> ContainerApp { - // For tests, just extract the host and port from the URL string - let url_without_protocol = custom_url.strip_prefix("http://").unwrap_or(custom_url); - let (host, _port) = if let Some(colon_pos) = url_without_protocol.find(':') { - let host = &url_without_protocol[..colon_pos]; - let port_str = &url_without_protocol[colon_pos + 1..]; - let port = port_str.parse::().unwrap_or(80); - (host, Some(port)) - } else { - (url_without_protocol, None) - }; - - // Create FQDN that matches the custom URL - let _fqdn = if let Some(port) = _port { - format!("{}:{}", host, port) - } else { - host.to_string() - }; - - let ingress = Some(alien_azure_clients::models::container_apps::Ingress { - external: true, - target_port: Some(8080), - fqdn: Some(custom_url.to_string()), // Use the full URL as FQDN for the test - traffic: vec![alien_azure_clients::models::container_apps::TrafficWeight { - latest_revision: true, - weight: Some(100), - revision_name: None, - label: None, - }], - transport: alien_azure_clients::models::container_apps::IngressTransport::Auto, - allow_insecure: false, - additional_port_mappings: vec![], - custom_domains: vec![], - ip_security_restrictions: vec![], - cors_policy: None, - client_certificate_mode: None, - exposed_port: None, - sticky_sessions: None, - }); - - ContainerApp { - id: Some(format!( - "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", - app_name - )), - name: Some(app_name.to_string()), - location: "East US".to_string(), - properties: Some(ContainerAppProperties { - provisioning_state: Some(ContainerAppPropertiesProvisioningState::Succeeded), - configuration: Some(Configuration { - ingress, - active_revisions_mode: ConfigurationActiveRevisionsMode::Single, - identity_settings: vec![], - registries: vec![], - secrets: vec![], - dapr: None, - max_inactive_revisions: None, - runtime: None, - service: None, - }), - outbound_ip_addresses: vec![], - custom_domain_verification_id: None, - environment_id: None, - event_stream_endpoint: None, - latest_ready_revision_name: None, - latest_revision_fqdn: None, - latest_revision_name: None, - managed_environment_id: None, - running_status: None, - template: None, - workload_profile_name: None, - }), - tags: std::collections::HashMap::new(), - extended_location: None, - identity: None, - managed_by: None, - system_data: None, - type_: None, - } - } - - async fn executor_for_wait_state( - controller: AzureWorkerController, - ) -> SingleControllerExecutor { - SingleControllerExecutor::builder() - .resource(basic_function()) - .controller(controller) - .platform(Platform::Azure) - .service_provider(Arc::new(MockPlatformServiceProvider::new())) - .with_test_dependencies() - .build() - .await - .unwrap() - } - - #[tokio::test] - async fn test_pre_container_app_rbac_wait_holds_state_when_woken_early() { - let deadline = current_unix_timestamp_secs().saturating_add(60); - let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); - controller.state = AzureWorkerState::WaitingBeforeContainerAppCreation; - controller.pre_container_app_rbac_wait_until_epoch_secs = Some(deadline); - - let mut executor = executor_for_wait_state(controller).await; - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Provisioning); - assert_eq!( - controller.state, - AzureWorkerState::WaitingBeforeContainerAppCreation - ); - assert_eq!( - step_result.suggested_delay, - Some(Duration::from_secs(AZURE_RBAC_WAIT_POLL_SECS)) - ); - assert_eq!( - controller.pre_container_app_rbac_wait_until_epoch_secs, - Some(deadline) - ); - } - - #[tokio::test] - async fn test_ready_rbac_wait_holds_state_when_woken_early() { - let deadline = current_unix_timestamp_secs().saturating_add(60); - let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); - controller.state = AzureWorkerState::WaitingForRbacPropagation; - controller.ready_rbac_wait_until_epoch_secs = Some(deadline); - - let mut executor = executor_for_wait_state(controller).await; - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Provisioning); - assert_eq!( - controller.state, - AzureWorkerState::WaitingForRbacPropagation - ); - assert_eq!( - step_result.suggested_delay, - Some(Duration::from_secs(AZURE_RBAC_WAIT_POLL_SECS)) - ); - assert_eq!(controller.ready_rbac_wait_until_epoch_secs, Some(deadline)); - } - - #[tokio::test] - async fn test_ready_rbac_wait_advances_after_deadline() { - let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); - controller.state = AzureWorkerState::WaitingForRbacPropagation; - controller.ready_rbac_wait_until_epoch_secs = - Some(current_unix_timestamp_secs().saturating_sub(1)); - - let mut executor = executor_for_wait_state(controller).await; - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Provisioning); - assert_eq!(controller.state, AzureWorkerState::RunningReadinessProbe); - assert_eq!(step_result.suggested_delay, None); - assert_eq!(controller.ready_rbac_wait_until_epoch_secs, None); - - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Running); - assert_eq!(controller.state, AzureWorkerState::Ready); - assert_eq!(step_result.suggested_delay, None); - } - - #[tokio::test] - async fn test_update_rbac_wait_holds_and_clears() { - let deadline = current_unix_timestamp_secs().saturating_add(60); - let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); - controller.state = AzureWorkerState::UpdateWaitingForRbacPropagation; - controller.ready_rbac_wait_until_epoch_secs = Some(deadline); - controller.update_rbac_wait_required = true; - - let mut executor = executor_for_wait_state(controller).await; - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Updating); - assert_eq!( - controller.state, - AzureWorkerState::UpdateWaitingForRbacPropagation - ); - assert_eq!( - step_result.suggested_delay, - Some(Duration::from_secs(AZURE_RBAC_WAIT_POLL_SECS)) - ); - assert_eq!(controller.ready_rbac_wait_until_epoch_secs, Some(deadline)); - assert!(controller.update_rbac_wait_required); - - let mut controller = controller.clone(); - controller.ready_rbac_wait_until_epoch_secs = - Some(current_unix_timestamp_secs().saturating_sub(1)); - let mut executor = executor_for_wait_state(controller).await; - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Updating); - assert_eq!( - controller.state, - AzureWorkerState::UpdateRunningReadinessProbe - ); - assert_eq!(step_result.suggested_delay, None); - assert_eq!(controller.ready_rbac_wait_until_epoch_secs, None); - assert!(!controller.update_rbac_wait_required); - - let step_result = executor.step().await.unwrap(); - let controller = executor.internal_state::().unwrap(); - - assert_eq!(executor.status(), ResourceStatus::Running); - assert_eq!(controller.state, AzureWorkerState::Ready); - assert_eq!(step_result.suggested_delay, None); - } - - // ─────────────── CREATE AND DELETE FLOW TESTS ──────────────────── - - #[rstest] - #[case::basic(basic_function())] - #[case::env_vars(function_with_env_vars())] - #[case::storage_link(function_with_storage_link())] - #[case::env_and_storage(function_with_env_and_storage())] - #[case::multiple_storages(function_with_multiple_storages())] - #[case::public_ingress(function_public_ingress())] - #[case::private_ingress(function_private_ingress())] - #[case::concurrency(function_with_concurrency())] - #[case::custom_config(function_custom_config())] - #[case::readiness_probe(function_with_readiness_probe())] - #[case::complete_test(function_complete_test())] - #[tokio::test] - async fn test_create_and_delete_flow_succeeds(#[case] worker: Worker) { - let app_name = format!("test-{}", worker.id); - let (mock_provider, _mock_server) = setup_mocks_for_function(&worker, &app_name, true); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AzureWorkerController::default()) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Run create flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify outputs are available - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.identifier.is_some()); - assert!(function_outputs.worker_name.starts_with("test-")); - - // Delete the worker - executor.delete().unwrap(); - - // Run delete flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } - - // ─────────────── UPDATE FLOW TESTS ──────────────────────────────── - - #[rstest] - #[case::basic_to_env(basic_function(), function_with_env_vars())] - #[case::env_to_storage(function_with_env_vars(), function_with_storage_link())] - #[case::storage_to_custom(function_with_storage_link(), function_custom_config())] - #[case::custom_to_public(function_custom_config(), function_public_ingress())] - #[case::public_to_complete(function_public_ingress(), function_complete_test())] - #[case::complete_to_basic(function_complete_test(), basic_function())] - #[tokio::test] - async fn test_update_flow_succeeds(#[case] from_function: Worker, #[case] to_function: Worker) { - // Ensure both workers have the same ID for valid updates - let worker_id = "test-update-worker".to_string(); - let mut from_function = from_function; - from_function.id = worker_id.clone(); - - let mut to_function = to_function; - to_function.id = worker_id.clone(); - - let app_name = format!("test-{}", worker_id); - let (mock_provider, mock_server) = setup_mocks_for_function(&to_function, &app_name, false); - - // Start with the "from" worker in Ready state - let mut ready_controller = AzureWorkerController::mock_ready(&app_name); - - // If the target worker has a readiness probe, update the controller URL to point to mock server - if to_function.readiness_probe.is_some() && !to_function.public_endpoints.is_empty() { - if let Some(ref server) = mock_server { - ready_controller.url = Some(server.base_url()); - } - } else if !to_function.public_endpoints.is_empty() { - // Ensure the controller has a URL for public workers - ready_controller.url = Some(format!("https://{}.azurecontainerapps.io", app_name)); - } - - let mut executor = SingleControllerExecutor::builder() - .resource(from_function) - .controller(ready_controller) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Ensure we start in Running state - assert_eq!(executor.status(), ResourceStatus::Running); - - // Update to the new worker - executor.update(to_function).unwrap(); - - // Run the update flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - // ─────────────── BEST EFFORT DELETION TESTS ─────────────────────── - - #[rstest] - #[case::basic(basic_function(), false)] - #[case::public_with_missing_app(function_public_ingress(), true)] - #[case::private_with_missing_app(function_private_ingress(), true)] - #[tokio::test] - async fn test_best_effort_deletion_when_resources_missing( - #[case] worker: Worker, - #[case] app_missing: bool, - ) { - let app_name = format!("test-{}", worker.id); - let mock_container_apps = - setup_mock_client_for_best_effort_deletion(&app_name, app_missing); - let mock_provider = setup_mock_service_provider(mock_container_apps, None); - - // Start with a ready controller - let mut ready_controller = AzureWorkerController::mock_ready(&app_name); - if !worker.public_endpoints.is_empty() { - ready_controller.url = Some(format!("https://{}.azurecontainerapps.io", app_name)); - } - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(ready_controller) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Ensure we start in Running state - assert_eq!(executor.status(), ResourceStatus::Running); - - // Delete the worker - executor.delete().unwrap(); - - // Run the delete flow - it should succeed even when resources are missing - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } - - // ─────────────── LONG RUNNING OPERATION TESTS ────────────────────── - - #[tokio::test] - async fn test_long_running_creation_operation() { - let worker = basic_function(); - let app_name = format!("test-{}", worker.id); - let (mock_container_apps, mock_lro) = - setup_mock_client_for_long_running_creation(&app_name, false); - let mock_provider = setup_mock_service_provider(mock_container_apps, Some(mock_lro)); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AzureWorkerController::default()) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Run create flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify the controller went through LRO states - let controller = executor.internal_state::().unwrap(); - assert!(controller.container_app_name.is_some()); - assert!(controller.resource_id.is_some()); - } - - // ─────────────── SPECIFIC VALIDATION TESTS ───────────────── - - /// Test that verifies public workers get URL in outputs - #[tokio::test] - async fn test_public_function_gets_url_in_outputs() { - let worker = function_public_ingress(); - let app_name = format!("test-{}", worker.id); - - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Mock creation with URL - mock_container_apps - .expect_create_or_update_container_app() - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name, true), - )) - }); - - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AzureWorkerController::default()) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify URL is in outputs - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - let endpoint = function_outputs - .public_endpoints - .get("default") - .expect("default public endpoint"); - assert!(endpoint.url.contains("azurecontainerapps.io")); - } - - /// Test that verifies private workers don't get URL in outputs - #[tokio::test] - async fn test_private_function_has_no_url_in_outputs() { - let worker = function_private_ingress(); - let app_name = format!("test-{}", worker.id); - - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Mock creation without URL - mock_container_apps - .expect_create_or_update_container_app() - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name, false), - )) - }); - - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AzureWorkerController::default()) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify no URL in outputs - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.public_endpoints.is_empty()); - } - - /// Test that verifies correct container app configuration parameters - #[tokio::test] - async fn test_container_app_configuration_validation() { - let worker = function_custom_config(); - let app_name = format!("test-{}", worker.id); - - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Validate container app creation request has correct parameters - let app_name_for_response = app_name.clone(); - mock_container_apps - .expect_create_or_update_container_app() - .withf(|_rg, _name, container_app| { - // Check that the container has correct resource configuration - if let Some(properties) = &container_app.properties { - if let Some(template) = &properties.template { - if let Some(container) = template.containers.first() { - if let Some(resources) = &container.resources { - // function_custom_config has 512MB memory - let expected_memory = format!("{}Gi", 512.0 / 1024.0); - return resources.memory.as_ref() == Some(&expected_memory) - && resources.cpu == Some(0.25); - } - } - } - } - false - }) - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name_for_response, false), - )) - }); - - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - - // Allow get_container_app calls during creation (may be called 0 or more times) - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| Ok(create_successful_container_app_response(&app_name, false))) - .times(0..); - - // Mock get operation failure for deletion verification - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }) - .times(0..); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AzureWorkerController::default()) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - /// Test that verifies environment variables are correctly passed - #[tokio::test] - async fn test_environment_variable_handling() { - let worker = function_with_env_vars(); - let app_name = format!("test-{}", worker.id); - - let mut mock_container_apps = MockContainerAppsApi::new(); - - // Validate container app creation request has environment variables - let app_name_for_response = app_name.clone(); - mock_container_apps - .expect_create_or_update_container_app() - .withf(|_rg, _name, container_app| { - if let Some(properties) = &container_app.properties { - if let Some(template) = &properties.template { - if let Some(container) = template.containers.first() { - // Check that environment variables are present - let has_app_env = container.env.iter().any(|env_var| { - env_var.name.as_deref() == Some("APP_ENV") - && env_var.value.as_deref() == Some("production") - }); - let has_log_level = container.env.iter().any(|env_var| { - env_var.name.as_deref() == Some("LOG_LEVEL") - && env_var.value.as_deref() == Some("debug") - }); - let has_db_name = container.env.iter().any(|env_var| { - env_var.name.as_deref() == Some("DB_NAME") - && env_var.value.as_deref() == Some("myapp") - }); - return has_app_env && has_log_level && has_db_name; - } - } - } - false - }) - .returning(move |_, _, _| { - Ok(OperationResult::Completed( - create_successful_container_app_response(&app_name_for_response, false), - )) - }); - - mock_container_apps - .expect_delete_container_app() - .returning(|_, _| Ok(OperationResult::Completed(()))); - - // Allow get_container_app calls during creation (may be called 0 or more times) - mock_container_apps - .expect_get_container_app() - .returning(move |_, _| Ok(create_successful_container_app_response(&app_name, false))) - .times(0..); - - // Mock get operation failure for deletion verification - mock_container_apps - .expect_get_container_app() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "ContainerApp".to_string(), - resource_name: "test-app".to_string(), - }, - )) - }) - .times(0..); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AzureWorkerController::default()) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - /// Test that verifies deletion works when container_app_name is not set (early creation failure) - #[tokio::test] - async fn test_delete_with_no_container_app_name_succeeds() { - let worker = basic_function(); - - // Create a controller with no container app name set (simulating early creation failure) - let controller = AzureWorkerController { - state: AzureWorkerState::CreateFailed, - container_app_name: None, // This is the key - no container app name set - resource_id: None, - url: None, - container_app_url: None, - pending_operation_url: None, - pending_operation_retry_after: None, - dapr_components: Vec::new(), - storage_trigger_infrastructure: Vec::new(), - fqdn: None, - certificate_id: None, - keyvault_cert_id: None, - container_apps_certificate_id: None, - uses_custom_domain: false, - certificate_issued_at: None, - commands_namespace_name: None, - commands_queue_name: None, - commands_dapr_component: None, - commands_sender_role_assignment_id: None, - commands_receiver_role_assignment_id: None, - commands_infrastructure_auth_wait_until_epoch_secs: None, - container_apps_environment_wake_wait_until_epoch_secs: None, - container_apps_environment_wake_retry_after_epoch_secs: None, - pre_container_app_rbac_wait_until_epoch_secs: None, - ready_rbac_wait_until_epoch_secs: None, - update_rbac_wait_required: false, - update_dapr_components_deleted: false, - _internal_stay_count: None, - }; - - // Mock provider - no expectations since no API calls should be made - let mock_provider = Arc::new(MockPlatformServiceProvider::new()); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(controller) - .platform(Platform::Azure) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Start in CreateFailed state - assert_eq!(executor.status(), ResourceStatus::ProvisionFailed); - - // Delete the worker - executor.delete().unwrap(); - - // Run the delete flow - should succeed without making any API calls - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } -} diff --git a/crates/alien-infra/src/worker/azure/support.rs b/crates/alien-infra/src/worker/azure/support.rs new file mode 100644 index 000000000..2eed433bc --- /dev/null +++ b/crates/alien-infra/src/worker/azure/support.rs @@ -0,0 +1,359 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::core::ResourceControllerContext; +use crate::error::{ErrorData, Result}; +use alien_azure_clients::models::container_apps::ContainerApp; +use alien_core::{ + AzureContainerAppsWorkerHeartbeatData, HeartbeatBackend, ObservedHealth, Platform, + ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, Worker, WorkerHeartbeatData, + WorkloadHeartbeatStatus, +}; +use alien_error::{AlienError, Context}; +use chrono::Utc; + +/// Generates a deterministic Azure Container Apps name for a worker. +pub(super) fn get_azure_container_app_name(prefix: &str, name: &str) -> String { + format!("{}-{}", prefix, name) +} + +pub(super) fn get_azure_storage_event_subscription_name( + worker_id: &str, + storage_id: &str, +) -> String { + let mut stem: String = format!("alien{worker_id}{storage_id}") + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect(); + stem.truncate(31); + let suffix = uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_OID, + format!("azure-storage-trigger:{worker_id}:{storage_id}").as_bytes(), + ) + .simple() + .to_string(); + format!("{stem}{suffix}") +} + +pub(super) fn azure_storage_event_types(events: &[String], worker_id: &str) -> Result> { + events + .iter() + .map(|event| { + let event_type = match event.as_str() { + "created" => "Microsoft.Storage.BlobCreated", + "deleted" => "Microsoft.Storage.BlobDeleted", + "tierChanged" => "Microsoft.Storage.BlobTierChanged", + _ => { + return Err(AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!( + "Azure storage trigger event '{}' is not supported; expected one of: created, deleted, tierChanged", + event + ), + resource_id: Some(worker_id.to_string()), + })); + } + }; + Ok(event_type.to_string()) + }) + .collect() +} + +#[cfg(not(test))] +pub(super) const AZURE_PRE_CONTAINER_APP_RBAC_WAIT_SECS: u64 = 60; +#[cfg(test)] +pub(super) const AZURE_PRE_CONTAINER_APP_RBAC_WAIT_SECS: u64 = 0; + +#[cfg(not(test))] +pub(super) const AZURE_READY_RBAC_WAIT_SECS: u64 = 120; +#[cfg(test)] +pub(super) const AZURE_READY_RBAC_WAIT_SECS: u64 = 0; + +#[cfg(not(test))] +pub(super) const AZURE_COMMANDS_INFRASTRUCTURE_AUTH_WAIT_SECS: u64 = 900; +#[cfg(test)] +pub(super) const AZURE_COMMANDS_INFRASTRUCTURE_AUTH_WAIT_SECS: u64 = 0; + +#[cfg(not(test))] +pub(super) const AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_WAIT_SECS: u64 = 600; +#[cfg(test)] +pub(super) const AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_WAIT_SECS: u64 = 0; + +pub(super) const AZURE_RBAC_WAIT_POLL_SECS: u64 = 10; +pub(super) const AZURE_RBAC_WAIT_MAX_ATTEMPTS: u32 = 1_000; +pub(super) const AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_POLL_SECS: u64 = 30; + +pub(super) fn current_unix_timestamp_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +pub(super) fn ensure_rbac_wait_deadline( + wait_until_epoch_secs: &mut Option, + wait_secs: u64, +) -> u64 { + let now = current_unix_timestamp_secs(); + *wait_until_epoch_secs.get_or_insert_with(|| now.saturating_add(wait_secs)) +} + +pub(super) fn rbac_wait_delay(deadline_epoch_secs: u64) -> Option { + let now = current_unix_timestamp_secs(); + let remaining = deadline_epoch_secs.saturating_sub(now); + + if remaining == 0 { + None + } else { + Some(Duration::from_secs( + remaining.min(AZURE_RBAC_WAIT_POLL_SECS), + )) + } +} + +pub(super) fn container_apps_environment_wake_delay(deadline_epoch_secs: u64) -> Option { + let now = current_unix_timestamp_secs(); + let remaining = deadline_epoch_secs.saturating_sub(now); + + if remaining == 0 { + None + } else { + Some(Duration::from_secs( + remaining.min(AZURE_CONTAINER_APPS_ENVIRONMENT_WAKE_POLL_SECS), + )) + } +} + +pub(super) fn retry_after_delay(retry_after_epoch_secs: u64) -> Option { + let now = current_unix_timestamp_secs(); + let remaining = retry_after_epoch_secs.saturating_sub(now); + + if remaining == 0 { + None + } else { + Some(Duration::from_secs(remaining)) + } +} + +pub(super) fn dns_name_from_url(url: &str) -> String { + let without_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .unwrap_or(url); + + without_scheme + .split('/') + .next() + .unwrap_or(without_scheme) + .trim_end_matches('.') + .to_string() +} + +pub(super) fn management_profile_dispatches_commands( + ctx: &ResourceControllerContext<'_>, + worker_id: &str, +) -> bool { + ctx.desired_stack + .management() + .profile() + .and_then(|profile| profile.0.get(worker_id)) + .is_some_and(|refs| refs.iter().any(|r| r.id() == "worker/dispatch-command")) +} + +pub(super) fn is_azure_container_apps_environment_waking_error( + error: &AlienError, +) -> bool { + fn matches_layer(message: &str, context: Option<&serde_json::Value>) -> bool { + let context_text = context.map(|value| value.to_string()).unwrap_or_default(); + message.contains("ContainerAppEnvironmentDisabled") + || message.contains("environment is stopped due to a long period of inactivity") + || context_text.contains("ContainerAppEnvironmentDisabled") + || context_text.contains("environment is stopped due to a long period of inactivity") + } + + if matches_layer(&error.message, error.context.as_ref()) { + return true; + } + + let mut source = error.source.as_deref(); + while let Some(layer) = source { + if matches_layer(&layer.message, layer.context.as_ref()) { + return true; + } + source = layer.source.as_deref(); + } + + false +} + +pub(super) fn get_container_apps_certificate_name(prefix: &str, worker_id: &str) -> String { + format!("{}-{}", prefix, worker_id) + .replace('_', "-") + .to_lowercase() +} + +/// Domain information for a worker. +pub(super) struct DomainInfo { + pub(super) fqdn: String, + pub(super) certificate_id: Option, + pub(super) keyvault_cert_id: Option, + pub(super) container_apps_certificate_id: Option, + pub(super) uses_custom_domain: bool, +} + +pub(super) enum DaprComponentOperation { + Completed, + LongRunning(Duration), + Pending(Duration), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AzureStorageTriggerInfrastructure { + pub source_resource_id: String, + pub event_subscription_name: String, + pub service_bus_resource_group: String, + pub namespace_name: String, + pub queue_name: String, + pub receiver_role_assignment_id: Option, +} + +pub(super) fn emit_azure_container_apps_worker_heartbeat( + ctx: &ResourceControllerContext<'_>, + worker_config: &Worker, + container_app_name: &str, + container_app: &ContainerApp, +) { + let properties = container_app.properties.as_ref(); + let template = properties.and_then(|properties| properties.template.as_ref()); + let container = template.and_then(|template| template.containers.first()); + let resources = container.and_then(|container| container.resources.as_ref()); + let scale = template.and_then(|template| template.scale.as_ref()); + let ingress = properties + .and_then(|properties| properties.configuration.as_ref()) + .and_then(|configuration| configuration.ingress.as_ref()); + + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: worker_config.id.clone(), + resource_type: Worker::RESOURCE_TYPE, + controller_platform: Platform::Azure, + backend: HeartbeatBackend::Azure, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Worker(WorkerHeartbeatData::AzureContainerApps( + AzureContainerAppsWorkerHeartbeatData { + status: WorkloadHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: Some(format!( + "Azure Container App '{container_app_name}' is reachable" + )), + stale: false, + partial: false, + collection_issues: vec![], + }, + app_name: container_app_name.to_string(), + revision: properties.and_then(|properties| properties.latest_revision_name.clone()), + environment_name: properties.and_then(|properties| { + properties + .managed_environment_id + .clone() + .or_else(|| properties.environment_id.clone()) + }), + provisioning_state: properties + .and_then(|properties| properties.provisioning_state.as_ref()) + .map(|state| format!("{state:?}")), + running_status: properties + .and_then(|properties| properties.running_status.as_ref()) + .map(|status| format!("{status:?}")), + ingress_fqdn: ingress.and_then(|ingress| ingress.fqdn.clone()), + min_replicas: scale.and_then(|scale| scale.min_replicas), + max_replicas: scale.map(|scale| scale.max_replicas), + cpu: resources.and_then(|resources| resources.cpu), + memory: resources.and_then(|resources| resources.memory.clone()), + }, + )), + raw: vec![], + }); +} + +/// Converts PEM-encoded private key and certificate chain to PKCS#12 format for Azure Key Vault. +/// Azure Key Vault requires certificates in PKCS#12 (PFX) format. +pub(super) fn pem_to_pkcs12(private_key_pem: &str, certificate_chain_pem: &str) -> Result> { + use alien_error::IntoAlienError; + + // Parse private key PEM + let key_blocks = pem::parse_many(private_key_pem) + .into_alien_error() + .context(ErrorData::CloudPlatformError { + message: "Failed to parse private key PEM".to_string(), + resource_id: None, + })?; + + let key_block = key_blocks + .into_iter() + .find(|p| p.tag().ends_with("PRIVATE KEY")) + .ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: + "No PRIVATE KEY block found in private key PEM (expected BEGIN PRIVATE KEY)" + .to_string(), + resource_id: None, + }) + })?; + + // p12 expects PKCS#8 PrivateKeyInfo DER bytes (BEGIN PRIVATE KEY) + if key_block.tag() != "PRIVATE KEY" { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: format!( + "Unsupported key type '{}'. Expected 'PRIVATE KEY' (PKCS#8). Convert to PKCS#8 first.", + key_block.tag() + ), + resource_id: None, + })); + } + let key_der = key_block.contents().to_vec(); + + // Parse certificate chain PEM + let cert_blocks = pem::parse_many(certificate_chain_pem) + .into_alien_error() + .context(ErrorData::CloudPlatformError { + message: "Failed to parse certificate chain PEM".to_string(), + resource_id: None, + })?; + + let mut certs: Vec = cert_blocks + .into_iter() + .filter(|p| p.tag().contains("CERTIFICATE")) + .collect(); + + if certs.is_empty() { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: "No CERTIFICATE blocks found in PEM".to_string(), + resource_id: None, + })); + } + + // Leaf is first, rest are intermediates + let leaf_pem = certs.remove(0); + let leaf_der = leaf_pem.contents().to_vec(); + + let intermediate_ders: Vec> = + certs.into_iter().map(|p| p.contents().to_vec()).collect(); + let intermediate_refs: Vec<&[u8]> = intermediate_ders.iter().map(|v| v.as_slice()).collect(); + + // Build PKCS#12 with empty password + let pfx = p12::PFX::new_with_cas( + &leaf_der, + &key_der, + &intermediate_refs, + "", + "Alien Worker Certificate", + ) + .ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: "Failed to build PKCS#12 (p12::PFX::new_with_cas returned None)".to_string(), + resource_id: None, + }) + })?; + + Ok(pfx.to_der()) +} diff --git a/crates/alien-infra/src/worker/azure/tests.rs b/crates/alien-infra/src/worker/azure/tests.rs new file mode 100644 index 000000000..2c457611c --- /dev/null +++ b/crates/alien-infra/src/worker/azure/tests.rs @@ -0,0 +1,1379 @@ +//! # Azure Worker Controller Tests +//! +//! See `crate::core::controller_test` for a comprehensive guide on testing infrastructure controllers. + +use std::sync::Arc; +use std::time::Duration; + +use alien_azure_clients::models::container_apps::{ + Configuration, ConfigurationActiveRevisionsMode, ContainerApp, ContainerAppProperties, + ContainerAppPropertiesProvisioningState, TrafficWeight, +}; +use alien_azure_clients::{ + container_apps::MockContainerAppsApi, + long_running_operation::{LongRunningOperation, MockLongRunningOperationApi, OperationResult}, +}; +use alien_client_core::ErrorData as CloudClientErrorData; +use alien_core::{Platform, ResourceStatus, Worker, WorkerOutputs}; +use alien_error::{AlienError, ContextError}; +use httpmock::MockServer; +use rstest::rstest; + +use super::{ + azure_storage_event_types, current_unix_timestamp_secs, dns_name_from_url, + get_azure_storage_event_subscription_name, AZURE_RBAC_WAIT_POLL_SECS, +}; +use crate::core::{controller_test::SingleControllerExecutor, MockPlatformServiceProvider}; +use crate::error::ErrorData; +use crate::infra_requirements::azure_utils::is_azure_authorization_propagation_error; +use crate::worker::{ + fixtures::*, readiness_probe::test_utils::create_readiness_probe_mock, AzureWorkerController, +}; +use crate::AzureWorkerState; + +#[test] +fn azure_storage_trigger_maps_only_supported_event_types() { + assert_eq!( + azure_storage_event_types( + &[ + "created".to_string(), + "deleted".to_string(), + "tierChanged".to_string(), + ], + "worker", + ) + .unwrap(), + vec![ + "Microsoft.Storage.BlobCreated", + "Microsoft.Storage.BlobDeleted", + "Microsoft.Storage.BlobTierChanged", + ] + ); + assert!(azure_storage_event_types(&["metadataUpdated".to_string()], "worker").is_err()); +} + +#[test] +fn azure_storage_event_subscription_name_is_stable_and_within_limit() { + let first = get_azure_storage_event_subscription_name( + "worker-with-a-very-long-name-that-needs-truncating", + "storage-with-a-very-long-name-that-needs-truncating", + ); + let second = get_azure_storage_event_subscription_name( + "worker-with-a-very-long-name-that-needs-truncating", + "storage-with-a-very-long-name-that-needs-truncating", + ); + assert_eq!(first, second); + assert!(first.len() <= 64); + assert!(first + .chars() + .all(|character| character.is_ascii_alphanumeric())); +} + +#[test] +fn strips_scheme_and_path_from_dns_endpoint_url() { + assert_eq!( + dns_name_from_url("https://app.example.azurecontainerapps.io/health"), + "app.example.azurecontainerapps.io" + ); + assert_eq!( + dns_name_from_url("app.example.azurecontainerapps.io."), + "app.example.azurecontainerapps.io" + ); +} + +#[test] +fn platform_domain_outputs_target_container_app_host_not_public_fqdn() { + let mut controller = AzureWorkerController::mock_ready("test-worker"); + controller.fqdn = Some("test-worker.public.example.com".to_string()); + controller.certificate_id = Some("cert_123".to_string()); + controller.url = Some("https://test-worker.azurecontainerapps.io".to_string()); + + let outputs = controller.build_outputs().unwrap(); + let worker_outputs = outputs.downcast_ref::().unwrap(); + let endpoint = worker_outputs + .public_endpoints + .get("default") + .expect("default public endpoint"); + + assert_eq!( + endpoint.url.as_str(), + "https://test-worker.azurecontainerapps.io" + ); + assert_eq!( + endpoint + .load_balancer_endpoint + .as_ref() + .map(|endpoint| endpoint.dns_name.as_str()), + Some("test-worker.azurecontainerapps.io") + ); +} + +#[test] +fn dns_target_is_ingress_host_when_url_is_overridden_to_public_fqdn() { + // Regression: when `url` is overridden to the public display FQDN (from `public_urls`), the + // CNAME target must still be the Container App ingress host. Otherwise the record name (the + // public FQDN) and the target collide into a self-referential CNAME, which the DNS provider + // rejects — the bug that deadlocked the Azure worker in `waitingForDns`. + let mut controller = AzureWorkerController::mock_ready("test-worker"); + controller.url = Some("https://test-worker.abc123.dev.vpc.direct".to_string()); + controller.container_app_url = + Some("https://test-worker.kindsky.eastus2.azurecontainerapps.io".to_string()); + + let outputs = controller.build_outputs().unwrap(); + let worker_outputs = outputs.downcast_ref::().unwrap(); + let endpoint = worker_outputs + .public_endpoints + .get("default") + .expect("default public endpoint"); + + // Display URL stays the public FQDN. + assert_eq!( + endpoint.url.as_str(), + "https://test-worker.abc123.dev.vpc.direct" + ); + // The CNAME target is the ingress host — and crucially NOT the record's own public FQDN. + let dns_name = endpoint + .load_balancer_endpoint + .as_ref() + .map(|endpoint| endpoint.dns_name.as_str()); + assert_eq!( + dns_name, + Some("test-worker.kindsky.eastus2.azurecontainerapps.io") + ); + assert_ne!(dns_name, Some("test-worker.abc123.dev.vpc.direct")); +} + +#[tokio::test] +async fn imported_worker_heartbeat_rebuilds_ingress_host_for_dns() { + // Regression for the create-path-only gap: an imported worker starts Ready with + // `container_app_url = None` and `url` = the public display FQDN (the importer skips the + // create flow). The heartbeat must rebuild `container_app_url` from the live Container App, + // so the DNS CNAME targets the ingress host rather than the self-referential public FQDN. + let app_name = "test-imported-worker"; + let mut mock = MockContainerAppsApi::new(); + mock.expect_get_container_app() + .returning(move |_, _| Ok(create_successful_container_app_response(app_name, true))) + .times(0..); + let mock_provider = setup_mock_service_provider(Arc::new(mock), None); + + // Imported shape: ingress host unset, url is the public display FQDN. + let mut controller = AzureWorkerController::mock_ready(app_name); + controller.container_app_url = None; + controller.url = Some("https://test-imported-worker.abc123.dev.vpc.direct".to_string()); + + let mut executor = SingleControllerExecutor::builder() + .resource(basic_function()) + .controller(controller) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + // The heartbeat rebuilt the ingress host… + assert_eq!( + controller.container_app_url.as_deref(), + Some("https://test-imported-worker.azurecontainerapps.io") + ); + // …so build_outputs targets it, NOT the public display FQDN. + let outputs = controller.build_outputs().unwrap(); + let worker_outputs = outputs.downcast_ref::().unwrap(); + let endpoint = worker_outputs + .public_endpoints + .get("default") + .expect("default public endpoint"); + let dns_name = endpoint + .load_balancer_endpoint + .as_ref() + .map(|endpoint| endpoint.dns_name.as_str()); + assert_eq!(dns_name, Some("test-imported-worker.azurecontainerapps.io")); + assert_ne!(dns_name, Some("test-imported-worker.abc123.dev.vpc.direct")); +} + +#[test] +fn detects_azure_authorization_propagation_error_from_http_context() { + let http_error = AlienError::new(CloudClientErrorData::HttpResponseError { + message: "Azure CreateOrUpdateDaprComponent failed: HTTP 403 Forbidden".to_string(), + url: "https://management.azure.com/test".to_string(), + http_status: 403, + http_request_text: None, + http_response_text: Some( + "{\"error\":{\"code\":\"AuthorizationFailed\",\"message\":\"The client does not have authorization to perform action. If access was recently granted, please refresh your credentials.\"}}" + .to_string(), + ), + }); + + let error = http_error.context(ErrorData::CloudPlatformError { + message: "Failed to create commands Dapr component".to_string(), + resource_id: Some("alien-rs-fn".to_string()), + }); + + assert!(is_azure_authorization_propagation_error(&error)); +} + +#[test] +fn ignores_non_authorization_cloud_platform_errors() { + let error = AlienError::new(ErrorData::CloudPlatformError { + message: "Failed to create commands Dapr component".to_string(), + resource_id: Some("alien-rs-fn".to_string()), + }); + + assert!(!is_azure_authorization_propagation_error(&error)); +} + +fn create_successful_container_app_response(app_name: &str, has_url: bool) -> ContainerApp { + let fqdn = if has_url { + Some(format!("{}.azurecontainerapps.io", app_name)) + } else { + None + }; + + let ingress = if has_url { + Some(alien_azure_clients::models::container_apps::Ingress { + external: true, + target_port: Some(8080), + fqdn: fqdn.clone(), + traffic: vec![alien_azure_clients::models::container_apps::TrafficWeight { + latest_revision: true, + weight: Some(100), + revision_name: None, + label: None, + }], + transport: alien_azure_clients::models::container_apps::IngressTransport::Auto, + allow_insecure: false, + additional_port_mappings: vec![], + custom_domains: vec![], + ip_security_restrictions: vec![], + cors_policy: None, + client_certificate_mode: None, + exposed_port: None, + sticky_sessions: None, + }) + } else { + None + }; + + ContainerApp { + id: Some(format!( + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", + app_name + )), + name: Some(app_name.to_string()), + location: "East US".to_string(), + properties: Some(ContainerAppProperties { + provisioning_state: Some(ContainerAppPropertiesProvisioningState::Succeeded), + configuration: Some(Configuration { + ingress, + active_revisions_mode: ConfigurationActiveRevisionsMode::Single, + identity_settings: vec![], + registries: vec![], + secrets: vec![], + dapr: None, + max_inactive_revisions: None, + runtime: None, + service: None, + }), + outbound_ip_addresses: vec![], + custom_domain_verification_id: None, + environment_id: None, + event_stream_endpoint: None, + latest_ready_revision_name: None, + latest_revision_fqdn: None, + latest_revision_name: None, + managed_environment_id: None, + running_status: None, + template: None, + workload_profile_name: None, + }), + tags: std::collections::HashMap::new(), + extended_location: None, + identity: None, + managed_by: None, + system_data: None, + type_: None, + } +} + +fn create_in_progress_container_app_response(app_name: &str) -> ContainerApp { + ContainerApp { + id: Some(format!( + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", + app_name + )), + name: Some(app_name.to_string()), + location: "East US".to_string(), + properties: Some(ContainerAppProperties { + provisioning_state: Some(ContainerAppPropertiesProvisioningState::InProgress), + outbound_ip_addresses: vec![], + custom_domain_verification_id: None, + environment_id: None, + event_stream_endpoint: None, + latest_ready_revision_name: None, + latest_revision_fqdn: None, + latest_revision_name: None, + managed_environment_id: None, + running_status: None, + template: None, + workload_profile_name: None, + configuration: None, + }), + tags: std::collections::HashMap::new(), + extended_location: None, + identity: None, + managed_by: None, + system_data: None, + type_: None, + } +} + +fn setup_mock_client_for_creation_and_update( + app_name: &str, + has_url: bool, +) -> Arc { + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Mock successful app creation - immediate completion + let app_name = app_name.to_string(); + let app_name_for_create = app_name.clone(); + mock_container_apps + .expect_create_or_update_container_app() + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name_for_create, has_url), + )) + }); + + // Mock successful updates - immediate completion + let app_name_for_update = app_name.clone(); + mock_container_apps + .expect_update_container_app() + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name_for_update, has_url), + )) + }); + + // Mock get operations - may be called multiple times during creation and update flows + let app_name_for_get = app_name.clone(); + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| { + Ok(create_successful_container_app_response( + &app_name_for_get, + has_url, + )) + }) + .times(0..); // Allow 0 or more calls + + Arc::new(mock_container_apps) +} + +fn setup_mock_client_for_creation_and_deletion( + app_name: &str, + has_url: bool, +) -> Arc { + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Mock successful app creation - immediate completion + let app_name = app_name.to_string(); + let app_name_for_create = app_name.clone(); + mock_container_apps + .expect_create_or_update_container_app() + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name_for_create, has_url), + )) + }); + + // Mock successful deletion - immediate completion + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + + // Mock get operations during creation (may be called multiple times) + let app_name_for_get_creation = app_name.clone(); + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| { + Ok(create_successful_container_app_response( + &app_name_for_get_creation, + has_url, + )) + }) + .times(0..); // Allow 0 or more calls during creation + + // Mock get operation failure for deletion verification + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }) + .times(0..); + + Arc::new(mock_container_apps) +} + +fn setup_mock_client_for_long_running_creation( + app_name: &str, + has_url: bool, +) -> (Arc, Arc) { + let mut mock_container_apps = MockContainerAppsApi::new(); + let mut mock_lro = MockLongRunningOperationApi::new(); + + // Mock creation that starts as long-running + // Use minimal retry_after for fast tests (actual Azure would use seconds) + let app_name = app_name.to_string(); + mock_container_apps + .expect_create_or_update_container_app() + .returning(|_, _, _| { + Ok(OperationResult::LongRunning(LongRunningOperation { + url: "https://management.azure.com/subscriptions/.../operations/test-op" + .to_string(), + retry_after: Some(Duration::from_millis(10)), + location_url: None, + })) + }); + + // Mock LRO polling - first incomplete, then complete + mock_lro + .expect_check_status() + .returning(|_, _, _| Ok(None)) // Still running + .times(1); + + mock_lro + .expect_check_status() + .returning(|_, _, _| Ok(Some("completed".to_string()))) // Completed + .times(1); + + // Mock get operations showing progression + let app_name_for_get1 = app_name.clone(); + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| { + Ok(create_in_progress_container_app_response( + &app_name_for_get1, + )) + }) + .times(1); + + let app_name_for_get2 = app_name.clone(); + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| { + Ok(create_successful_container_app_response( + &app_name_for_get2, + has_url, + )) + }); + + (Arc::new(mock_container_apps), Arc::new(mock_lro)) +} + +fn setup_mock_client_for_best_effort_deletion( + _app_name: &str, + app_missing: bool, +) -> Arc { + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Mock deletion (might fail if app missing) + if app_missing { + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }); + } else { + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + } + + // Always return not found for final status check + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }); + + Arc::new(mock_container_apps) +} + +fn setup_mock_service_provider( + mock_container_apps: Arc, + mock_lro: Option>, +) -> Arc { + let mut mock_provider = MockPlatformServiceProvider::new(); + + mock_provider + .expect_get_azure_container_apps_client() + .returning(move |_| Ok(mock_container_apps.clone())); + + if let Some(lro_client) = mock_lro { + mock_provider + .expect_get_azure_long_running_operation_client() + .returning(move |_| Ok(lro_client.clone())); + } + + // Mock Azure authorization client for resource-scoped permissions + mock_provider + .expect_get_azure_authorization_client() + .returning(|_| { + use alien_azure_clients::authorization::MockAuthorizationApi; + let mut mock_auth = MockAuthorizationApi::new(); + mock_auth + .expect_create_or_update_role_definition() + .returning(|_, _, role_def| Ok(role_def.clone())); + mock_auth + .expect_build_role_assignment_id() + .returning(|_, name| { + format!( + "/test/providers/Microsoft.Authorization/roleAssignments/{}", + name + ) + }); + mock_auth + .expect_create_or_update_role_assignment_by_id() + .returning(|_, role_assignment| Ok(role_assignment.clone())); + mock_auth + .expect_delete_role_assignment_by_id() + .returning(|_| Ok(None)); + Ok(Arc::new(mock_auth)) + }); + + Arc::new(mock_provider) +} + +/// Sets up mock Container Apps client and optional readiness probe mock server +/// Returns (container_apps_mock_provider, optional_mock_server) +fn setup_mocks_for_function( + worker: &Worker, + app_name: &str, + for_deletion: bool, +) -> (Arc, Option) { + let has_url = !worker.public_endpoints.is_empty(); + let needs_readiness_probe = has_url && worker.readiness_probe.is_some(); + + // Set up mock server for readiness probe if needed + let mock_server = if needs_readiness_probe { + Some(create_readiness_probe_mock(worker)) + } else { + None + }; + + // Set up Container Apps client mock - create custom response if we need to override URL + let container_apps_mock = if needs_readiness_probe && mock_server.is_some() { + // Create custom mock that returns the mock server URL + let mock_server_url = mock_server.as_ref().unwrap().base_url(); + setup_mock_client_with_custom_url(app_name, &mock_server_url, for_deletion) + } else if for_deletion { + setup_mock_client_for_creation_and_deletion(app_name, has_url) + } else { + setup_mock_client_for_creation_and_update(app_name, has_url) + }; + + let mock_provider = setup_mock_service_provider(container_apps_mock, None); + + (mock_provider, mock_server) +} + +fn setup_mock_client_with_custom_url( + app_name: &str, + custom_url: &str, + for_deletion: bool, +) -> Arc { + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Create a container app response with custom URL + let custom_response = create_container_app_with_custom_url(app_name, custom_url); + + // Mock successful app creation + let response_for_create = custom_response.clone(); + mock_container_apps + .expect_create_or_update_container_app() + .returning(move |_, _, _| Ok(OperationResult::Completed(response_for_create.clone()))); + + if for_deletion { + // Mock successful deletion + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + + // Mock get operations during creation (may be called multiple times) + let response_for_get_creation = custom_response.clone(); + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| Ok(response_for_get_creation.clone())) + .times(0..); + + // Mock get operation failure for deletion verification + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }) + .times(0..); + } else { + // Mock successful updates + let response_for_update = custom_response.clone(); + mock_container_apps + .expect_update_container_app() + .returning(move |_, _, _| Ok(OperationResult::Completed(response_for_update.clone()))); + + // Mock get operations (may be called multiple times) + let response_for_get = custom_response.clone(); + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| Ok(response_for_get.clone())) + .times(0..); + } + + Arc::new(mock_container_apps) +} + +fn create_container_app_with_custom_url(app_name: &str, custom_url: &str) -> ContainerApp { + // For tests, just extract the host and port from the URL string + let url_without_protocol = custom_url.strip_prefix("http://").unwrap_or(custom_url); + let (host, _port) = if let Some(colon_pos) = url_without_protocol.find(':') { + let host = &url_without_protocol[..colon_pos]; + let port_str = &url_without_protocol[colon_pos + 1..]; + let port = port_str.parse::().unwrap_or(80); + (host, Some(port)) + } else { + (url_without_protocol, None) + }; + + // Create FQDN that matches the custom URL + let _fqdn = if let Some(port) = _port { + format!("{}:{}", host, port) + } else { + host.to_string() + }; + + let ingress = Some(alien_azure_clients::models::container_apps::Ingress { + external: true, + target_port: Some(8080), + fqdn: Some(custom_url.to_string()), // Use the full URL as FQDN for the test + traffic: vec![alien_azure_clients::models::container_apps::TrafficWeight { + latest_revision: true, + weight: Some(100), + revision_name: None, + label: None, + }], + transport: alien_azure_clients::models::container_apps::IngressTransport::Auto, + allow_insecure: false, + additional_port_mappings: vec![], + custom_domains: vec![], + ip_security_restrictions: vec![], + cors_policy: None, + client_certificate_mode: None, + exposed_port: None, + sticky_sessions: None, + }); + + ContainerApp { + id: Some(format!( + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.App/containerApps/{}", + app_name + )), + name: Some(app_name.to_string()), + location: "East US".to_string(), + properties: Some(ContainerAppProperties { + provisioning_state: Some(ContainerAppPropertiesProvisioningState::Succeeded), + configuration: Some(Configuration { + ingress, + active_revisions_mode: ConfigurationActiveRevisionsMode::Single, + identity_settings: vec![], + registries: vec![], + secrets: vec![], + dapr: None, + max_inactive_revisions: None, + runtime: None, + service: None, + }), + outbound_ip_addresses: vec![], + custom_domain_verification_id: None, + environment_id: None, + event_stream_endpoint: None, + latest_ready_revision_name: None, + latest_revision_fqdn: None, + latest_revision_name: None, + managed_environment_id: None, + running_status: None, + template: None, + workload_profile_name: None, + }), + tags: std::collections::HashMap::new(), + extended_location: None, + identity: None, + managed_by: None, + system_data: None, + type_: None, + } +} + +async fn executor_for_wait_state(controller: AzureWorkerController) -> SingleControllerExecutor { + SingleControllerExecutor::builder() + .resource(basic_function()) + .controller(controller) + .platform(Platform::Azure) + .service_provider(Arc::new(MockPlatformServiceProvider::new())) + .with_test_dependencies() + .build() + .await + .unwrap() +} + +#[tokio::test] +async fn test_pre_container_app_rbac_wait_holds_state_when_woken_early() { + let deadline = current_unix_timestamp_secs().saturating_add(60); + let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); + controller.state = AzureWorkerState::WaitingBeforeContainerAppCreation; + controller.pre_container_app_rbac_wait_until_epoch_secs = Some(deadline); + + let mut executor = executor_for_wait_state(controller).await; + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Provisioning); + assert_eq!( + controller.state, + AzureWorkerState::WaitingBeforeContainerAppCreation + ); + assert_eq!( + step_result.suggested_delay, + Some(Duration::from_secs(AZURE_RBAC_WAIT_POLL_SECS)) + ); + assert_eq!( + controller.pre_container_app_rbac_wait_until_epoch_secs, + Some(deadline) + ); +} + +#[tokio::test] +async fn test_ready_rbac_wait_holds_state_when_woken_early() { + let deadline = current_unix_timestamp_secs().saturating_add(60); + let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); + controller.state = AzureWorkerState::WaitingForRbacPropagation; + controller.ready_rbac_wait_until_epoch_secs = Some(deadline); + + let mut executor = executor_for_wait_state(controller).await; + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Provisioning); + assert_eq!( + controller.state, + AzureWorkerState::WaitingForRbacPropagation + ); + assert_eq!( + step_result.suggested_delay, + Some(Duration::from_secs(AZURE_RBAC_WAIT_POLL_SECS)) + ); + assert_eq!(controller.ready_rbac_wait_until_epoch_secs, Some(deadline)); +} + +#[tokio::test] +async fn test_ready_rbac_wait_advances_after_deadline() { + let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); + controller.state = AzureWorkerState::WaitingForRbacPropagation; + controller.ready_rbac_wait_until_epoch_secs = + Some(current_unix_timestamp_secs().saturating_sub(1)); + + let mut executor = executor_for_wait_state(controller).await; + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Provisioning); + assert_eq!(controller.state, AzureWorkerState::RunningReadinessProbe); + assert_eq!(step_result.suggested_delay, None); + assert_eq!(controller.ready_rbac_wait_until_epoch_secs, None); + + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Running); + assert_eq!(controller.state, AzureWorkerState::Ready); + assert_eq!(step_result.suggested_delay, None); +} + +#[tokio::test] +async fn test_update_rbac_wait_holds_and_clears() { + let deadline = current_unix_timestamp_secs().saturating_add(60); + let mut controller = AzureWorkerController::mock_ready("test-basic-worker"); + controller.state = AzureWorkerState::UpdateWaitingForRbacPropagation; + controller.ready_rbac_wait_until_epoch_secs = Some(deadline); + controller.update_rbac_wait_required = true; + + let mut executor = executor_for_wait_state(controller).await; + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Updating); + assert_eq!( + controller.state, + AzureWorkerState::UpdateWaitingForRbacPropagation + ); + assert_eq!( + step_result.suggested_delay, + Some(Duration::from_secs(AZURE_RBAC_WAIT_POLL_SECS)) + ); + assert_eq!(controller.ready_rbac_wait_until_epoch_secs, Some(deadline)); + assert!(controller.update_rbac_wait_required); + + let mut controller = controller.clone(); + controller.ready_rbac_wait_until_epoch_secs = + Some(current_unix_timestamp_secs().saturating_sub(1)); + let mut executor = executor_for_wait_state(controller).await; + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Updating); + assert_eq!( + controller.state, + AzureWorkerState::UpdateRunningReadinessProbe + ); + assert_eq!(step_result.suggested_delay, None); + assert_eq!(controller.ready_rbac_wait_until_epoch_secs, None); + assert!(!controller.update_rbac_wait_required); + + let step_result = executor.step().await.unwrap(); + let controller = executor.internal_state::().unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Running); + assert_eq!(controller.state, AzureWorkerState::Ready); + assert_eq!(step_result.suggested_delay, None); +} + +// ─────────────── CREATE AND DELETE FLOW TESTS ──────────────────── + +#[rstest] +#[case::basic(basic_function())] +#[case::env_vars(function_with_env_vars())] +#[case::storage_link(function_with_storage_link())] +#[case::env_and_storage(function_with_env_and_storage())] +#[case::multiple_storages(function_with_multiple_storages())] +#[case::public_ingress(function_public_ingress())] +#[case::private_ingress(function_private_ingress())] +#[case::concurrency(function_with_concurrency())] +#[case::custom_config(function_custom_config())] +#[case::readiness_probe(function_with_readiness_probe())] +#[case::complete_test(function_complete_test())] +#[tokio::test] +async fn test_create_and_delete_flow_succeeds(#[case] worker: Worker) { + let app_name = format!("test-{}", worker.id); + let (mock_provider, _mock_server) = setup_mocks_for_function(&worker, &app_name, true); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AzureWorkerController::default()) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Run create flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify outputs are available + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.identifier.is_some()); + assert!(function_outputs.worker_name.starts_with("test-")); + + // Delete the worker + executor.delete().unwrap(); + + // Run delete flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} + +// ─────────────── UPDATE FLOW TESTS ──────────────────────────────── + +#[rstest] +#[case::basic_to_env(basic_function(), function_with_env_vars())] +#[case::env_to_storage(function_with_env_vars(), function_with_storage_link())] +#[case::storage_to_custom(function_with_storage_link(), function_custom_config())] +#[case::custom_to_public(function_custom_config(), function_public_ingress())] +#[case::public_to_complete(function_public_ingress(), function_complete_test())] +#[case::complete_to_basic(function_complete_test(), basic_function())] +#[tokio::test] +async fn test_update_flow_succeeds(#[case] from_function: Worker, #[case] to_function: Worker) { + // Ensure both workers have the same ID for valid updates + let worker_id = "test-update-worker".to_string(); + let mut from_function = from_function; + from_function.id = worker_id.clone(); + + let mut to_function = to_function; + to_function.id = worker_id.clone(); + + let app_name = format!("test-{}", worker_id); + let (mock_provider, mock_server) = setup_mocks_for_function(&to_function, &app_name, false); + + // Start with the "from" worker in Ready state + let mut ready_controller = AzureWorkerController::mock_ready(&app_name); + + // If the target worker has a readiness probe, update the controller URL to point to mock server + if to_function.readiness_probe.is_some() && !to_function.public_endpoints.is_empty() { + if let Some(ref server) = mock_server { + ready_controller.url = Some(server.base_url()); + } + } else if !to_function.public_endpoints.is_empty() { + // Ensure the controller has a URL for public workers + ready_controller.url = Some(format!("https://{}.azurecontainerapps.io", app_name)); + } + + let mut executor = SingleControllerExecutor::builder() + .resource(from_function) + .controller(ready_controller) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Ensure we start in Running state + assert_eq!(executor.status(), ResourceStatus::Running); + + // Update to the new worker + executor.update(to_function).unwrap(); + + // Run the update flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +// ─────────────── BEST EFFORT DELETION TESTS ─────────────────────── + +#[rstest] +#[case::basic(basic_function(), false)] +#[case::public_with_missing_app(function_public_ingress(), true)] +#[case::private_with_missing_app(function_private_ingress(), true)] +#[tokio::test] +async fn test_best_effort_deletion_when_resources_missing( + #[case] worker: Worker, + #[case] app_missing: bool, +) { + let app_name = format!("test-{}", worker.id); + let mock_container_apps = setup_mock_client_for_best_effort_deletion(&app_name, app_missing); + let mock_provider = setup_mock_service_provider(mock_container_apps, None); + + // Start with a ready controller + let mut ready_controller = AzureWorkerController::mock_ready(&app_name); + if !worker.public_endpoints.is_empty() { + ready_controller.url = Some(format!("https://{}.azurecontainerapps.io", app_name)); + } + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(ready_controller) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Ensure we start in Running state + assert_eq!(executor.status(), ResourceStatus::Running); + + // Delete the worker + executor.delete().unwrap(); + + // Run the delete flow - it should succeed even when resources are missing + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} + +// ─────────────── LONG RUNNING OPERATION TESTS ────────────────────── + +#[tokio::test] +async fn test_long_running_creation_operation() { + let worker = basic_function(); + let app_name = format!("test-{}", worker.id); + let (mock_container_apps, mock_lro) = + setup_mock_client_for_long_running_creation(&app_name, false); + let mock_provider = setup_mock_service_provider(mock_container_apps, Some(mock_lro)); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AzureWorkerController::default()) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Run create flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify the controller went through LRO states + let controller = executor.internal_state::().unwrap(); + assert!(controller.container_app_name.is_some()); + assert!(controller.resource_id.is_some()); +} + +// ─────────────── SPECIFIC VALIDATION TESTS ───────────────── + +/// Test that verifies public workers get URL in outputs +#[tokio::test] +async fn test_public_function_gets_url_in_outputs() { + let worker = function_public_ingress(); + let app_name = format!("test-{}", worker.id); + + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Mock creation with URL + mock_container_apps + .expect_create_or_update_container_app() + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name, true), + )) + }); + + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AzureWorkerController::default()) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify URL is in outputs + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + let endpoint = function_outputs + .public_endpoints + .get("default") + .expect("default public endpoint"); + assert!(endpoint.url.contains("azurecontainerapps.io")); +} + +/// Test that verifies private workers don't get URL in outputs +#[tokio::test] +async fn test_private_function_has_no_url_in_outputs() { + let worker = function_private_ingress(); + let app_name = format!("test-{}", worker.id); + + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Mock creation without URL + mock_container_apps + .expect_create_or_update_container_app() + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name, false), + )) + }); + + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AzureWorkerController::default()) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify no URL in outputs + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.public_endpoints.is_empty()); +} + +/// Test that verifies correct container app configuration parameters +#[tokio::test] +async fn test_container_app_configuration_validation() { + let worker = function_custom_config(); + let app_name = format!("test-{}", worker.id); + + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Validate container app creation request has correct parameters + let app_name_for_response = app_name.clone(); + mock_container_apps + .expect_create_or_update_container_app() + .withf(|_rg, _name, container_app| { + // Check that the container has correct resource configuration + if let Some(properties) = &container_app.properties { + if let Some(template) = &properties.template { + if let Some(container) = template.containers.first() { + if let Some(resources) = &container.resources { + // function_custom_config has 512MB memory + let expected_memory = format!("{}Gi", 512.0 / 1024.0); + return resources.memory.as_ref() == Some(&expected_memory) + && resources.cpu == Some(0.25); + } + } + } + } + false + }) + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name_for_response, false), + )) + }); + + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + + // Allow get_container_app calls during creation (may be called 0 or more times) + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| Ok(create_successful_container_app_response(&app_name, false))) + .times(0..); + + // Mock get operation failure for deletion verification + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }) + .times(0..); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AzureWorkerController::default()) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +/// Test that verifies environment variables are correctly passed +#[tokio::test] +async fn test_environment_variable_handling() { + let worker = function_with_env_vars(); + let app_name = format!("test-{}", worker.id); + + let mut mock_container_apps = MockContainerAppsApi::new(); + + // Validate container app creation request has environment variables + let app_name_for_response = app_name.clone(); + mock_container_apps + .expect_create_or_update_container_app() + .withf(|_rg, _name, container_app| { + if let Some(properties) = &container_app.properties { + if let Some(template) = &properties.template { + if let Some(container) = template.containers.first() { + // Check that environment variables are present + let has_app_env = container.env.iter().any(|env_var| { + env_var.name.as_deref() == Some("APP_ENV") + && env_var.value.as_deref() == Some("production") + }); + let has_log_level = container.env.iter().any(|env_var| { + env_var.name.as_deref() == Some("LOG_LEVEL") + && env_var.value.as_deref() == Some("debug") + }); + let has_db_name = container.env.iter().any(|env_var| { + env_var.name.as_deref() == Some("DB_NAME") + && env_var.value.as_deref() == Some("myapp") + }); + return has_app_env && has_log_level && has_db_name; + } + } + } + false + }) + .returning(move |_, _, _| { + Ok(OperationResult::Completed( + create_successful_container_app_response(&app_name_for_response, false), + )) + }); + + mock_container_apps + .expect_delete_container_app() + .returning(|_, _| Ok(OperationResult::Completed(()))); + + // Allow get_container_app calls during creation (may be called 0 or more times) + mock_container_apps + .expect_get_container_app() + .returning(move |_, _| Ok(create_successful_container_app_response(&app_name, false))) + .times(0..); + + // Mock get operation failure for deletion verification + mock_container_apps + .expect_get_container_app() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "ContainerApp".to_string(), + resource_name: "test-app".to_string(), + }, + )) + }) + .times(0..); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_container_apps), None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AzureWorkerController::default()) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +/// Test that verifies deletion works when container_app_name is not set (early creation failure) +#[tokio::test] +async fn test_delete_with_no_container_app_name_succeeds() { + let worker = basic_function(); + + // Create a controller with no container app name set (simulating early creation failure) + let controller = AzureWorkerController { + state: AzureWorkerState::CreateFailed, + container_app_name: None, // This is the key - no container app name set + resource_id: None, + url: None, + container_app_url: None, + pending_operation_url: None, + pending_operation_retry_after: None, + dapr_components: Vec::new(), + storage_trigger_infrastructure: Vec::new(), + fqdn: None, + certificate_id: None, + keyvault_cert_id: None, + container_apps_certificate_id: None, + uses_custom_domain: false, + certificate_issued_at: None, + commands_namespace_name: None, + commands_queue_name: None, + commands_dapr_component: None, + commands_sender_role_assignment_id: None, + commands_receiver_role_assignment_id: None, + commands_infrastructure_auth_wait_until_epoch_secs: None, + container_apps_environment_wake_wait_until_epoch_secs: None, + container_apps_environment_wake_retry_after_epoch_secs: None, + pre_container_app_rbac_wait_until_epoch_secs: None, + ready_rbac_wait_until_epoch_secs: None, + update_rbac_wait_required: false, + update_dapr_components_deleted: false, + _internal_stay_count: None, + }; + + // Mock provider - no expectations since no API calls should be made + let mock_provider = Arc::new(MockPlatformServiceProvider::new()); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(controller) + .platform(Platform::Azure) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Start in CreateFailed state + assert_eq!(executor.status(), ResourceStatus::ProvisionFailed); + + // Delete the worker + executor.delete().unwrap(); + + // Run the delete flow - should succeed without making any API calls + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} From ab10c0f47618da4213ca8acd57ec893ab3aa4ff2 Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 15:43:15 +0900 Subject: [PATCH 04/18] refactor: split aws worker controller into modules Break the 5,000-line worker/aws.rs into a directory module so each concern can be read and reviewed in isolation: - aws/mod.rs: controller struct and the #[controller] handler impl (kept whole for macro state generation) - aws/support.rs: naming helpers, error classifiers, heartbeat emission, and the load balancer state types - aws/helpers.rs: the plain helper-method impl blocks - aws/tests.rs: the controller test module Pure code motion: moved items are bumped to pub(super) where needed; external paths are unchanged via the existing worker::aws re-exports. --- crates/alien-infra/src/worker/aws/helpers.rs | 404 +++++ .../src/worker/{aws.rs => aws/mod.rs} | 1554 +---------------- crates/alien-infra/src/worker/aws/support.rs | 140 ++ crates/alien-infra/src/worker/aws/tests.rs | 1004 +++++++++++ 4 files changed, 1559 insertions(+), 1543 deletions(-) create mode 100644 crates/alien-infra/src/worker/aws/helpers.rs rename crates/alien-infra/src/worker/{aws.rs => aws/mod.rs} (69%) create mode 100644 crates/alien-infra/src/worker/aws/support.rs create mode 100644 crates/alien-infra/src/worker/aws/tests.rs diff --git a/crates/alien-infra/src/worker/aws/helpers.rs b/crates/alien-infra/src/worker/aws/helpers.rs new file mode 100644 index 000000000..5fc456cf3 --- /dev/null +++ b/crates/alien-infra/src/worker/aws/helpers.rs @@ -0,0 +1,404 @@ +use super::*; + +impl AwsWorkerController { + pub(super) fn should_wait_for_lambda_vpc_enis(ctx: &ResourceControllerContext<'_>) -> bool { + matches!( + ctx.deployment_config.stack_settings.network, + Some(NetworkSettings::Create { .. }) + ) + } + + pub(super) fn resolve_domain_info( + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + ) -> Result> { + let stack_settings = &ctx.deployment_config.stack_settings; + if let Some(custom) = stack_settings + .domains + .as_ref() + .and_then(|domains| domains.custom_domains.as_ref()) + .and_then(|domains| domains.get(resource_id)) + { + let cert_arn = custom + .certificate + .aws + .as_ref() + .map(|cert| cert.certificate_arn.clone()) + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Custom domain requires an AWS certificate ARN".to_string(), + resource_id: Some(resource_id.to_string()), + }) + })?; + + return Ok(Some(DomainInfo { + fqdn: custom.domain.clone(), + certificate_id: None, + certificate_arn: Some(cert_arn), + uses_custom_domain: true, + })); + } + + let Some(resource) = ctx + .deployment_config + .domain_metadata + .as_ref() + .and_then(|metadata| metadata.resources.get(resource_id)) + else { + return Ok(None); + }; + + Ok(Some(DomainInfo { + fqdn: resource.fqdn.clone(), + certificate_id: Some(resource.certificate_id.clone()), + certificate_arn: None, + uses_custom_domain: false, + })) + } + + pub(super) fn ensure_domain_info( + &mut self, + ctx: &ResourceControllerContext<'_>, + resource_id: &str, + ) -> Result { + if self.fqdn.is_some() + && self.domain_name.is_some() + && (self.certificate_id.is_some() + || self.certificate_arn.is_some() + || self.uses_custom_domain) + { + return Ok(true); + } + + match Self::resolve_domain_info(ctx, resource_id)? { + Some(domain_info) => { + self.fqdn = Some(domain_info.fqdn.clone()); + self.domain_name = Some(domain_info.fqdn.clone()); + self.certificate_id = domain_info.certificate_id; + self.certificate_arn = domain_info.certificate_arn; + self.uses_custom_domain = domain_info.uses_custom_domain; + if self.url.is_none() { + self.url = ctx + .deployment_config + .public_endpoints + .as_ref() + .and_then(|resources| resources.get(resource_id)) + .and_then(|endpoints| endpoints.values().next().cloned()) + .or_else(|| Some(format!("https://{}", domain_info.fqdn))); + } + Ok(true) + } + None => Ok(false), + } + } + + pub(super) fn unexpected_update_wrapper_state( + resource_id: &str, + handler: &str, + state: AwsWorkerState, + ) -> AlienError { + AlienError::new(ErrorData::ResourceControllerConfigError { + resource_id: resource_id.to_string(), + message: format!("{handler} returned unexpected state during update: {state:?}"), + }) + } +} + +// Separate impl block for helper methods +impl AwsWorkerController { + /// Rewrite an ECR image URI to use the given region if it points to a different one. + /// + /// Lambda requires container images in the same region as the worker. + /// When the management account's ECR is in a different region and private + /// image replication copies images to the target region, the image URI must + /// reference the replicated copy. + /// + /// Only rewrites URIs matching the ECR format: `{account}.dkr.ecr.{region}.amazonaws.com/...` + pub(super) fn rewrite_ecr_region_if_needed(image_uri: &str, target_region: &str) -> String { + // ECR URI format: {account_id}.dkr.ecr.{region}.amazonaws.com/{repo}:{tag} + let Some(host_end) = image_uri.find('/') else { + return image_uri.to_string(); + }; + let host = &image_uri[..host_end]; + let parts: Vec<&str> = host.split('.').collect(); + // parts: [account_id, "dkr", "ecr", region, "amazonaws", "com"] + if parts.len() >= 6 + && parts[1] == "dkr" + && parts[2] == "ecr" + && parts[4] == "amazonaws" + && parts[3] != target_region + { + let new_host = format!("{}.dkr.ecr.{}.amazonaws.com", parts[0], target_region); + format!("{}{}", new_host, &image_uri[host_end..]) + } else { + image_uri.to_string() + } + } + + /// Creates an SQS event source mapping for a queue trigger + pub(super) async fn create_queue_event_source_mapping( + &mut self, + ctx: &ResourceControllerContext<'_>, + aws_cfg: &alien_aws_clients::AwsClientConfig, + worker_config: &alien_core::Worker, + queue_ref: &alien_core::ResourceRef, + ) -> Result<()> { + let lambda_client = ctx.service_provider.get_aws_lambda_client(aws_cfg).await?; + + // Get queue controller to access outputs + let queue_controller = + ctx.require_dependency::(queue_ref)?; + let queue_outputs_wrapper = queue_controller.get_outputs().ok_or_else(|| { + AlienError::new(ErrorData::DependencyNotReady { + resource_id: worker_config.id.clone(), + dependency_id: queue_ref.id.clone(), + }) + })?; + let queue_outputs = queue_outputs_wrapper + .downcast_ref::() + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Invalid queue outputs type".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + // Extract queue name from the queue URL + let queue_name = if let Some(url) = &queue_outputs.identifier { + // SQS URL format: https://sqs.region.amazonaws.com/account-id/queue-name + url.split('/') + .last() + .unwrap_or(&queue_outputs.queue_name) + .to_string() + } else { + queue_outputs.queue_name.clone() + }; + + // Construct SQS queue ARN: arn:aws:sqs:region:account-id:queue-name + let queue_arn = format!( + "arn:aws:sqs:{}:{}:{}", + aws_cfg.region, aws_cfg.account_id, queue_name + ); + + info!( + worker=%worker_config.id, + queue_arn=%queue_arn, + "Creating SQS event source mapping" + ); + + let worker_name = self.arn.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Worker ARN not available for event source mapping".to_string(), + resource_id: Some(worker_config.id.clone()), + }) + })?; + + let existing_mappings = lambda_client + .list_event_source_mappings(ListEventSourceMappingsRequest { + event_source_arn: Some(queue_arn.clone()), + function_name: Some(worker_name.clone()), + marker: None, + max_items: None, + }) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to list event source mappings for queue '{}'", + queue_name + ), + resource_id: Some(worker_config.id.clone()), + })?; + + if let Some(existing_mapping) = existing_mappings + .event_source_mappings + .unwrap_or_default() + .into_iter() + .find(|mapping| { + mapping.event_source_arn.as_deref() == Some(queue_arn.as_str()) + && mapping.function_arn.as_deref() == Some(worker_name.as_str()) + }) + { + if let Some(uuid) = existing_mapping.uuid { + if !self.event_source_mappings.contains(&uuid) { + self.event_source_mappings.push(uuid.clone()); + } + info!( + worker=%worker_config.id, + queue_arn=%queue_arn, + uuid=%uuid, + "SQS event source mapping already exists; treating as created" + ); + return Ok(()); + } + } + + let request = alien_aws_clients::lambda::CreateEventSourceMappingRequest::builder() + .event_source_arn(queue_arn.clone()) + .function_name(worker_name.clone()) + .batch_size(1) // Always 1 message per invocation as per design + .enabled(true) + .build(); + + let response = match lambda_client.create_event_source_mapping(request).await { + Ok(response) => response, + Err(e) if is_remote_resource_conflict(&e) => { + let existing_mappings = lambda_client + .list_event_source_mappings(ListEventSourceMappingsRequest { + event_source_arn: Some(queue_arn.clone()), + function_name: Some(worker_name.clone()), + marker: None, + max_items: None, + }) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to list event source mappings for queue '{}' after conflict", + queue_name + ), + resource_id: Some(worker_config.id.clone()), + })?; + + existing_mappings + .event_source_mappings + .unwrap_or_default() + .into_iter() + .find(|mapping| { + mapping.event_source_arn.as_deref() == Some(queue_arn.as_str()) + && mapping.function_arn.as_deref() == Some(worker_name.as_str()) + }) + .ok_or_else(|| { + AlienError::new(ErrorData::CloudPlatformError { + message: format!( + "Event source mapping for queue '{}' already exists but could not be found", + queue_name + ), + resource_id: Some(worker_config.id.clone()), + }) + })? + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create event source mapping for queue '{}'", + queue_name + ), + resource_id: Some(worker_config.id.clone()), + })); + } + }; + + if let Some(uuid) = response.uuid { + if !self.event_source_mappings.contains(&uuid) { + self.event_source_mappings.push(uuid.clone()); + } + info!( + worker=%worker_config.id, + queue_arn=%queue_arn, + uuid=%uuid, + "Successfully created SQS event source mapping" + ); + } + + Ok(()) + } + + // ─────────────── HELPER METHODS ──────────────────────────── + + /// Gets VPC configuration from the Network resource if one exists in the stack. + /// + /// If a Network resource exists (ID: "default-network"), this method retrieves + /// the VPC ID, subnet IDs, and security group ID from the Network controller + /// to configure the Lambda worker to run inside the VPC. + /// + /// Returns `None` if no Network resource exists in the stack. + pub(super) fn get_vpc_config( + &self, + ctx: &ResourceControllerContext<'_>, + ) -> Result> { + // Check if the stack has a Network resource + let network_id = "default-network"; + if !ctx.desired_stack.resources.contains_key(network_id) { + return Ok(None); + } + + // Get the Network controller state via require_dependency + let network_ref = ResourceRef::new(Network::RESOURCE_TYPE, network_id.to_string()); + let network_state = + ctx.require_dependency::(&network_ref)?; + + // Only configure VPC if we have subnet IDs and a security group + // For Lambda, we use private subnets (no public IP assignment) + if network_state.private_subnet_ids.is_empty() { + return Ok(None); + } + + let security_group_ids = match &network_state.security_group_id { + Some(sg) => vec![sg.clone()], + None => vec![], + }; + + if security_group_ids.is_empty() { + return Ok(None); + } + + Ok(Some( + VpcConfig::builder() + .subnet_ids(network_state.private_subnet_ids.clone()) + .security_group_ids(security_group_ids) + .build(), + )) + } + + pub(super) async fn prepare_environment_variables( + &self, + initial_env: &HashMap, + links: &[ResourceRef], + ctx: &ResourceControllerContext<'_>, + worker_name_for_error_logging: &str, + ) -> Result> { + let worker_config = ctx.desired_resource_config::()?; + + // Get the worker's own binding params (may be None during initial creation) + let self_binding_params = self.get_binding_params()?; + + let env_vars = EnvironmentVariableBuilder::try_new(initial_env)? + .add_worker_runtime_env_vars(ctx, &worker_config.id, worker_config.timeout_seconds)? + .add_linked_resources(links, ctx, worker_name_for_error_logging) + .await? + .add_self_worker_binding(&worker_config.id, self_binding_params.as_ref())? + .build(); + + Ok(env_vars) + } + + /// Creates a controller in a ready state with mock values for testing purposes. + #[cfg(feature = "test-utils")] + pub fn mock_ready(worker_name: &str) -> Self { + Self { + state: AwsWorkerState::Ready, + arn: Some(format!( + "arn:aws:lambda:us-east-1:123456789012:function:{}", + worker_name + )), + url: Some(format!("https://abcd1234.lambda-url.us-east-1.on.aws/")), + worker_name: Some(worker_name.to_string()), + event_source_mappings: Vec::new(), + fqdn: None, + certificate_id: None, + certificate_arn: None, + api_id: None, + integration_id: None, + route_id: None, + stage_name: None, + api_mapping_id: None, + domain_name: None, + load_balancer: None, + uses_custom_domain: false, + certificate_issued_at: None, + s3_permission_statement_ids: Vec::new(), + eventbridge_rule_names: Vec::new(), + eventbridge_permission_statement_ids: Vec::new(), + _internal_stay_count: None, + } + } +} diff --git a/crates/alien-infra/src/worker/aws.rs b/crates/alien-infra/src/worker/aws/mod.rs similarity index 69% rename from crates/alien-infra/src/worker/aws.rs rename to crates/alien-infra/src/worker/aws/mod.rs index 0a04619fb..8e223a173 100644 --- a/crates/alien-infra/src/worker/aws.rs +++ b/crates/alien-infra/src/worker/aws/mod.rs @@ -1,4 +1,3 @@ -use serde::{Deserialize, Serialize}; use std::{collections::HashMap, time::Duration}; use tracing::{debug, info, warn}; @@ -9,190 +8,36 @@ use crate::core::ResourceController; use crate::core::ResourceControllerContext; use crate::error::{ErrorData, Result}; use crate::worker::readiness_probe::{ - run_readiness_probe_with_dns_override, ReadinessProbeDnsOverride, READINESS_PROBE_MAX_ATTEMPTS, + run_readiness_probe_with_dns_override, READINESS_PROBE_MAX_ATTEMPTS, }; use alien_aws_clients::apigatewayv2::{ CreateApiMappingRequest, CreateApiRequest, CreateDomainNameRequest, CreateIntegrationRequest, CreateRouteRequest, CreateStageRequest, DomainNameConfiguration, }; use alien_aws_clients::ec2::{DescribeNetworkInterfacesRequest, Filter}; -use alien_aws_clients::eventbridge::{ - EventBridgeTag, EventBridgeTarget, PutRuleRequest, PutTargetsRequest, -}; +use alien_aws_clients::eventbridge::{EventBridgeTarget, PutRuleRequest, PutTargetsRequest}; use alien_aws_clients::lambda::{ - AddPermissionRequest, CreateFunctionRequest, Environment, FunctionCode, FunctionConfiguration, + AddPermissionRequest, CreateFunctionRequest, Environment, FunctionCode, ListEventSourceMappingsRequest, UpdateFunctionCodeRequest, UpdateFunctionConfigurationRequest, VpcConfig, }; use alien_aws_clients::s3::{LambdaFunctionConfiguration, NotificationConfiguration}; use alien_client_core::ErrorData as CloudClientErrorData; use alien_core::{ - standard_resource_tags, AwsLambdaWorkerHeartbeatData, CertificateStatus, DnsRecordStatus, - HeartbeatBackend, Network, NetworkSettings, ObservedHealth, Platform, ProviderLifecycleState, - ResourceDefinition, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs, ResourceRef, - ResourceStatus, Worker, WorkerHeartbeatData, WorkerOutputs, WorkloadHeartbeatStatus, + standard_resource_tags, CertificateStatus, DnsRecordStatus, Network, NetworkSettings, + ResourceDefinition, ResourceOutputs, ResourceRef, ResourceStatus, Worker, WorkerOutputs, }; use alien_error::{AlienError, Context, ContextError, IntoAlienError}; use alien_macros::controller; -use chrono::Utc; - -/// Generates the full, prefixed AWS resource name. -fn get_aws_worker_name(prefix: &str, name: &str) -> String { - format!("{}-{}", prefix, name) -} - -fn readiness_probe_dns_override( - url: &str, - fqdn: Option<&str>, - load_balancer: Option<&LoadBalancerState>, -) -> Option { - let fqdn = fqdn?; - let endpoint = load_balancer?.endpoint.as_ref()?; - let parsed = reqwest::Url::parse(url).ok()?; - let url_host = parsed.host_str()?; - - if url_host != fqdn { - return None; - } - - Some(ReadinessProbeDnsOverride { - host: fqdn.to_string(), - target_dns_name: endpoint.dns_name.clone(), - port: parsed.port_or_known_default().unwrap_or(443), - }) -} - -fn eventbridge_tags(prefix: &str, resource_id: &str) -> Vec { - standard_resource_tags(prefix, resource_id) - .into_iter() - .map(|(key, value)| EventBridgeTag { key, value }) - .collect() -} - -fn is_remote_resource_conflict(error: &AlienError) -> bool { - matches!( - &error.error, - Some(CloudClientErrorData::RemoteResourceConflict { .. }) - ) -} - -fn replace_lambda_notification_config( - notification_config: &mut NotificationConfiguration, - replacement: LambdaFunctionConfiguration, -) { - if let Some(replacement_id) = replacement.id.as_ref() { - notification_config - .lambda_function_configurations - .retain(|config| config.id.as_ref() != Some(replacement_id)); - } - notification_config - .lambda_function_configurations - .push(replacement); -} - -impl AwsWorkerController { - fn should_wait_for_lambda_vpc_enis(ctx: &ResourceControllerContext<'_>) -> bool { - matches!( - ctx.deployment_config.stack_settings.network, - Some(NetworkSettings::Create { .. }) - ) - } - - fn resolve_domain_info( - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - ) -> Result> { - let stack_settings = &ctx.deployment_config.stack_settings; - if let Some(custom) = stack_settings - .domains - .as_ref() - .and_then(|domains| domains.custom_domains.as_ref()) - .and_then(|domains| domains.get(resource_id)) - { - let cert_arn = custom - .certificate - .aws - .as_ref() - .map(|cert| cert.certificate_arn.clone()) - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Custom domain requires an AWS certificate ARN".to_string(), - resource_id: Some(resource_id.to_string()), - }) - })?; - - return Ok(Some(DomainInfo { - fqdn: custom.domain.clone(), - certificate_id: None, - certificate_arn: Some(cert_arn), - uses_custom_domain: true, - })); - } - let Some(resource) = ctx - .deployment_config - .domain_metadata - .as_ref() - .and_then(|metadata| metadata.resources.get(resource_id)) - else { - return Ok(None); - }; +mod helpers; +mod support; +#[cfg(test)] +mod tests; - Ok(Some(DomainInfo { - fqdn: resource.fqdn.clone(), - certificate_id: Some(resource.certificate_id.clone()), - certificate_arn: None, - uses_custom_domain: false, - })) - } +use support::*; - fn ensure_domain_info( - &mut self, - ctx: &ResourceControllerContext<'_>, - resource_id: &str, - ) -> Result { - if self.fqdn.is_some() - && self.domain_name.is_some() - && (self.certificate_id.is_some() - || self.certificate_arn.is_some() - || self.uses_custom_domain) - { - return Ok(true); - } - - match Self::resolve_domain_info(ctx, resource_id)? { - Some(domain_info) => { - self.fqdn = Some(domain_info.fqdn.clone()); - self.domain_name = Some(domain_info.fqdn.clone()); - self.certificate_id = domain_info.certificate_id; - self.certificate_arn = domain_info.certificate_arn; - self.uses_custom_domain = domain_info.uses_custom_domain; - if self.url.is_none() { - self.url = ctx - .deployment_config - .public_endpoints - .as_ref() - .and_then(|resources| resources.get(resource_id)) - .and_then(|endpoints| endpoints.values().next().cloned()) - .or_else(|| Some(format!("https://{}", domain_info.fqdn))); - } - Ok(true) - } - None => Ok(false), - } - } - - fn unexpected_update_wrapper_state( - resource_id: &str, - handler: &str, - state: AwsWorkerState, - ) -> AlienError { - AlienError::new(ErrorData::ResourceControllerConfigError { - resource_id: resource_id.to_string(), - message: format!("{handler} returned unexpected state during update: {state:?}"), - }) - } -} +pub use support::{LoadBalancerEndpoint, LoadBalancerState}; #[controller] pub struct AwsWorkerController { @@ -234,77 +79,6 @@ pub struct AwsWorkerController { pub(crate) eventbridge_permission_statement_ids: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LoadBalancerEndpoint { - pub dns_name: String, - pub hosted_zone_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LoadBalancerState { - pub endpoint: Option, -} - -struct DomainInfo { - fqdn: String, - certificate_id: Option, - certificate_arn: Option, - uses_custom_domain: bool, -} - -fn emit_aws_lambda_worker_heartbeat( - ctx: &ResourceControllerContext<'_>, - worker_config: &Worker, - aws_worker_name: &str, - function_info: &FunctionConfiguration, -) { - ctx.emit_heartbeat(ResourceHeartbeat { - deployment_id: None, - resource_id: worker_config.id.clone(), - resource_type: Worker::RESOURCE_TYPE, - controller_platform: Platform::Aws, - backend: HeartbeatBackend::Aws, - observed_at: Utc::now(), - data: ResourceHeartbeatData::Worker(WorkerHeartbeatData::AwsLambda( - AwsLambdaWorkerHeartbeatData { - status: WorkloadHeartbeatStatus { - health: ObservedHealth::Healthy, - lifecycle: ProviderLifecycleState::Running, - message: Some(format!("AWS Lambda function '{aws_worker_name}' is active")), - stale: false, - partial: false, - collection_issues: vec![], - }, - function_name: function_info - .function_name - .clone() - .unwrap_or_else(|| aws_worker_name.to_string()), - runtime: None, - package_type: None, - memory_size_mb: None, - timeout_seconds: None, - version: None, - revision_id: None, - last_modified: None, - state: function_info.state.clone(), - state_reason: None, - state_reason_code: None, - last_update_status: function_info.last_update_status.clone(), - 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: worker_config.triggers.len() as u32, - }, - )), - raw: vec![], - }); -} - #[controller] impl AwsWorkerController { // ─────────────── CREATE FLOW ────────────────────────────── @@ -3695,1309 +3469,3 @@ impl AwsWorkerController { } } } - -// Separate impl block for helper methods -impl AwsWorkerController { - /// Rewrite an ECR image URI to use the given region if it points to a different one. - /// - /// Lambda requires container images in the same region as the worker. - /// When the management account's ECR is in a different region and private - /// image replication copies images to the target region, the image URI must - /// reference the replicated copy. - /// - /// Only rewrites URIs matching the ECR format: `{account}.dkr.ecr.{region}.amazonaws.com/...` - fn rewrite_ecr_region_if_needed(image_uri: &str, target_region: &str) -> String { - // ECR URI format: {account_id}.dkr.ecr.{region}.amazonaws.com/{repo}:{tag} - let Some(host_end) = image_uri.find('/') else { - return image_uri.to_string(); - }; - let host = &image_uri[..host_end]; - let parts: Vec<&str> = host.split('.').collect(); - // parts: [account_id, "dkr", "ecr", region, "amazonaws", "com"] - if parts.len() >= 6 - && parts[1] == "dkr" - && parts[2] == "ecr" - && parts[4] == "amazonaws" - && parts[3] != target_region - { - let new_host = format!("{}.dkr.ecr.{}.amazonaws.com", parts[0], target_region); - format!("{}{}", new_host, &image_uri[host_end..]) - } else { - image_uri.to_string() - } - } - - /// Creates an SQS event source mapping for a queue trigger - async fn create_queue_event_source_mapping( - &mut self, - ctx: &ResourceControllerContext<'_>, - aws_cfg: &alien_aws_clients::AwsClientConfig, - worker_config: &alien_core::Worker, - queue_ref: &alien_core::ResourceRef, - ) -> Result<()> { - let lambda_client = ctx.service_provider.get_aws_lambda_client(aws_cfg).await?; - - // Get queue controller to access outputs - let queue_controller = - ctx.require_dependency::(queue_ref)?; - let queue_outputs_wrapper = queue_controller.get_outputs().ok_or_else(|| { - AlienError::new(ErrorData::DependencyNotReady { - resource_id: worker_config.id.clone(), - dependency_id: queue_ref.id.clone(), - }) - })?; - let queue_outputs = queue_outputs_wrapper - .downcast_ref::() - .ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Invalid queue outputs type".to_string(), - resource_id: Some(worker_config.id.clone()), - }) - })?; - - // Extract queue name from the queue URL - let queue_name = if let Some(url) = &queue_outputs.identifier { - // SQS URL format: https://sqs.region.amazonaws.com/account-id/queue-name - url.split('/') - .last() - .unwrap_or(&queue_outputs.queue_name) - .to_string() - } else { - queue_outputs.queue_name.clone() - }; - - // Construct SQS queue ARN: arn:aws:sqs:region:account-id:queue-name - let queue_arn = format!( - "arn:aws:sqs:{}:{}:{}", - aws_cfg.region, aws_cfg.account_id, queue_name - ); - - info!( - worker=%worker_config.id, - queue_arn=%queue_arn, - "Creating SQS event source mapping" - ); - - let worker_name = self.arn.as_ref().ok_or_else(|| { - AlienError::new(ErrorData::ResourceConfigInvalid { - message: "Worker ARN not available for event source mapping".to_string(), - resource_id: Some(worker_config.id.clone()), - }) - })?; - - let existing_mappings = lambda_client - .list_event_source_mappings(ListEventSourceMappingsRequest { - event_source_arn: Some(queue_arn.clone()), - function_name: Some(worker_name.clone()), - marker: None, - max_items: None, - }) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to list event source mappings for queue '{}'", - queue_name - ), - resource_id: Some(worker_config.id.clone()), - })?; - - if let Some(existing_mapping) = existing_mappings - .event_source_mappings - .unwrap_or_default() - .into_iter() - .find(|mapping| { - mapping.event_source_arn.as_deref() == Some(queue_arn.as_str()) - && mapping.function_arn.as_deref() == Some(worker_name.as_str()) - }) - { - if let Some(uuid) = existing_mapping.uuid { - if !self.event_source_mappings.contains(&uuid) { - self.event_source_mappings.push(uuid.clone()); - } - info!( - worker=%worker_config.id, - queue_arn=%queue_arn, - uuid=%uuid, - "SQS event source mapping already exists; treating as created" - ); - return Ok(()); - } - } - - let request = alien_aws_clients::lambda::CreateEventSourceMappingRequest::builder() - .event_source_arn(queue_arn.clone()) - .function_name(worker_name.clone()) - .batch_size(1) // Always 1 message per invocation as per design - .enabled(true) - .build(); - - let response = match lambda_client.create_event_source_mapping(request).await { - Ok(response) => response, - Err(e) if is_remote_resource_conflict(&e) => { - let existing_mappings = lambda_client - .list_event_source_mappings(ListEventSourceMappingsRequest { - event_source_arn: Some(queue_arn.clone()), - function_name: Some(worker_name.clone()), - marker: None, - max_items: None, - }) - .await - .context(ErrorData::CloudPlatformError { - message: format!( - "Failed to list event source mappings for queue '{}' after conflict", - queue_name - ), - resource_id: Some(worker_config.id.clone()), - })?; - - existing_mappings - .event_source_mappings - .unwrap_or_default() - .into_iter() - .find(|mapping| { - mapping.event_source_arn.as_deref() == Some(queue_arn.as_str()) - && mapping.function_arn.as_deref() == Some(worker_name.as_str()) - }) - .ok_or_else(|| { - AlienError::new(ErrorData::CloudPlatformError { - message: format!( - "Event source mapping for queue '{}' already exists but could not be found", - queue_name - ), - resource_id: Some(worker_config.id.clone()), - }) - })? - } - Err(e) => { - return Err(e.context(ErrorData::CloudPlatformError { - message: format!( - "Failed to create event source mapping for queue '{}'", - queue_name - ), - resource_id: Some(worker_config.id.clone()), - })); - } - }; - - if let Some(uuid) = response.uuid { - if !self.event_source_mappings.contains(&uuid) { - self.event_source_mappings.push(uuid.clone()); - } - info!( - worker=%worker_config.id, - queue_arn=%queue_arn, - uuid=%uuid, - "Successfully created SQS event source mapping" - ); - } - - Ok(()) - } - - // ─────────────── HELPER METHODS ──────────────────────────── - - /// Gets VPC configuration from the Network resource if one exists in the stack. - /// - /// If a Network resource exists (ID: "default-network"), this method retrieves - /// the VPC ID, subnet IDs, and security group ID from the Network controller - /// to configure the Lambda worker to run inside the VPC. - /// - /// Returns `None` if no Network resource exists in the stack. - fn get_vpc_config(&self, ctx: &ResourceControllerContext<'_>) -> Result> { - // Check if the stack has a Network resource - let network_id = "default-network"; - if !ctx.desired_stack.resources.contains_key(network_id) { - return Ok(None); - } - - // Get the Network controller state via require_dependency - let network_ref = ResourceRef::new(Network::RESOURCE_TYPE, network_id.to_string()); - let network_state = - ctx.require_dependency::(&network_ref)?; - - // Only configure VPC if we have subnet IDs and a security group - // For Lambda, we use private subnets (no public IP assignment) - if network_state.private_subnet_ids.is_empty() { - return Ok(None); - } - - let security_group_ids = match &network_state.security_group_id { - Some(sg) => vec![sg.clone()], - None => vec![], - }; - - if security_group_ids.is_empty() { - return Ok(None); - } - - Ok(Some( - VpcConfig::builder() - .subnet_ids(network_state.private_subnet_ids.clone()) - .security_group_ids(security_group_ids) - .build(), - )) - } - - async fn prepare_environment_variables( - &self, - initial_env: &HashMap, - links: &[ResourceRef], - ctx: &ResourceControllerContext<'_>, - worker_name_for_error_logging: &str, - ) -> Result> { - let worker_config = ctx.desired_resource_config::()?; - - // Get the worker's own binding params (may be None during initial creation) - let self_binding_params = self.get_binding_params()?; - - let env_vars = EnvironmentVariableBuilder::try_new(initial_env)? - .add_worker_runtime_env_vars(ctx, &worker_config.id, worker_config.timeout_seconds)? - .add_linked_resources(links, ctx, worker_name_for_error_logging) - .await? - .add_self_worker_binding(&worker_config.id, self_binding_params.as_ref())? - .build(); - - Ok(env_vars) - } - - /// Creates a controller in a ready state with mock values for testing purposes. - #[cfg(feature = "test-utils")] - pub fn mock_ready(worker_name: &str) -> Self { - Self { - state: AwsWorkerState::Ready, - arn: Some(format!( - "arn:aws:lambda:us-east-1:123456789012:function:{}", - worker_name - )), - url: Some(format!("https://abcd1234.lambda-url.us-east-1.on.aws/")), - worker_name: Some(worker_name.to_string()), - event_source_mappings: Vec::new(), - fqdn: None, - certificate_id: None, - certificate_arn: None, - api_id: None, - integration_id: None, - route_id: None, - stage_name: None, - api_mapping_id: None, - domain_name: None, - load_balancer: None, - uses_custom_domain: false, - certificate_issued_at: None, - s3_permission_statement_ids: Vec::new(), - eventbridge_rule_names: Vec::new(), - eventbridge_permission_statement_ids: Vec::new(), - _internal_stay_count: None, - } - } -} - -#[cfg(test)] -mod tests { - //! # AWS Worker Controller Tests - //! - //! See `crate::core::controller_test` for a comprehensive guide on testing infrastructure controllers. - - use std::collections::HashMap; - use std::sync::Arc; - - use alien_aws_clients::acm::{ImportCertificateResponse, MockAcmApi}; - use alien_aws_clients::apigatewayv2::{ - Api, ApiMapping, DomainName, DomainNameConfiguration, Integration, MockApiGatewayV2Api, - Route, Stage, - }; - use alien_aws_clients::iam::MockIamApi; - use alien_aws_clients::lambda::{AddPermissionResponse, FunctionConfiguration, MockLambdaApi}; - use alien_client_core::ErrorData as CloudClientErrorData; - use alien_core::{ - CertificateStatus, DnsRecordStatus, DomainMetadata, Platform, PublicEndpointUrls, - ResourceDomainInfo, ResourceStatus, Worker, WorkerOutputs, - }; - use alien_error::AlienError; - use httpmock::prelude::*; - use rstest::rstest; - - use crate::core::controller_test::SingleControllerExecutor; - use crate::core::MockPlatformServiceProvider; - use crate::worker::{ - fixtures::*, readiness_probe::test_utils::create_readiness_probe_mock, AwsWorkerController, - }; - - fn create_successful_function_response(worker_name: &str) -> FunctionConfiguration { - FunctionConfiguration { - function_name: Some(worker_name.to_string()), - function_arn: Some(format!( - "arn:aws:lambda:us-east-1:123456789012:function:{}", - worker_name - )), - state: Some("Active".to_string()), - last_update_status: Some("Successful".to_string()), - kms_key_arn: None, - } - } - - fn create_test_domain_metadata(resource_id: &str) -> DomainMetadata { - let mut resources = HashMap::new(); - resources.insert( - resource_id.to_string(), - ResourceDomainInfo { - fqdn: format!("{}.test.example.com", resource_id), - certificate_id: "test-cert-id".to_string(), - certificate_status: CertificateStatus::Issued, - dns_status: DnsRecordStatus::Active, - dns_error: None, - certificate_chain: Some( - "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n" - .to_string(), - ), - private_key: Some( - "-----BEGIN RSA PRIVATE KEY-----\nMIIBtest\n-----END RSA PRIVATE KEY-----\n" - .to_string(), - ), - endpoints: HashMap::new(), - aliases: Vec::new(), - issued_at: Some("2024-01-01T00:00:00Z".to_string()), - }, - ); - DomainMetadata { - base_domain: "test.example.com".to_string(), - public_subdomain: "test".to_string(), - hosted_zone_id: "Z1234567890ABC".to_string(), - resources, - } - } - - fn create_acm_mock_for_creation() -> Arc { - let mut mock_acm = MockAcmApi::new(); - mock_acm.expect_import_certificate().returning(|_| { - Ok(ImportCertificateResponse { - certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" - .to_string(), - }) - }); - Arc::new(mock_acm) - } - - fn create_acm_mock_for_creation_and_deletion() -> Arc { - let mut mock_acm = MockAcmApi::new(); - mock_acm.expect_import_certificate().returning(|_| { - Ok(ImportCertificateResponse { - certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" - .to_string(), - }) - }); - mock_acm.expect_delete_certificate().returning(|_| Ok(())); - Arc::new(mock_acm) - } - - fn create_apigatewayv2_mock_for_creation() -> Arc { - let mut mock_apigw = MockApiGatewayV2Api::new(); - mock_apigw.expect_create_api().returning(|_| { - Ok(Api { - api_id: Some("test-api-id".to_string()), - api_endpoint: Some( - "https://test-api-id.execute-api.us-east-1.amazonaws.com".to_string(), - ), - name: None, - protocol_type: None, - }) - }); - mock_apigw.expect_create_integration().returning(|_, _| { - Ok(Integration { - integration_id: Some("test-integration-id".to_string()), - integration_type: None, - integration_uri: None, - }) - }); - mock_apigw.expect_create_route().returning(|_, _| { - Ok(Route { - route_id: Some("test-route-id".to_string()), - route_key: None, - }) - }); - mock_apigw.expect_create_stage().returning(|_, _| { - Ok(Stage { - stage_name: Some("$default".to_string()), - auto_deploy: None, - }) - }); - mock_apigw.expect_create_domain_name().returning(|_| { - Ok(DomainName { - domain_name: Some("test.example.com".to_string()), - domain_name_configurations: Some(vec![DomainNameConfiguration { - certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" - .to_string(), - endpoint_type: "REGIONAL".to_string(), - security_policy: "TLS_1_2".to_string(), - api_gateway_domain_name: Some( - "test.execute-api.us-east-1.amazonaws.com".to_string(), - ), - hosted_zone_id: Some("Z1D633PJN98FT9".to_string()), - }]), - }) - }); - mock_apigw.expect_create_api_mapping().returning(|_, _| { - Ok(ApiMapping { - api_mapping_id: Some("test-mapping-id".to_string()), - api_mapping_key: None, - stage: None, - }) - }); - Arc::new(mock_apigw) - } - - fn create_apigatewayv2_mock_for_creation_and_deletion() -> Arc { - let mut mock_apigw = MockApiGatewayV2Api::new(); - mock_apigw.expect_create_api().returning(|_| { - Ok(Api { - api_id: Some("test-api-id".to_string()), - api_endpoint: Some( - "https://test-api-id.execute-api.us-east-1.amazonaws.com".to_string(), - ), - name: None, - protocol_type: None, - }) - }); - mock_apigw.expect_create_integration().returning(|_, _| { - Ok(Integration { - integration_id: Some("test-integration-id".to_string()), - integration_type: None, - integration_uri: None, - }) - }); - mock_apigw.expect_create_route().returning(|_, _| { - Ok(Route { - route_id: Some("test-route-id".to_string()), - route_key: None, - }) - }); - mock_apigw.expect_create_stage().returning(|_, _| { - Ok(Stage { - stage_name: Some("$default".to_string()), - auto_deploy: None, - }) - }); - mock_apigw.expect_create_domain_name().returning(|_| { - Ok(DomainName { - domain_name: Some("test.example.com".to_string()), - domain_name_configurations: Some(vec![DomainNameConfiguration { - certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" - .to_string(), - endpoint_type: "REGIONAL".to_string(), - security_policy: "TLS_1_2".to_string(), - api_gateway_domain_name: Some( - "test.execute-api.us-east-1.amazonaws.com".to_string(), - ), - hosted_zone_id: Some("Z1D633PJN98FT9".to_string()), - }]), - }) - }); - mock_apigw.expect_create_api_mapping().returning(|_, _| { - Ok(ApiMapping { - api_mapping_id: Some("test-mapping-id".to_string()), - api_mapping_key: None, - stage: None, - }) - }); - mock_apigw - .expect_delete_api_mapping() - .returning(|_, _| Ok(())); - mock_apigw.expect_delete_domain_name().returning(|_| Ok(())); - mock_apigw.expect_delete_api().returning(|_| Ok(())); - Arc::new(mock_apigw) - } - - fn setup_mock_client_for_creation_and_update( - worker_name: &str, - has_url: bool, - ) -> Arc { - let mut mock_lambda = MockLambdaApi::new(); - - // Mock successful worker creation - let worker_name = worker_name.to_string(); - let worker_name_for_create = worker_name.clone(); - mock_lambda - .expect_create_function() - .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); - - // Mock worker status checks - first pending, then active - let worker_name_for_get = worker_name.clone(); - mock_lambda - .expect_get_function_configuration() - .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))); - - // Mock API Gateway permission and self-binding env var update if public ingress - if has_url { - mock_lambda - .expect_add_permission() - .returning(|_, _| Ok(AddPermissionResponse { statement: None })); - - let worker_name_for_self_binding = worker_name.clone(); - mock_lambda - .expect_update_function_configuration() - .returning(move |_, _| { - Ok(create_successful_function_response( - &worker_name_for_self_binding, - )) - }); - } - - // Mock concurrency operations (may or may not be called depending on worker config) - mock_lambda - .expect_put_function_concurrency() - .returning(|_, _| Ok(())); - mock_lambda - .expect_delete_function_concurrency() - .returning(|_| Ok(())); - - // Mock successful updates - let worker_name_for_code_update = worker_name.clone(); - mock_lambda - .expect_update_function_code() - .returning(move |_, _| { - Ok(create_successful_function_response( - &worker_name_for_code_update, - )) - }); - - if !has_url { - let worker_name_for_config_update = worker_name.clone(); - mock_lambda - .expect_update_function_configuration() - .returning(move |_, _| { - Ok(create_successful_function_response( - &worker_name_for_config_update, - )) - }); - } - - Arc::new(mock_lambda) - } - - fn setup_mock_client_for_creation_and_deletion( - worker_name: &str, - has_url: bool, - ) -> Arc { - let mut mock_lambda = MockLambdaApi::new(); - - // Mock successful worker creation - let worker_name = worker_name.to_string(); - let worker_name_for_create = worker_name.clone(); - mock_lambda - .expect_create_function() - .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); - - // Mock worker status checks - let worker_name_for_get = worker_name.clone(); - mock_lambda - .expect_get_function_configuration() - .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) - .times(1); // Only for creation flow - - // Mock API Gateway permission and self-binding env var update if public ingress - if has_url { - mock_lambda - .expect_add_permission() - .returning(|_, _| Ok(AddPermissionResponse { statement: None })); - - // Mock update_function_configuration for self-binding env var update - let worker_name_for_config_update = worker_name.clone(); - mock_lambda - .expect_update_function_configuration() - .returning(move |_, _| { - Ok(create_successful_function_response( - &worker_name_for_config_update, - )) - }); - } - - // Mock concurrency operations (may or may not be called depending on worker config) - mock_lambda - .expect_put_function_concurrency() - .returning(|_, _| Ok(())); - mock_lambda - .expect_delete_function_concurrency() - .returning(|_| Ok(())); - - // Mock successful worker deletion - mock_lambda - .expect_delete_function() - .returning(|_, _| Ok(())); - - // Mock worker not found during deletion check - mock_lambda - .expect_get_function_configuration() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - - Arc::new(mock_lambda) - } - - fn setup_mock_client_for_best_effort_deletion( - _worker_name: &str, - function_missing: bool, - ) -> Arc { - let mut mock_lambda = MockLambdaApi::new(); - - // Mock worker deletion (might fail if worker missing) - if function_missing { - mock_lambda.expect_delete_function().returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - } else { - mock_lambda - .expect_delete_function() - .returning(|_, _| Ok(())); - } - - // Always return not found for final status check - mock_lambda - .expect_get_function_configuration() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - - Arc::new(mock_lambda) - } - - fn create_aws_iam_mock_for_resource_permissions() -> Arc { - let mut mock_iam = MockIamApi::new(); - mock_iam - .expect_put_role_policy() - .returning(|_, _, _| Ok(())); - Arc::new(mock_iam) - } - - fn setup_mock_service_provider( - mock_lambda: Arc, - mock_acm: Option>, - mock_apigw: Option>, - ) -> Arc { - let mut mock_provider = MockPlatformServiceProvider::new(); - - mock_provider - .expect_get_aws_lambda_client() - .returning(move |_| Ok(mock_lambda.clone())); - - // Mock IAM client for resource-scoped permissions (ApplyingResourcePermissions state) - let mock_iam = create_aws_iam_mock_for_resource_permissions(); - mock_provider - .expect_get_aws_iam_client() - .returning(move |_| Ok(mock_iam.clone())); - - if let Some(acm) = mock_acm { - mock_provider - .expect_get_aws_acm_client() - .returning(move |_| Ok(acm.clone())); - } - - if let Some(apigw) = mock_apigw { - mock_provider - .expect_get_aws_apigatewayv2_client() - .returning(move |_| Ok(apigw.clone())); - } - - Arc::new(mock_provider) - } - - /// Sets up all mocks for a worker test, including Lambda, ACM, and API Gateway. - /// - /// Returns `(mock_provider, optional_mock_server, optional_domain_metadata, optional_public_urls)`. - /// For public workers, `domain_metadata` and `public_urls` must be set on the executor builder. - /// When a readiness probe is configured, `public_urls` overrides the FQDN URL so the probe - /// hits the local mock HTTP server instead. - fn setup_mocks_for_function( - worker: &Worker, - worker_name: &str, - for_deletion: bool, - ) -> ( - Arc, - Option, - Option, - Option, - ) { - let has_url = !worker.public_endpoints.is_empty(); - let needs_readiness_probe = has_url && worker.readiness_probe.is_some(); - - // Set up mock server for readiness probe if needed - let mock_server = if needs_readiness_probe { - Some(create_readiness_probe_mock(worker)) - } else { - None - }; - - // Set up Lambda client mock (same for both flows; URL config calls are removed) - let lambda_mock = if for_deletion { - setup_mock_client_for_creation_and_deletion(worker_name, has_url) - } else { - setup_mock_client_for_creation_and_update(worker_name, has_url) - }; - - // Set up ACM and API Gateway mocks for public workers - let (acm_mock, apigw_mock, domain_metadata, public_endpoints) = if has_url { - let dm = create_test_domain_metadata(&worker.id); - let acm = if for_deletion { - create_acm_mock_for_creation_and_deletion() - } else { - create_acm_mock_for_creation() - }; - let apigw = if for_deletion { - create_apigatewayv2_mock_for_creation_and_deletion() - } else { - create_apigatewayv2_mock_for_creation() - }; - // For readiness probe tests, override the FQDN URL with the mock server URL - let pub_endpoints = mock_server.as_ref().map(|server| { - HashMap::from([( - worker.id.clone(), - HashMap::from([("api".to_string(), server.base_url())]), - )]) - }); - (Some(acm), Some(apigw), Some(dm), pub_endpoints) - } else { - (None, None, None, None) - }; - - let mock_provider = setup_mock_service_provider(lambda_mock, acm_mock, apigw_mock); - - ( - mock_provider, - mock_server, - domain_metadata, - public_endpoints, - ) - } - - // ─────────────── CREATE AND DELETE FLOW TESTS ──────────────────── - - #[rstest] - #[case::basic(basic_function(), false)] - #[case::env_vars(function_with_env_vars(), false)] - #[case::storage_link(function_with_storage_link(), false)] - #[case::env_and_storage(function_with_env_and_storage(), false)] - #[case::multiple_storages(function_with_multiple_storages(), false)] - #[case::public_ingress(function_public_ingress(), true)] - #[case::private_ingress(function_private_ingress(), false)] - #[case::concurrency(function_with_concurrency(), false)] - #[case::custom_config(function_custom_config(), false)] - #[case::readiness_probe(function_with_readiness_probe(), true)] - #[case::complete_test(function_complete_test(), true)] - #[tokio::test] - async fn test_create_and_delete_flow_succeeds(#[case] worker: Worker, #[case] _has_url: bool) { - let worker_name = format!("test-{}", worker.id); - let (mock_provider, _mock_server, domain_metadata, public_endpoints) = - setup_mocks_for_function(&worker, &worker_name, true); - - let mut builder = SingleControllerExecutor::builder() - .resource(worker) - .controller(AwsWorkerController::default()) - .platform(Platform::Aws) - .service_provider(mock_provider) - .with_test_dependencies(); - - if let Some(dm) = domain_metadata { - builder = builder.domain_metadata(dm); - } - if let Some(urls) = public_endpoints { - builder = builder.public_endpoints(urls); - } - - let mut executor = builder.build().await.unwrap(); - - // Run create flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify outputs are available - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.identifier.is_some()); - assert!(function_outputs.worker_name.starts_with("test-")); - - // Delete the worker - executor.delete().unwrap(); - - // Run delete flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } - - // ─────────────── UPDATE FLOW TESTS ──────────────────────────────── - - #[rstest] - #[case::basic_to_env(basic_function(), function_with_env_vars())] - #[case::env_to_storage(function_with_env_vars(), function_with_storage_link())] - #[case::storage_to_custom(function_with_storage_link(), function_custom_config())] - #[case::custom_to_public(function_custom_config(), function_public_ingress())] - #[case::public_to_complete(function_public_ingress(), function_complete_test())] - #[case::complete_to_basic(function_complete_test(), basic_function())] - #[tokio::test] - async fn test_update_flow_succeeds(#[case] from_function: Worker, #[case] to_function: Worker) { - // Ensure both workers have the same ID for valid updates - let worker_id = "test-update-worker".to_string(); - let mut from_function = from_function; - from_function.id = worker_id.clone(); - - let mut to_function = to_function; - to_function.id = worker_id.clone(); - - let worker_name = format!("test-{}", worker_id); - let (mock_provider, mock_server, domain_metadata, public_endpoints) = - setup_mocks_for_function(&to_function, &worker_name, false); - - // Start with the "from" worker in Ready state - let mut ready_controller = AwsWorkerController::mock_ready(&worker_name); - - // If the target worker has a readiness probe, update the controller URL to point to mock server - if to_function.readiness_probe.is_some() && !to_function.public_endpoints.is_empty() { - if let Some(ref server) = mock_server { - ready_controller.url = Some(server.base_url()); - } - } - - let mut builder = SingleControllerExecutor::builder() - .resource(from_function) - .controller(ready_controller) - .platform(Platform::Aws) - .service_provider(mock_provider) - .with_test_dependencies(); - - if let Some(dm) = domain_metadata { - builder = builder.domain_metadata(dm); - } - if let Some(urls) = public_endpoints { - builder = builder.public_endpoints(urls); - } - - let mut executor = builder.build().await.unwrap(); - - // Ensure we start in Running state - assert_eq!(executor.status(), ResourceStatus::Running); - - // Update to the new worker - let target_is_public = !to_function.public_endpoints.is_empty(); - executor.update(to_function).unwrap(); - - // Run the update flow - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - if target_is_public { - let url = function_outputs - .public_endpoints - .get("default") - .expect("default public endpoint") - .url - .as_str(); - assert!(url.starts_with("http://") || url.starts_with("https://")); - assert!(!url.contains("lambda-url")); - } - } - - // ─────────────── BEST EFFORT DELETION TESTS ─────────────────────── - - #[rstest] - #[case::basic(basic_function(), false)] - #[case::public_with_missing_function(function_public_ingress(), true)] - #[case::public(function_public_ingress(), false)] - #[case::private_with_missing_function(function_private_ingress(), true)] - #[tokio::test] - async fn test_best_effort_deletion_when_resources_missing( - #[case] worker: Worker, - #[case] function_missing: bool, - ) { - let worker_name = format!("test-{}", worker.id); - let has_url = !worker.public_endpoints.is_empty(); - let mock_lambda = - setup_mock_client_for_best_effort_deletion(&worker_name, function_missing); - let mock_provider = setup_mock_service_provider(mock_lambda, None, None); - - // Start with a ready controller - let mut ready_controller = AwsWorkerController::mock_ready(&worker_name); - if has_url { - ready_controller.url = - Some("https://example.execute-api.us-east-1.amazonaws.com".to_string()); - } - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(ready_controller) - .platform(Platform::Aws) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - // Ensure we start in Running state - assert_eq!(executor.status(), ResourceStatus::Running); - - // Delete the worker - executor.delete().unwrap(); - - // Run the delete flow - it should succeed even when resources are missing - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Deleted); - - // Verify outputs are no longer available - assert!(executor.outputs().is_none()); - } - - // ─────────────── SPECIFIC VALIDATION TESTS ───────────────── - - /// Test that verifies public workers go through ACM certificate import and API Gateway setup. - #[tokio::test] - async fn test_public_function_creates_api_gateway_and_certificate() { - let worker = function_public_ingress(); - let worker_name = format!("test-{}", worker.id); - let domain_metadata = create_test_domain_metadata(&worker.id); - - let mut mock_lambda = MockLambdaApi::new(); - - // Mock worker creation - let worker_name_for_create = worker_name.clone(); - mock_lambda - .expect_create_function() - .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); - - let worker_name_for_get = worker_name.clone(); - mock_lambda - .expect_get_function_configuration() - .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) - .times(1); - - // Validate API Gateway permission is added with the correct apigateway principal - mock_lambda - .expect_add_permission() - .withf(|_, request| { - request.statement_id == "ApiGatewayInvoke" - && request.action == "lambda:InvokeFunction" - && request.principal == "apigateway.amazonaws.com" - }) - .returning(|_, _| Ok(AddPermissionResponse { statement: None })); - - // Mock self-binding env var update - let worker_name_for_config_update = worker_name.clone(); - mock_lambda - .expect_update_function_configuration() - .returning(move |_, _| { - Ok(create_successful_function_response( - &worker_name_for_config_update, - )) - }); - - mock_lambda - .expect_delete_function() - .returning(|_, _| Ok(())); - mock_lambda - .expect_get_function_configuration() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - - // Validate ACM certificate import - let mut mock_acm = MockAcmApi::new(); - mock_acm - .expect_import_certificate() - .times(1) - .returning(|_| { - Ok(ImportCertificateResponse { - certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" - .to_string(), - }) - }); - mock_acm.expect_delete_certificate().returning(|_| Ok(())); - - // Validate API Gateway is created with the worker's name in the API name - let mut mock_apigw = MockApiGatewayV2Api::new(); - mock_apigw - .expect_create_api() - .withf(|request| request.name.contains("public-func")) - .returning(|_| { - Ok(Api { - api_id: Some("test-api-id".to_string()), - api_endpoint: None, - name: None, - protocol_type: None, - }) - }); - mock_apigw.expect_create_integration().returning(|_, _| { - Ok(Integration { - integration_id: Some("test-integration-id".to_string()), - integration_type: None, - integration_uri: None, - }) - }); - mock_apigw.expect_create_route().returning(|_, _| { - Ok(Route { - route_id: Some("test-route-id".to_string()), - route_key: None, - }) - }); - mock_apigw.expect_create_stage().returning(|_, _| { - Ok(Stage { - stage_name: Some("$default".to_string()), - auto_deploy: None, - }) - }); - mock_apigw.expect_create_domain_name().returning(|_| { - Ok(DomainName { - domain_name: Some("public-func.test.example.com".to_string()), - domain_name_configurations: Some(vec![DomainNameConfiguration { - certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" - .to_string(), - endpoint_type: "REGIONAL".to_string(), - security_policy: "TLS_1_2".to_string(), - api_gateway_domain_name: Some( - "test.execute-api.us-east-1.amazonaws.com".to_string(), - ), - hosted_zone_id: Some("Z1D633PJN98FT9".to_string()), - }]), - }) - }); - mock_apigw.expect_create_api_mapping().returning(|_, _| { - Ok(ApiMapping { - api_mapping_id: Some("test-mapping-id".to_string()), - api_mapping_key: None, - stage: None, - }) - }); - mock_apigw - .expect_delete_api_mapping() - .returning(|_, _| Ok(())); - mock_apigw.expect_delete_domain_name().returning(|_| Ok(())); - mock_apigw.expect_delete_api().returning(|_| Ok(())); - - let mock_provider = setup_mock_service_provider( - Arc::new(mock_lambda), - Some(Arc::new(mock_acm)), - Some(Arc::new(mock_apigw)), - ); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AwsWorkerController::default()) - .platform(Platform::Aws) - .service_provider(mock_provider) - .domain_metadata(domain_metadata) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify URL is in outputs (derived from domain_metadata FQDN) - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.public_endpoints.contains_key("default")); - } - - /// Test that verifies private workers don't get URL creation - #[tokio::test] - async fn test_private_function_skips_url_creation() { - let worker = function_private_ingress(); - let worker_name = format!("test-{}", worker.id); - - let mut mock_lambda = MockLambdaApi::new(); - - // Mock worker creation - let worker_name_for_create = worker_name.clone(); - mock_lambda - .expect_create_function() - .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); - - let worker_name_for_get = worker_name.clone(); - mock_lambda - .expect_get_function_configuration() - .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) - .times(1); - - // API Gateway and permission should NOT be called for private workers - mock_lambda.expect_add_permission().times(0); - - mock_lambda - .expect_delete_function() - .returning(|_, _| Ok(())); - mock_lambda - .expect_get_function_configuration() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_lambda), None, None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AwsWorkerController::default()) - .platform(Platform::Aws) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - - // Verify no URL in outputs - let outputs = executor.outputs().unwrap(); - let function_outputs = outputs.downcast_ref::().unwrap(); - assert!(function_outputs.public_endpoints.is_empty()); - } - - /// Test that verifies correct worker configuration parameters - #[tokio::test] - async fn test_worker_configuration_validation() { - let worker = function_custom_config(); - let worker_name = format!("test-{}", worker.id); - - let mut mock_lambda = MockLambdaApi::new(); - - // Validate worker creation request has correct parameters - let worker_name_for_create = worker_name.clone(); - mock_lambda - .expect_create_function() - .withf(|request| { - request.memory_size == Some(512) - && request.timeout == Some(120) - && request.package_type == "Image" - && request - .architectures - .as_ref() - .map(|a| a.contains(&"arm64".to_string())) - .unwrap_or(false) - }) - .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); - - let worker_name_for_get = worker_name.clone(); - mock_lambda - .expect_get_function_configuration() - .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) - .times(1); - - mock_lambda - .expect_delete_function() - .returning(|_, _| Ok(())); - mock_lambda - .expect_get_function_configuration() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_lambda), None, None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AwsWorkerController::default()) - .platform(Platform::Aws) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } - - /// Test that verifies environment variables are correctly passed - #[tokio::test] - async fn test_environment_variable_handling() { - let worker = function_with_env_vars(); - let worker_name = format!("test-{}", worker.id); - - let mut mock_lambda = MockLambdaApi::new(); - - // Validate worker creation request has environment variables - let worker_name_for_create = worker_name.clone(); - mock_lambda - .expect_create_function() - .withf(|request| { - if let Some(env) = &request.environment { - if let Some(vars) = &env.variables { - vars.get("APP_ENV") == Some(&"production".to_string()) - && vars.get("LOG_LEVEL") == Some(&"debug".to_string()) - && vars.get("DB_NAME") == Some(&"myapp".to_string()) - } else { - false - } - } else { - false - } - }) - .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); - - let worker_name_for_get = worker_name.clone(); - mock_lambda - .expect_get_function_configuration() - .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) - .times(1); - - mock_lambda - .expect_delete_function() - .returning(|_, _| Ok(())); - mock_lambda - .expect_get_function_configuration() - .returning(|_, _| { - Err(AlienError::new( - CloudClientErrorData::RemoteResourceNotFound { - resource_type: "Worker".to_string(), - resource_name: "test-worker".to_string(), - }, - )) - }); - - let mock_provider = setup_mock_service_provider(Arc::new(mock_lambda), None, None); - - let mut executor = SingleControllerExecutor::builder() - .resource(worker) - .controller(AwsWorkerController::default()) - .platform(Platform::Aws) - .service_provider(mock_provider) - .with_test_dependencies() - .build() - .await - .unwrap(); - - executor.run_until_terminal().await.unwrap(); - assert_eq!(executor.status(), ResourceStatus::Running); - } -} diff --git a/crates/alien-infra/src/worker/aws/support.rs b/crates/alien-infra/src/worker/aws/support.rs new file mode 100644 index 000000000..55cef3024 --- /dev/null +++ b/crates/alien-infra/src/worker/aws/support.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; + +use crate::core::ResourceControllerContext; +use crate::worker::readiness_probe::ReadinessProbeDnsOverride; +use alien_aws_clients::eventbridge::EventBridgeTag; +use alien_aws_clients::lambda::FunctionConfiguration; +use alien_aws_clients::s3::{LambdaFunctionConfiguration, NotificationConfiguration}; +use alien_client_core::ErrorData as CloudClientErrorData; +use alien_core::{ + standard_resource_tags, AwsLambdaWorkerHeartbeatData, HeartbeatBackend, ObservedHealth, + Platform, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, Worker, + WorkerHeartbeatData, WorkloadHeartbeatStatus, +}; +use alien_error::AlienError; +use chrono::Utc; + +/// Generates the full, prefixed AWS resource name. +pub(super) fn get_aws_worker_name(prefix: &str, name: &str) -> String { + format!("{}-{}", prefix, name) +} + +pub(super) fn readiness_probe_dns_override( + url: &str, + fqdn: Option<&str>, + load_balancer: Option<&LoadBalancerState>, +) -> Option { + let fqdn = fqdn?; + let endpoint = load_balancer?.endpoint.as_ref()?; + let parsed = reqwest::Url::parse(url).ok()?; + let url_host = parsed.host_str()?; + + if url_host != fqdn { + return None; + } + + Some(ReadinessProbeDnsOverride { + host: fqdn.to_string(), + target_dns_name: endpoint.dns_name.clone(), + port: parsed.port_or_known_default().unwrap_or(443), + }) +} + +pub(super) fn eventbridge_tags(prefix: &str, resource_id: &str) -> Vec { + standard_resource_tags(prefix, resource_id) + .into_iter() + .map(|(key, value)| EventBridgeTag { key, value }) + .collect() +} + +pub(super) fn is_remote_resource_conflict(error: &AlienError) -> bool { + matches!( + &error.error, + Some(CloudClientErrorData::RemoteResourceConflict { .. }) + ) +} + +pub(super) fn replace_lambda_notification_config( + notification_config: &mut NotificationConfiguration, + replacement: LambdaFunctionConfiguration, +) { + if let Some(replacement_id) = replacement.id.as_ref() { + notification_config + .lambda_function_configurations + .retain(|config| config.id.as_ref() != Some(replacement_id)); + } + notification_config + .lambda_function_configurations + .push(replacement); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadBalancerEndpoint { + pub dns_name: String, + pub hosted_zone_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadBalancerState { + pub endpoint: Option, +} + +pub(super) struct DomainInfo { + pub(super) fqdn: String, + pub(super) certificate_id: Option, + pub(super) certificate_arn: Option, + pub(super) uses_custom_domain: bool, +} + +pub(super) fn emit_aws_lambda_worker_heartbeat( + ctx: &ResourceControllerContext<'_>, + worker_config: &Worker, + aws_worker_name: &str, + function_info: &FunctionConfiguration, +) { + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: worker_config.id.clone(), + resource_type: Worker::RESOURCE_TYPE, + controller_platform: Platform::Aws, + backend: HeartbeatBackend::Aws, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Worker(WorkerHeartbeatData::AwsLambda( + AwsLambdaWorkerHeartbeatData { + status: WorkloadHeartbeatStatus { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: Some(format!("AWS Lambda function '{aws_worker_name}' is active")), + stale: false, + partial: false, + collection_issues: vec![], + }, + function_name: function_info + .function_name + .clone() + .unwrap_or_else(|| aws_worker_name.to_string()), + runtime: None, + package_type: None, + memory_size_mb: None, + timeout_seconds: None, + version: None, + revision_id: None, + last_modified: None, + state: function_info.state.clone(), + state_reason: None, + state_reason_code: None, + last_update_status: function_info.last_update_status.clone(), + 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: worker_config.triggers.len() as u32, + }, + )), + raw: vec![], + }); +} diff --git a/crates/alien-infra/src/worker/aws/tests.rs b/crates/alien-infra/src/worker/aws/tests.rs new file mode 100644 index 000000000..6f3715b0f --- /dev/null +++ b/crates/alien-infra/src/worker/aws/tests.rs @@ -0,0 +1,1004 @@ +//! # AWS Worker Controller Tests +//! +//! See `crate::core::controller_test` for a comprehensive guide on testing infrastructure controllers. + +use std::collections::HashMap; +use std::sync::Arc; + +use alien_aws_clients::acm::{ImportCertificateResponse, MockAcmApi}; +use alien_aws_clients::apigatewayv2::{ + Api, ApiMapping, DomainName, DomainNameConfiguration, Integration, MockApiGatewayV2Api, Route, + Stage, +}; +use alien_aws_clients::iam::MockIamApi; +use alien_aws_clients::lambda::{AddPermissionResponse, FunctionConfiguration, MockLambdaApi}; +use alien_client_core::ErrorData as CloudClientErrorData; +use alien_core::{ + CertificateStatus, DnsRecordStatus, DomainMetadata, Platform, PublicEndpointUrls, + ResourceDomainInfo, ResourceStatus, Worker, WorkerOutputs, +}; +use alien_error::AlienError; +use httpmock::prelude::*; +use rstest::rstest; + +use crate::core::controller_test::SingleControllerExecutor; +use crate::core::MockPlatformServiceProvider; +use crate::worker::{ + fixtures::*, readiness_probe::test_utils::create_readiness_probe_mock, AwsWorkerController, +}; + +fn create_successful_function_response(worker_name: &str) -> FunctionConfiguration { + FunctionConfiguration { + function_name: Some(worker_name.to_string()), + function_arn: Some(format!( + "arn:aws:lambda:us-east-1:123456789012:function:{}", + worker_name + )), + state: Some("Active".to_string()), + last_update_status: Some("Successful".to_string()), + kms_key_arn: None, + } +} + +fn create_test_domain_metadata(resource_id: &str) -> DomainMetadata { + let mut resources = HashMap::new(); + resources.insert( + resource_id.to_string(), + ResourceDomainInfo { + fqdn: format!("{}.test.example.com", resource_id), + certificate_id: "test-cert-id".to_string(), + certificate_status: CertificateStatus::Issued, + dns_status: DnsRecordStatus::Active, + dns_error: None, + certificate_chain: Some( + "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n".to_string(), + ), + private_key: Some( + "-----BEGIN RSA PRIVATE KEY-----\nMIIBtest\n-----END RSA PRIVATE KEY-----\n" + .to_string(), + ), + endpoints: HashMap::new(), + aliases: Vec::new(), + issued_at: Some("2024-01-01T00:00:00Z".to_string()), + }, + ); + DomainMetadata { + base_domain: "test.example.com".to_string(), + public_subdomain: "test".to_string(), + hosted_zone_id: "Z1234567890ABC".to_string(), + resources, + } +} + +fn create_acm_mock_for_creation() -> Arc { + let mut mock_acm = MockAcmApi::new(); + mock_acm.expect_import_certificate().returning(|_| { + Ok(ImportCertificateResponse { + certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" + .to_string(), + }) + }); + Arc::new(mock_acm) +} + +fn create_acm_mock_for_creation_and_deletion() -> Arc { + let mut mock_acm = MockAcmApi::new(); + mock_acm.expect_import_certificate().returning(|_| { + Ok(ImportCertificateResponse { + certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" + .to_string(), + }) + }); + mock_acm.expect_delete_certificate().returning(|_| Ok(())); + Arc::new(mock_acm) +} + +fn create_apigatewayv2_mock_for_creation() -> Arc { + let mut mock_apigw = MockApiGatewayV2Api::new(); + mock_apigw.expect_create_api().returning(|_| { + Ok(Api { + api_id: Some("test-api-id".to_string()), + api_endpoint: Some( + "https://test-api-id.execute-api.us-east-1.amazonaws.com".to_string(), + ), + name: None, + protocol_type: None, + }) + }); + mock_apigw.expect_create_integration().returning(|_, _| { + Ok(Integration { + integration_id: Some("test-integration-id".to_string()), + integration_type: None, + integration_uri: None, + }) + }); + mock_apigw.expect_create_route().returning(|_, _| { + Ok(Route { + route_id: Some("test-route-id".to_string()), + route_key: None, + }) + }); + mock_apigw.expect_create_stage().returning(|_, _| { + Ok(Stage { + stage_name: Some("$default".to_string()), + auto_deploy: None, + }) + }); + mock_apigw.expect_create_domain_name().returning(|_| { + Ok(DomainName { + domain_name: Some("test.example.com".to_string()), + domain_name_configurations: Some(vec![DomainNameConfiguration { + certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" + .to_string(), + endpoint_type: "REGIONAL".to_string(), + security_policy: "TLS_1_2".to_string(), + api_gateway_domain_name: Some( + "test.execute-api.us-east-1.amazonaws.com".to_string(), + ), + hosted_zone_id: Some("Z1D633PJN98FT9".to_string()), + }]), + }) + }); + mock_apigw.expect_create_api_mapping().returning(|_, _| { + Ok(ApiMapping { + api_mapping_id: Some("test-mapping-id".to_string()), + api_mapping_key: None, + stage: None, + }) + }); + Arc::new(mock_apigw) +} + +fn create_apigatewayv2_mock_for_creation_and_deletion() -> Arc { + let mut mock_apigw = MockApiGatewayV2Api::new(); + mock_apigw.expect_create_api().returning(|_| { + Ok(Api { + api_id: Some("test-api-id".to_string()), + api_endpoint: Some( + "https://test-api-id.execute-api.us-east-1.amazonaws.com".to_string(), + ), + name: None, + protocol_type: None, + }) + }); + mock_apigw.expect_create_integration().returning(|_, _| { + Ok(Integration { + integration_id: Some("test-integration-id".to_string()), + integration_type: None, + integration_uri: None, + }) + }); + mock_apigw.expect_create_route().returning(|_, _| { + Ok(Route { + route_id: Some("test-route-id".to_string()), + route_key: None, + }) + }); + mock_apigw.expect_create_stage().returning(|_, _| { + Ok(Stage { + stage_name: Some("$default".to_string()), + auto_deploy: None, + }) + }); + mock_apigw.expect_create_domain_name().returning(|_| { + Ok(DomainName { + domain_name: Some("test.example.com".to_string()), + domain_name_configurations: Some(vec![DomainNameConfiguration { + certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" + .to_string(), + endpoint_type: "REGIONAL".to_string(), + security_policy: "TLS_1_2".to_string(), + api_gateway_domain_name: Some( + "test.execute-api.us-east-1.amazonaws.com".to_string(), + ), + hosted_zone_id: Some("Z1D633PJN98FT9".to_string()), + }]), + }) + }); + mock_apigw.expect_create_api_mapping().returning(|_, _| { + Ok(ApiMapping { + api_mapping_id: Some("test-mapping-id".to_string()), + api_mapping_key: None, + stage: None, + }) + }); + mock_apigw + .expect_delete_api_mapping() + .returning(|_, _| Ok(())); + mock_apigw.expect_delete_domain_name().returning(|_| Ok(())); + mock_apigw.expect_delete_api().returning(|_| Ok(())); + Arc::new(mock_apigw) +} + +fn setup_mock_client_for_creation_and_update( + worker_name: &str, + has_url: bool, +) -> Arc { + let mut mock_lambda = MockLambdaApi::new(); + + // Mock successful worker creation + let worker_name = worker_name.to_string(); + let worker_name_for_create = worker_name.clone(); + mock_lambda + .expect_create_function() + .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); + + // Mock worker status checks - first pending, then active + let worker_name_for_get = worker_name.clone(); + mock_lambda + .expect_get_function_configuration() + .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))); + + // Mock API Gateway permission and self-binding env var update if public ingress + if has_url { + mock_lambda + .expect_add_permission() + .returning(|_, _| Ok(AddPermissionResponse { statement: None })); + + let worker_name_for_self_binding = worker_name.clone(); + mock_lambda + .expect_update_function_configuration() + .returning(move |_, _| { + Ok(create_successful_function_response( + &worker_name_for_self_binding, + )) + }); + } + + // Mock concurrency operations (may or may not be called depending on worker config) + mock_lambda + .expect_put_function_concurrency() + .returning(|_, _| Ok(())); + mock_lambda + .expect_delete_function_concurrency() + .returning(|_| Ok(())); + + // Mock successful updates + let worker_name_for_code_update = worker_name.clone(); + mock_lambda + .expect_update_function_code() + .returning(move |_, _| { + Ok(create_successful_function_response( + &worker_name_for_code_update, + )) + }); + + if !has_url { + let worker_name_for_config_update = worker_name.clone(); + mock_lambda + .expect_update_function_configuration() + .returning(move |_, _| { + Ok(create_successful_function_response( + &worker_name_for_config_update, + )) + }); + } + + Arc::new(mock_lambda) +} + +fn setup_mock_client_for_creation_and_deletion( + worker_name: &str, + has_url: bool, +) -> Arc { + let mut mock_lambda = MockLambdaApi::new(); + + // Mock successful worker creation + let worker_name = worker_name.to_string(); + let worker_name_for_create = worker_name.clone(); + mock_lambda + .expect_create_function() + .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); + + // Mock worker status checks + let worker_name_for_get = worker_name.clone(); + mock_lambda + .expect_get_function_configuration() + .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) + .times(1); // Only for creation flow + + // Mock API Gateway permission and self-binding env var update if public ingress + if has_url { + mock_lambda + .expect_add_permission() + .returning(|_, _| Ok(AddPermissionResponse { statement: None })); + + // Mock update_function_configuration for self-binding env var update + let worker_name_for_config_update = worker_name.clone(); + mock_lambda + .expect_update_function_configuration() + .returning(move |_, _| { + Ok(create_successful_function_response( + &worker_name_for_config_update, + )) + }); + } + + // Mock concurrency operations (may or may not be called depending on worker config) + mock_lambda + .expect_put_function_concurrency() + .returning(|_, _| Ok(())); + mock_lambda + .expect_delete_function_concurrency() + .returning(|_| Ok(())); + + // Mock successful worker deletion + mock_lambda + .expect_delete_function() + .returning(|_, _| Ok(())); + + // Mock worker not found during deletion check + mock_lambda + .expect_get_function_configuration() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + + Arc::new(mock_lambda) +} + +fn setup_mock_client_for_best_effort_deletion( + _worker_name: &str, + function_missing: bool, +) -> Arc { + let mut mock_lambda = MockLambdaApi::new(); + + // Mock worker deletion (might fail if worker missing) + if function_missing { + mock_lambda.expect_delete_function().returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + } else { + mock_lambda + .expect_delete_function() + .returning(|_, _| Ok(())); + } + + // Always return not found for final status check + mock_lambda + .expect_get_function_configuration() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + + Arc::new(mock_lambda) +} + +fn create_aws_iam_mock_for_resource_permissions() -> Arc { + let mut mock_iam = MockIamApi::new(); + mock_iam + .expect_put_role_policy() + .returning(|_, _, _| Ok(())); + Arc::new(mock_iam) +} + +fn setup_mock_service_provider( + mock_lambda: Arc, + mock_acm: Option>, + mock_apigw: Option>, +) -> Arc { + let mut mock_provider = MockPlatformServiceProvider::new(); + + mock_provider + .expect_get_aws_lambda_client() + .returning(move |_| Ok(mock_lambda.clone())); + + // Mock IAM client for resource-scoped permissions (ApplyingResourcePermissions state) + let mock_iam = create_aws_iam_mock_for_resource_permissions(); + mock_provider + .expect_get_aws_iam_client() + .returning(move |_| Ok(mock_iam.clone())); + + if let Some(acm) = mock_acm { + mock_provider + .expect_get_aws_acm_client() + .returning(move |_| Ok(acm.clone())); + } + + if let Some(apigw) = mock_apigw { + mock_provider + .expect_get_aws_apigatewayv2_client() + .returning(move |_| Ok(apigw.clone())); + } + + Arc::new(mock_provider) +} + +/// Sets up all mocks for a worker test, including Lambda, ACM, and API Gateway. +/// +/// Returns `(mock_provider, optional_mock_server, optional_domain_metadata, optional_public_urls)`. +/// For public workers, `domain_metadata` and `public_urls` must be set on the executor builder. +/// When a readiness probe is configured, `public_urls` overrides the FQDN URL so the probe +/// hits the local mock HTTP server instead. +fn setup_mocks_for_function( + worker: &Worker, + worker_name: &str, + for_deletion: bool, +) -> ( + Arc, + Option, + Option, + Option, +) { + let has_url = !worker.public_endpoints.is_empty(); + let needs_readiness_probe = has_url && worker.readiness_probe.is_some(); + + // Set up mock server for readiness probe if needed + let mock_server = if needs_readiness_probe { + Some(create_readiness_probe_mock(worker)) + } else { + None + }; + + // Set up Lambda client mock (same for both flows; URL config calls are removed) + let lambda_mock = if for_deletion { + setup_mock_client_for_creation_and_deletion(worker_name, has_url) + } else { + setup_mock_client_for_creation_and_update(worker_name, has_url) + }; + + // Set up ACM and API Gateway mocks for public workers + let (acm_mock, apigw_mock, domain_metadata, public_endpoints) = if has_url { + let dm = create_test_domain_metadata(&worker.id); + let acm = if for_deletion { + create_acm_mock_for_creation_and_deletion() + } else { + create_acm_mock_for_creation() + }; + let apigw = if for_deletion { + create_apigatewayv2_mock_for_creation_and_deletion() + } else { + create_apigatewayv2_mock_for_creation() + }; + // For readiness probe tests, override the FQDN URL with the mock server URL + let pub_endpoints = mock_server.as_ref().map(|server| { + HashMap::from([( + worker.id.clone(), + HashMap::from([("api".to_string(), server.base_url())]), + )]) + }); + (Some(acm), Some(apigw), Some(dm), pub_endpoints) + } else { + (None, None, None, None) + }; + + let mock_provider = setup_mock_service_provider(lambda_mock, acm_mock, apigw_mock); + + ( + mock_provider, + mock_server, + domain_metadata, + public_endpoints, + ) +} + +// ─────────────── CREATE AND DELETE FLOW TESTS ──────────────────── + +#[rstest] +#[case::basic(basic_function(), false)] +#[case::env_vars(function_with_env_vars(), false)] +#[case::storage_link(function_with_storage_link(), false)] +#[case::env_and_storage(function_with_env_and_storage(), false)] +#[case::multiple_storages(function_with_multiple_storages(), false)] +#[case::public_ingress(function_public_ingress(), true)] +#[case::private_ingress(function_private_ingress(), false)] +#[case::concurrency(function_with_concurrency(), false)] +#[case::custom_config(function_custom_config(), false)] +#[case::readiness_probe(function_with_readiness_probe(), true)] +#[case::complete_test(function_complete_test(), true)] +#[tokio::test] +async fn test_create_and_delete_flow_succeeds(#[case] worker: Worker, #[case] _has_url: bool) { + let worker_name = format!("test-{}", worker.id); + let (mock_provider, _mock_server, domain_metadata, public_endpoints) = + setup_mocks_for_function(&worker, &worker_name, true); + + let mut builder = SingleControllerExecutor::builder() + .resource(worker) + .controller(AwsWorkerController::default()) + .platform(Platform::Aws) + .service_provider(mock_provider) + .with_test_dependencies(); + + if let Some(dm) = domain_metadata { + builder = builder.domain_metadata(dm); + } + if let Some(urls) = public_endpoints { + builder = builder.public_endpoints(urls); + } + + let mut executor = builder.build().await.unwrap(); + + // Run create flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify outputs are available + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.identifier.is_some()); + assert!(function_outputs.worker_name.starts_with("test-")); + + // Delete the worker + executor.delete().unwrap(); + + // Run delete flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} + +// ─────────────── UPDATE FLOW TESTS ──────────────────────────────── + +#[rstest] +#[case::basic_to_env(basic_function(), function_with_env_vars())] +#[case::env_to_storage(function_with_env_vars(), function_with_storage_link())] +#[case::storage_to_custom(function_with_storage_link(), function_custom_config())] +#[case::custom_to_public(function_custom_config(), function_public_ingress())] +#[case::public_to_complete(function_public_ingress(), function_complete_test())] +#[case::complete_to_basic(function_complete_test(), basic_function())] +#[tokio::test] +async fn test_update_flow_succeeds(#[case] from_function: Worker, #[case] to_function: Worker) { + // Ensure both workers have the same ID for valid updates + let worker_id = "test-update-worker".to_string(); + let mut from_function = from_function; + from_function.id = worker_id.clone(); + + let mut to_function = to_function; + to_function.id = worker_id.clone(); + + let worker_name = format!("test-{}", worker_id); + let (mock_provider, mock_server, domain_metadata, public_endpoints) = + setup_mocks_for_function(&to_function, &worker_name, false); + + // Start with the "from" worker in Ready state + let mut ready_controller = AwsWorkerController::mock_ready(&worker_name); + + // If the target worker has a readiness probe, update the controller URL to point to mock server + if to_function.readiness_probe.is_some() && !to_function.public_endpoints.is_empty() { + if let Some(ref server) = mock_server { + ready_controller.url = Some(server.base_url()); + } + } + + let mut builder = SingleControllerExecutor::builder() + .resource(from_function) + .controller(ready_controller) + .platform(Platform::Aws) + .service_provider(mock_provider) + .with_test_dependencies(); + + if let Some(dm) = domain_metadata { + builder = builder.domain_metadata(dm); + } + if let Some(urls) = public_endpoints { + builder = builder.public_endpoints(urls); + } + + let mut executor = builder.build().await.unwrap(); + + // Ensure we start in Running state + assert_eq!(executor.status(), ResourceStatus::Running); + + // Update to the new worker + let target_is_public = !to_function.public_endpoints.is_empty(); + executor.update(to_function).unwrap(); + + // Run the update flow + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + if target_is_public { + let url = function_outputs + .public_endpoints + .get("default") + .expect("default public endpoint") + .url + .as_str(); + assert!(url.starts_with("http://") || url.starts_with("https://")); + assert!(!url.contains("lambda-url")); + } +} + +// ─────────────── BEST EFFORT DELETION TESTS ─────────────────────── + +#[rstest] +#[case::basic(basic_function(), false)] +#[case::public_with_missing_function(function_public_ingress(), true)] +#[case::public(function_public_ingress(), false)] +#[case::private_with_missing_function(function_private_ingress(), true)] +#[tokio::test] +async fn test_best_effort_deletion_when_resources_missing( + #[case] worker: Worker, + #[case] function_missing: bool, +) { + let worker_name = format!("test-{}", worker.id); + let has_url = !worker.public_endpoints.is_empty(); + let mock_lambda = setup_mock_client_for_best_effort_deletion(&worker_name, function_missing); + let mock_provider = setup_mock_service_provider(mock_lambda, None, None); + + // Start with a ready controller + let mut ready_controller = AwsWorkerController::mock_ready(&worker_name); + if has_url { + ready_controller.url = + Some("https://example.execute-api.us-east-1.amazonaws.com".to_string()); + } + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(ready_controller) + .platform(Platform::Aws) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + // Ensure we start in Running state + assert_eq!(executor.status(), ResourceStatus::Running); + + // Delete the worker + executor.delete().unwrap(); + + // Run the delete flow - it should succeed even when resources are missing + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + // Verify outputs are no longer available + assert!(executor.outputs().is_none()); +} + +// ─────────────── SPECIFIC VALIDATION TESTS ───────────────── + +/// Test that verifies public workers go through ACM certificate import and API Gateway setup. +#[tokio::test] +async fn test_public_function_creates_api_gateway_and_certificate() { + let worker = function_public_ingress(); + let worker_name = format!("test-{}", worker.id); + let domain_metadata = create_test_domain_metadata(&worker.id); + + let mut mock_lambda = MockLambdaApi::new(); + + // Mock worker creation + let worker_name_for_create = worker_name.clone(); + mock_lambda + .expect_create_function() + .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); + + let worker_name_for_get = worker_name.clone(); + mock_lambda + .expect_get_function_configuration() + .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) + .times(1); + + // Validate API Gateway permission is added with the correct apigateway principal + mock_lambda + .expect_add_permission() + .withf(|_, request| { + request.statement_id == "ApiGatewayInvoke" + && request.action == "lambda:InvokeFunction" + && request.principal == "apigateway.amazonaws.com" + }) + .returning(|_, _| Ok(AddPermissionResponse { statement: None })); + + // Mock self-binding env var update + let worker_name_for_config_update = worker_name.clone(); + mock_lambda + .expect_update_function_configuration() + .returning(move |_, _| { + Ok(create_successful_function_response( + &worker_name_for_config_update, + )) + }); + + mock_lambda + .expect_delete_function() + .returning(|_, _| Ok(())); + mock_lambda + .expect_get_function_configuration() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + + // Validate ACM certificate import + let mut mock_acm = MockAcmApi::new(); + mock_acm + .expect_import_certificate() + .times(1) + .returning(|_| { + Ok(ImportCertificateResponse { + certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" + .to_string(), + }) + }); + mock_acm.expect_delete_certificate().returning(|_| Ok(())); + + // Validate API Gateway is created with the worker's name in the API name + let mut mock_apigw = MockApiGatewayV2Api::new(); + mock_apigw + .expect_create_api() + .withf(|request| request.name.contains("public-func")) + .returning(|_| { + Ok(Api { + api_id: Some("test-api-id".to_string()), + api_endpoint: None, + name: None, + protocol_type: None, + }) + }); + mock_apigw.expect_create_integration().returning(|_, _| { + Ok(Integration { + integration_id: Some("test-integration-id".to_string()), + integration_type: None, + integration_uri: None, + }) + }); + mock_apigw.expect_create_route().returning(|_, _| { + Ok(Route { + route_id: Some("test-route-id".to_string()), + route_key: None, + }) + }); + mock_apigw.expect_create_stage().returning(|_, _| { + Ok(Stage { + stage_name: Some("$default".to_string()), + auto_deploy: None, + }) + }); + mock_apigw.expect_create_domain_name().returning(|_| { + Ok(DomainName { + domain_name: Some("public-func.test.example.com".to_string()), + domain_name_configurations: Some(vec![DomainNameConfiguration { + certificate_arn: "arn:aws:acm:us-east-1:123456789012:certificate/test-cert-id" + .to_string(), + endpoint_type: "REGIONAL".to_string(), + security_policy: "TLS_1_2".to_string(), + api_gateway_domain_name: Some( + "test.execute-api.us-east-1.amazonaws.com".to_string(), + ), + hosted_zone_id: Some("Z1D633PJN98FT9".to_string()), + }]), + }) + }); + mock_apigw.expect_create_api_mapping().returning(|_, _| { + Ok(ApiMapping { + api_mapping_id: Some("test-mapping-id".to_string()), + api_mapping_key: None, + stage: None, + }) + }); + mock_apigw + .expect_delete_api_mapping() + .returning(|_, _| Ok(())); + mock_apigw.expect_delete_domain_name().returning(|_| Ok(())); + mock_apigw.expect_delete_api().returning(|_| Ok(())); + + let mock_provider = setup_mock_service_provider( + Arc::new(mock_lambda), + Some(Arc::new(mock_acm)), + Some(Arc::new(mock_apigw)), + ); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AwsWorkerController::default()) + .platform(Platform::Aws) + .service_provider(mock_provider) + .domain_metadata(domain_metadata) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify URL is in outputs (derived from domain_metadata FQDN) + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.public_endpoints.contains_key("default")); +} + +/// Test that verifies private workers don't get URL creation +#[tokio::test] +async fn test_private_function_skips_url_creation() { + let worker = function_private_ingress(); + let worker_name = format!("test-{}", worker.id); + + let mut mock_lambda = MockLambdaApi::new(); + + // Mock worker creation + let worker_name_for_create = worker_name.clone(); + mock_lambda + .expect_create_function() + .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); + + let worker_name_for_get = worker_name.clone(); + mock_lambda + .expect_get_function_configuration() + .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) + .times(1); + + // API Gateway and permission should NOT be called for private workers + mock_lambda.expect_add_permission().times(0); + + mock_lambda + .expect_delete_function() + .returning(|_, _| Ok(())); + mock_lambda + .expect_get_function_configuration() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_lambda), None, None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AwsWorkerController::default()) + .platform(Platform::Aws) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); + + // Verify no URL in outputs + let outputs = executor.outputs().unwrap(); + let function_outputs = outputs.downcast_ref::().unwrap(); + assert!(function_outputs.public_endpoints.is_empty()); +} + +/// Test that verifies correct worker configuration parameters +#[tokio::test] +async fn test_worker_configuration_validation() { + let worker = function_custom_config(); + let worker_name = format!("test-{}", worker.id); + + let mut mock_lambda = MockLambdaApi::new(); + + // Validate worker creation request has correct parameters + let worker_name_for_create = worker_name.clone(); + mock_lambda + .expect_create_function() + .withf(|request| { + request.memory_size == Some(512) + && request.timeout == Some(120) + && request.package_type == "Image" + && request + .architectures + .as_ref() + .map(|a| a.contains(&"arm64".to_string())) + .unwrap_or(false) + }) + .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); + + let worker_name_for_get = worker_name.clone(); + mock_lambda + .expect_get_function_configuration() + .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) + .times(1); + + mock_lambda + .expect_delete_function() + .returning(|_, _| Ok(())); + mock_lambda + .expect_get_function_configuration() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_lambda), None, None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AwsWorkerController::default()) + .platform(Platform::Aws) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} + +/// Test that verifies environment variables are correctly passed +#[tokio::test] +async fn test_environment_variable_handling() { + let worker = function_with_env_vars(); + let worker_name = format!("test-{}", worker.id); + + let mut mock_lambda = MockLambdaApi::new(); + + // Validate worker creation request has environment variables + let worker_name_for_create = worker_name.clone(); + mock_lambda + .expect_create_function() + .withf(|request| { + if let Some(env) = &request.environment { + if let Some(vars) = &env.variables { + vars.get("APP_ENV") == Some(&"production".to_string()) + && vars.get("LOG_LEVEL") == Some(&"debug".to_string()) + && vars.get("DB_NAME") == Some(&"myapp".to_string()) + } else { + false + } + } else { + false + } + }) + .returning(move |_| Ok(create_successful_function_response(&worker_name_for_create))); + + let worker_name_for_get = worker_name.clone(); + mock_lambda + .expect_get_function_configuration() + .returning(move |_, _| Ok(create_successful_function_response(&worker_name_for_get))) + .times(1); + + mock_lambda + .expect_delete_function() + .returning(|_, _| Ok(())); + mock_lambda + .expect_get_function_configuration() + .returning(|_, _| { + Err(AlienError::new( + CloudClientErrorData::RemoteResourceNotFound { + resource_type: "Worker".to_string(), + resource_name: "test-worker".to_string(), + }, + )) + }); + + let mock_provider = setup_mock_service_provider(Arc::new(mock_lambda), None, None); + + let mut executor = SingleControllerExecutor::builder() + .resource(worker) + .controller(AwsWorkerController::default()) + .platform(Platform::Aws) + .service_provider(mock_provider) + .with_test_dependencies() + .build() + .await + .unwrap(); + + executor.run_until_terminal().await.unwrap(); + assert_eq!(executor.status(), ResourceStatus::Running); +} From b8a2c813f6658792d97848daff3e3138f85cc885 Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 16:12:18 +0900 Subject: [PATCH 05/18] refactor: split gcp compute client into modules Break the 5,700-line gcp/compute.rs into a directory module so each concern can be read and reviewed in isolation: - compute/mod.rs: module doc, ComputeServiceConfig, and re-exports - compute/api.rs: the ComputeApi trait (automock still generates MockComputeApi here, re-exported unchanged) - compute/client.rs: ComputeClient and its ComputeApi impl - compute/types/: the serde data structures, split along the existing section banners (operations, network, load_balancer, instance) - compute/tests.rs: the PSC serde test module Pure code motion: no items were renamed or reordered; external paths are unchanged via glob re-exports from compute/mod.rs. Note in gcp/AGENTS.md that compute/ is the directory-module variant of the cloudrun.rs pattern. --- crates/alien-gcp-clients/src/gcp/AGENTS.md | 2 +- crates/alien-gcp-clients/src/gcp/compute.rs | 5682 ----------------- .../alien-gcp-clients/src/gcp/compute/api.rs | 535 ++ .../src/gcp/compute/client.rs | 1323 ++++ .../alien-gcp-clients/src/gcp/compute/mod.rs | 63 + .../src/gcp/compute/tests.rs | 90 + .../src/gcp/compute/types/instance.rs | 1224 ++++ .../src/gcp/compute/types/load_balancer.rs | 1281 ++++ .../src/gcp/compute/types/mod.rs | 9 + .../src/gcp/compute/types/network.rs | 987 +++ .../src/gcp/compute/types/operations.rs | 199 + 11 files changed, 5712 insertions(+), 5683 deletions(-) delete mode 100644 crates/alien-gcp-clients/src/gcp/compute.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/api.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/client.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/mod.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/tests.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/types/instance.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/types/load_balancer.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/types/mod.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/types/network.rs create mode 100644 crates/alien-gcp-clients/src/gcp/compute/types/operations.rs diff --git a/crates/alien-gcp-clients/src/gcp/AGENTS.md b/crates/alien-gcp-clients/src/gcp/AGENTS.md index 64d6e0470..4cb4b027c 100644 --- a/crates/alien-gcp-clients/src/gcp/AGENTS.md +++ b/crates/alien-gcp-clients/src/gcp/AGENTS.md @@ -1,6 +1,6 @@ ## 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. 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 65cf1ad37..000000000 --- a/crates/alien-gcp-clients/src/gcp/compute.rs +++ /dev/null @@ -1,5682 +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; - - // --- 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 - } - - // --- 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 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()); - } -} 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..f988c0df5 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/api.rs @@ -0,0 +1,535 @@ +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; + + // --- 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..fec9a3f01 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/client.rs @@ -0,0 +1,1323 @@ +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 + } + + // --- 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..4b7c6dd78 --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/tests.rs @@ -0,0 +1,90 @@ +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()); +} 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..63065d17d --- /dev/null +++ b/crates/alien-gcp-clients/src/gcp/compute/types/load_balancer.rs @@ -0,0 +1,1281 @@ +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 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 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, +} From a32f80d100fc88e24513cae62176b10fa14dc238 Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 16:40:08 +0900 Subject: [PATCH 06/18] refactor: split alien-build lib into modules Break the 4,800-line lib.rs into concern modules so each area can be read and reviewed in isolation: - lib.rs: build_stack orchestration, build_resource, and build_target_to_file - push.rs: push_stack, push targets, OCI tarball selection, and image index assembly - cache.rs: artifact cache keys, source hashing, cargo metadata, and cached artifact lookup - base_images.rs: base image resolution, entrypoint/cmd handling, and pull/retry classification - tests.rs: the unit test module, still gated by #[cfg(test)] Pure code motion: moved items are bumped to pub(crate); the public API is unchanged via the root pub use of push_stack. --- crates/alien-build/src/base_images.rs | 337 +++ crates/alien-build/src/cache.rs | 671 +++++ crates/alien-build/src/lib.rs | 3331 +------------------------ crates/alien-build/src/push.rs | 688 +++++ crates/alien-build/src/tests.rs | 1633 ++++++++++++ 5 files changed, 3360 insertions(+), 3300 deletions(-) create mode 100644 crates/alien-build/src/base_images.rs create mode 100644 crates/alien-build/src/cache.rs create mode 100644 crates/alien-build/src/push.rs create mode 100644 crates/alien-build/src/tests.rs 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 697975cc6..cd766bc5f 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,552 +1228,22 @@ async fn build_resource( Ok(finalized_dir) } -async fn compute_source_artifact_cache_key( +/// Build a specific OS/architecture target to an OCI tarball file +#[allow(clippy::too_many_arguments)] +async fn build_target_to_file( src: &str, toolchain_config: &alien_core::ToolchainConfig, + resource_name: &str, + stack_id: &str, settings: &BuildSettings, - targets: &[BinaryTarget], + target: &BinaryTarget, + output_path: &Path, 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())) -} - -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(()) -} - -/// Build a specific OS/architecture target to an OCI tarball file -#[allow(clippy::too_many_arguments)] -async fn build_target_to_file( - src: &str, - toolchain_config: &alien_core::ToolchainConfig, - resource_name: &str, - stack_id: &str, - settings: &BuildSettings, - target: &BinaryTarget, - output_path: &Path, - workload: toolchain::WorkloadKind, -) -> Result { - info!( - "Starting toolchain build for resource: {} (target: {})", - resource_name, - target.runtime_platform_id() + info!( + "Starting toolchain build for resource: {} (target: {})", + resource_name, + target.runtime_platform_id() ); // Parse cache store from cache URL if provided @@ -2809,2009 +1546,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::*; - 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..798e6bab8 --- /dev/null +++ b/crates/alien-build/src/tests.rs @@ -0,0 +1,1633 @@ +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; + +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" + ); +} From c2200ebfa5acf58a2cb5446648e55b873c4e65cb Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Sun, 19 Jul 2026 17:08:21 +0900 Subject: [PATCH 07/18] chore: enforce max file length (2000 lines) in hk checks --- hk.pkl | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/hk.pkl b/hk.pkl index 082295478..c90178474 100644 --- a/hk.pkl +++ b/hk.pkl @@ -10,6 +10,39 @@ local fast_steps = new Mapping { } ["check-added-large-files"] = Builtins.check_added_large_files ["check-merge-conflict"] = Builtins.check_merge_conflict + ["max-lines"] { + glob = List("*.rs", "*.ts", "*.tsx", "*.js", "*.jsx") + exclude = List( + "client-sdks/**", // generated (Speakeasy SDK codegen) + "packages/sdk/src/worker-runtime/generated/**", // generated + // The #[controller] macro requires the annotated struct and its single + // annotated impl block to live in one module, so these cannot be split further. + "crates/alien-infra/src/worker/gcp/mod.rs", + "crates/alien-infra/src/worker/aws/mod.rs", + "crates/alien-infra/src/worker/azure/mod.rs", + "crates/alien-test/src/distribution.rs", // legacy, pending split + "crates/alien-helm/src/generator.rs", // legacy, pending split + "crates/alien-deploy-cli/src/commands/up.rs", // legacy, pending split + "crates/alien-aws-clients/src/aws/ec2.rs", // legacy, pending split + "crates/alien-gcp-clients/tests/gcp_compute_client_tests.rs", // legacy, pending split + "crates/alien-terraform/src/generator.rs", // legacy, pending split + "crates/alien-core/src/heartbeat.rs", // legacy, pending split + "crates/alien-deploy-cli/src/commands/join.rs", // legacy, pending split + "crates/alien-infra/src/kubernetes_public_endpoint.rs", // legacy, pending split + "crates/alien-infra/src/network/aws.rs", // legacy, pending split + "crates/alien-aws-clients/tests/aws_s3_client_tests.rs", // legacy, pending split + "crates/alien-cloudformation/src/generator.rs", // legacy, pending split + "crates/alien-bindings/src/provider.rs", // legacy, pending split + "crates/alien-bindings/tests/storage.rs", // legacy, pending split + "crates/alien-infra/src/container/kubernetes.rs", // legacy, pending split + "crates/alien-infra/src/network/azure.rs", // legacy, pending split + "crates/alien-infra/src/core/executor.rs", // legacy, pending split + "crates/alien-commands/src/server/mod.rs", // legacy, pending split + "crates/alien-cli/src/commands/release.rs", // legacy, pending split + "crates/alien-preflights/src/mutations/compute_cluster.rs" // legacy, pending split + ) + check = #"wc -l {{files}} | awk '$2 != "total" && $1 > 2000 { printf "%s has %s lines (max 2000) - split this file or add an hk.pkl exclude\n", $2, $1; found = 1 } END { exit found }'"# + } ["newlines"] = Builtins.newlines ["rustfmt"] = (Builtins.rustfmt) { check = "rustfmt --check --edition 2021 {{files}}" From 8e37376f50d8643e3174c58a9a7e2bdc8e70c356 Mon Sep 17 00:00:00 2001 From: Dan Lilienblum Date: Mon, 20 Jul 2026 12:24:44 +0900 Subject: [PATCH 08/18] refactor: split helm generator into modules Break the 4,647-line generator.rs into a generator/ directory so each concern can be read and reviewed in isolation: - mod.rs: the options types, the generate_helm_chart and render_manager_fetch_values entrypoints, and shared JSON/YAML/name helpers - operator.rs: generate_operator_manifest and the operator document builders - values.rs: ChartAnalysis, values.yaml assembly, and cloud-identity mapping - schema.rs: values.schema.json - templates.rs: the Helm template (.tpl) bodies - examples.rs: the per-target values examples and README - tests.rs: the unit test module, still gated by #[cfg(test)] Pure code motion: moved helpers are bumped to pub(super); the public API is unchanged (lib.rs still re-exports generate_operator_manifest and the other entrypoints). The file drops off the hk.pkl max-lines exclude now that every module is under the 2000-line cap. --- crates/alien-helm/src/generator.rs | 4647 ------------------ crates/alien-helm/src/generator/examples.rs | 111 + crates/alien-helm/src/generator/mod.rs | 451 ++ crates/alien-helm/src/generator/operator.rs | 744 +++ crates/alien-helm/src/generator/schema.rs | 496 ++ crates/alien-helm/src/generator/templates.rs | 1204 +++++ crates/alien-helm/src/generator/tests.rs | 805 +++ crates/alien-helm/src/generator/values.rs | 867 ++++ hk.pkl | 1 - 9 files changed, 4678 insertions(+), 4648 deletions(-) delete mode 100644 crates/alien-helm/src/generator.rs create mode 100644 crates/alien-helm/src/generator/examples.rs create mode 100644 crates/alien-helm/src/generator/mod.rs create mode 100644 crates/alien-helm/src/generator/operator.rs create mode 100644 crates/alien-helm/src/generator/schema.rs create mode 100644 crates/alien-helm/src/generator/templates.rs create mode 100644 crates/alien-helm/src/generator/tests.rs create mode 100644 crates/alien-helm/src/generator/values.rs 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 ^(?