Skip to main content

alien_core/
client_config.rs

1//! Client configuration structures for different cloud platforms
2//!
3//! This module contains the configuration structs for all supported cloud platforms.
4//! These structs define the authentication and platform-specific settings needed
5//! to connect to cloud services, but do not contain implementation logic (which
6//! remains in the respective client crates).
7
8use crate::Platform;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Service endpoint overrides for testing AWS services
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct AwsServiceOverrides {
17    /// Override endpoints for specific AWS services
18    /// Key is the service name (e.g., "lambda", "s3"), value is the base URL
19    pub endpoints: HashMap<String, String>,
20}
21
22/// Configuration for AWS role impersonation
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct AwsImpersonationConfig {
27    /// The ARN of the role to assume
28    pub role_arn: String,
29    /// Optional session name for the assumed role session
30    pub session_name: Option<String>,
31    /// Optional duration for the assumed role credentials (in seconds)
32    pub duration_seconds: Option<i32>,
33    /// Optional external ID for the assume role operation
34    pub external_id: Option<String>,
35    /// Optional target region override. When provided, the impersonated config
36    /// uses this region instead of inheriting the caller's region. Required for
37    /// cross-region impersonation (e.g., management in us-east-1 targeting us-east-2).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub target_region: Option<String>,
40}
41
42/// Configuration for AWS Web Identity Token authentication
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct AwsWebIdentityConfig {
47    /// The ARN of the role to assume
48    pub role_arn: String,
49    /// Optional session name for the assumed role session
50    pub session_name: Option<String>,
51    /// The path to the web identity token file
52    pub web_identity_token_file: String,
53    /// Optional duration for the assumed role credentials (in seconds)
54    pub duration_seconds: Option<i32>,
55}
56
57/// Supported AWS authentication methods
58#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[serde(rename_all = "camelCase", tag = "type")]
61pub enum AwsCredentials {
62    /// Static direct access keys.
63    AccessKeys {
64        /// AWS Access Key ID
65        access_key_id: String,
66        /// AWS Secret Access Key
67        secret_access_key: String,
68        /// Optional AWS Session Token
69        session_token: Option<String>,
70    },
71    /// Temporary AWS session credentials with an expiration time.
72    SessionCredentials {
73        /// AWS Access Key ID
74        access_key_id: String,
75        /// AWS Secret Access Key
76        secret_access_key: String,
77        /// AWS Session Token
78        session_token: String,
79        /// Credential expiration as an RFC3339 timestamp
80        expires_at: String,
81    },
82    /// AWS Instance Metadata Service credentials.
83    Imds {
84        /// Optional IMDS endpoint override
85        endpoint: Option<String>,
86    },
87    /// AWS profile credentials loaded via the AWS CLI.
88    Profile {
89        /// AWS profile name
90        name: String,
91    },
92    /// Web Identity Token for OIDC authentication
93    WebIdentity {
94        /// Web identity configuration
95        config: AwsWebIdentityConfig,
96    },
97}
98
99impl std::fmt::Debug for AwsCredentials {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            AwsCredentials::AccessKeys {
103                access_key_id,
104                session_token,
105                ..
106            } => f
107                .debug_struct("AwsCredentials::AccessKeys")
108                .field("access_key_id", access_key_id)
109                .field("secret_access_key", &"[REDACTED]")
110                .field(
111                    "session_token",
112                    &session_token.as_ref().map(|_| "[REDACTED]"),
113                )
114                .finish(),
115            AwsCredentials::SessionCredentials {
116                access_key_id,
117                expires_at,
118                ..
119            } => f
120                .debug_struct("AwsCredentials::SessionCredentials")
121                .field("access_key_id", access_key_id)
122                .field("secret_access_key", &"[REDACTED]")
123                .field("session_token", &"[REDACTED]")
124                .field("expires_at", expires_at)
125                .finish(),
126            AwsCredentials::Imds { endpoint } => f
127                .debug_struct("AwsCredentials::Imds")
128                .field("endpoint", endpoint)
129                .finish(),
130            AwsCredentials::Profile { name } => f
131                .debug_struct("AwsCredentials::Profile")
132                .field("name", name)
133                .finish(),
134            AwsCredentials::WebIdentity { config } => f
135                .debug_struct("AwsCredentials::WebIdentity")
136                .field("config", config)
137                .finish(),
138        }
139    }
140}
141
142/// AWS client configuration
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct AwsClientConfig {
147    /// The AWS Account ID.
148    pub account_id: String,
149    /// The AWS region.
150    pub region: String,
151    /// AWS authentication credentials.
152    pub credentials: AwsCredentials,
153    /// Service endpoint overrides for testing
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub service_overrides: Option<AwsServiceOverrides>,
156}
157
158/// Service endpoint overrides for testing GCP services
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
161#[serde(rename_all = "camelCase", deny_unknown_fields)]
162pub struct GcpServiceOverrides {
163    /// Override endpoints for specific GCP services
164    /// Key is the service name (e.g., "cloudrun", "storage"), value is the base URL
165    pub endpoints: HashMap<String, String>,
166}
167
168/// Authentication options for talking to GCP APIs.
169#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
171#[serde(rename_all = "camelCase", tag = "type")]
172pub enum GcpCredentials {
173    /// Use an already-minted OAuth2 access token.
174    AccessToken { token: String },
175
176    /// Use a refreshable service account impersonation source.
177    ImpersonatedServiceAccount {
178        /// Source configuration used to call IAMCredentials.
179        #[cfg_attr(feature = "openapi", schema(value_type = Object))]
180        source: Box<GcpClientConfig>,
181        /// Service account impersonation request.
182        config: GcpImpersonationConfig,
183    },
184
185    /// Use a full Service Account JSON key (as string). A short-lived JWT will
186    /// be created and exchanged for a bearer token automatically.
187    ServiceAccountKey { json: String },
188
189    /// Use GCP metadata server for authentication (for instances running on GCP)
190    ServiceMetadata,
191
192    /// Use projected service account token (for Kubernetes workload identity)
193    ProjectedServiceAccount {
194        /// Path to the projected service account token
195        token_file: String,
196        /// Service account email
197        service_account_email: String,
198    },
199
200    /// Use an external account credential configuration.
201    ExternalAccount {
202        /// Workload identity audience.
203        audience: String,
204        /// Subject token type for STS token exchange.
205        subject_token_type: String,
206        /// STS token exchange URL.
207        token_url: String,
208        /// Path to the subject token file.
209        credential_source_file: String,
210        /// Optional service account impersonation URL.
211        service_account_impersonation_url: Option<String>,
212    },
213
214    /// Use gcloud Application Default Credentials (authorized_user).
215    /// Exchanges refresh_token for an access_token via Google's OAuth2 endpoint.
216    AuthorizedUser {
217        /// OAuth2 client ID
218        client_id: String,
219        /// OAuth2 client secret
220        client_secret: String,
221        /// OAuth2 refresh token
222        refresh_token: String,
223    },
224}
225
226impl std::fmt::Debug for GcpCredentials {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        match self {
229            GcpCredentials::AccessToken { .. } => f
230                .debug_struct("GcpCredentials::AccessToken")
231                .field("token", &"[REDACTED]")
232                .finish(),
233            GcpCredentials::ImpersonatedServiceAccount { source, config } => f
234                .debug_struct("GcpCredentials::ImpersonatedServiceAccount")
235                .field("source_project_id", &source.project_id)
236                .field("source_region", &source.region)
237                .field("service_account_email", &config.service_account_email)
238                .finish(),
239            GcpCredentials::ServiceAccountKey { .. } => f
240                .debug_struct("GcpCredentials::ServiceAccountKey")
241                .field("json", &"[REDACTED]")
242                .finish(),
243            GcpCredentials::ServiceMetadata => write!(f, "GcpCredentials::ServiceMetadata"),
244            GcpCredentials::ProjectedServiceAccount {
245                token_file,
246                service_account_email,
247            } => f
248                .debug_struct("GcpCredentials::ProjectedServiceAccount")
249                .field("token_file", token_file)
250                .field("service_account_email", service_account_email)
251                .finish(),
252            GcpCredentials::ExternalAccount {
253                audience,
254                subject_token_type,
255                token_url,
256                credential_source_file,
257                service_account_impersonation_url,
258            } => f
259                .debug_struct("GcpCredentials::ExternalAccount")
260                .field("audience", audience)
261                .field("subject_token_type", subject_token_type)
262                .field("token_url", token_url)
263                .field("credential_source_file", credential_source_file)
264                .field(
265                    "service_account_impersonation_url",
266                    service_account_impersonation_url,
267                )
268                .finish(),
269            GcpCredentials::AuthorizedUser { client_id, .. } => f
270                .debug_struct("GcpCredentials::AuthorizedUser")
271                .field("client_id", client_id)
272                .field("client_secret", &"[REDACTED]")
273                .field("refresh_token", &"[REDACTED]")
274                .finish(),
275        }
276    }
277}
278
279/// Configuration for GCP service account impersonation
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283pub struct GcpImpersonationConfig {
284    /// The email of the service account to impersonate
285    pub service_account_email: String,
286    /// The OAuth 2.0 scopes that define the access token's permissions
287    pub scopes: Vec<String>,
288    /// Optional sequence of service accounts in a delegation chain
289    pub delegates: Option<Vec<String>>,
290    /// Optional desired lifetime duration of the access token (max 3600s)
291    pub lifetime: Option<String>,
292    /// Optional target project ID override. When provided, the impersonated config
293    /// uses this project ID instead of inheriting the caller's project.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub target_project_id: Option<String>,
296    /// Optional target region override. When provided, the impersonated config
297    /// uses this region instead of inheriting the caller's region.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub target_region: Option<String>,
300}
301
302impl Default for GcpImpersonationConfig {
303    fn default() -> Self {
304        Self {
305            service_account_email: String::new(),
306            scopes: vec!["https://www.googleapis.com/auth/cloud-platform".to_string()],
307            delegates: None,
308            lifetime: Some("3600s".to_string()),
309            target_project_id: None,
310            target_region: None,
311        }
312    }
313}
314
315/// GCP client configuration
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
318#[serde(rename_all = "camelCase", deny_unknown_fields)]
319pub struct GcpClientConfig {
320    /// The GCP Project ID.
321    pub project_id: String,
322    /// The GCP region for resources.
323    pub region: String,
324    /// GCP authentication credentials.
325    pub credentials: GcpCredentials,
326    /// Service endpoint overrides for testing
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub service_overrides: Option<GcpServiceOverrides>,
329    /// The GCP project number (numeric). Resolved at runtime via Resource Manager API.
330    /// Used in IAM condition expressions where resource.name uses project number.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub project_number: Option<String>,
333}
334
335/// Service endpoint overrides for testing Azure services
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
338#[serde(rename_all = "camelCase", deny_unknown_fields)]
339pub struct AzureServiceOverrides {
340    /// Override endpoints for specific Azure services
341    /// Key is the service name (e.g., "management", "storage", "containerApps"), value is the base URL
342    pub endpoints: HashMap<String, String>,
343}
344
345/// Represents Azure authentication credentials
346#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
347#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
348#[serde(rename_all = "camelCase", tag = "type")]
349pub enum AzureCredentials {
350    /// Service principal with client secret
351    ServicePrincipal {
352        /// The client ID (application ID)
353        client_id: String,
354        /// The client secret
355        client_secret: String,
356    },
357    /// Direct access token
358    AccessToken {
359        /// The bearer token to use for authentication
360        token: String,
361    },
362    /// Short-lived bearer tokens keyed by their exact Azure OAuth scope.
363    ///
364    /// This is the only Azure credential form returned by the credential mint
365    /// endpoint. It contains no refreshable source credential and must not be
366    /// used for a scope that is absent from the map.
367    ScopedAccessTokens {
368        /// Exact scope-to-token map. Minted configs include only the Azure
369        /// management, storage, Key Vault, and Service Bus scopes used by
370        /// Alien bindings.
371        tokens: HashMap<String, String>,
372    },
373    /// Azure VM IMDS managed identity.
374    VmManagedIdentity {
375        /// The client ID of the user-assigned managed identity
376        client_id: String,
377        /// Optional IMDS endpoint override
378        identity_endpoint: Option<String>,
379    },
380    /// Azure AD Workload Identity (federated identity)
381    WorkloadIdentity {
382        /// The client ID of the managed identity or application
383        client_id: String,
384        /// The tenant ID for authentication
385        tenant_id: String,
386        /// Path to the federated token file
387        federated_token_file: String,
388        /// The authority host URL
389        authority_host: String,
390    },
391    /// Azure Managed Identity (Container Apps / App Service)
392    /// Uses IDENTITY_ENDPOINT + IDENTITY_HEADER injected by the platform
393    ManagedIdentity {
394        /// The client ID of the user-assigned managed identity
395        client_id: String,
396        /// The identity endpoint URL (from IDENTITY_ENDPOINT env var)
397        identity_endpoint: String,
398        /// The identity header secret (from IDENTITY_HEADER env var)
399        identity_header: String,
400    },
401}
402
403impl std::fmt::Debug for AzureCredentials {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        match self {
406            AzureCredentials::ServicePrincipal { client_id, .. } => f
407                .debug_struct("AzureCredentials::ServicePrincipal")
408                .field("client_id", client_id)
409                .field("client_secret", &"[REDACTED]")
410                .finish(),
411            AzureCredentials::AccessToken { .. } => f
412                .debug_struct("AzureCredentials::AccessToken")
413                .field("token", &"[REDACTED]")
414                .finish(),
415            AzureCredentials::ScopedAccessTokens { tokens } => f
416                .debug_struct("AzureCredentials::ScopedAccessTokens")
417                .field("scopes", &tokens.keys().collect::<Vec<_>>())
418                .field("tokens", &"[REDACTED]")
419                .finish(),
420            AzureCredentials::VmManagedIdentity {
421                client_id,
422                identity_endpoint,
423            } => f
424                .debug_struct("AzureCredentials::VmManagedIdentity")
425                .field("client_id", client_id)
426                .field("identity_endpoint", identity_endpoint)
427                .finish(),
428            AzureCredentials::WorkloadIdentity {
429                client_id,
430                tenant_id,
431                federated_token_file,
432                authority_host,
433            } => f
434                .debug_struct("AzureCredentials::WorkloadIdentity")
435                .field("client_id", client_id)
436                .field("tenant_id", tenant_id)
437                .field("federated_token_file", federated_token_file)
438                .field("authority_host", authority_host)
439                .finish(),
440            AzureCredentials::ManagedIdentity {
441                client_id,
442                identity_endpoint,
443                ..
444            } => f
445                .debug_struct("AzureCredentials::ManagedIdentity")
446                .field("client_id", client_id)
447                .field("identity_endpoint", identity_endpoint)
448                .field("identity_header", &"[REDACTED]")
449                .finish(),
450        }
451    }
452}
453
454/// Configuration for Azure managed identity impersonation
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
457#[serde(rename_all = "camelCase", deny_unknown_fields)]
458pub struct AzureImpersonationConfig {
459    /// The client ID of the managed identity or service principal to impersonate
460    pub client_id: String,
461    /// The scope for the access token (e.g., "https://management.azure.com/.default")
462    pub scope: String,
463    /// Optional tenant ID for cross-tenant impersonation
464    pub tenant_id: Option<String>,
465    /// Optional target subscription ID override. When provided, the impersonated config
466    /// uses this subscription instead of inheriting the caller's subscription.
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub target_subscription_id: Option<String>,
469    /// Optional target region override. When provided, the impersonated config
470    /// uses this region instead of inheriting the caller's region.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub target_region: Option<String>,
473}
474
475impl Default for AzureImpersonationConfig {
476    fn default() -> Self {
477        Self {
478            client_id: String::new(),
479            scope: "https://management.azure.com/.default".to_string(),
480            tenant_id: None,
481            target_subscription_id: None,
482            target_region: None,
483        }
484    }
485}
486
487/// Azure client configuration
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
489#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
490#[serde(rename_all = "camelCase", deny_unknown_fields)]
491pub struct AzureClientConfig {
492    /// The Azure Subscription ID where resources will be deployed.
493    pub subscription_id: String,
494    /// The customer's Azure Tenant ID.
495    pub tenant_id: String,
496    /// Azure region for resources.
497    pub region: Option<String>,
498    /// Azure authentication credentials.
499    pub credentials: AzureCredentials,
500    /// Service endpoint overrides for testing
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub service_overrides: Option<AzureServiceOverrides>,
503}
504
505/// Configuration mode for Kubernetes access
506#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
507#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
508#[serde(rename_all = "camelCase", tag = "mode")]
509pub enum KubernetesClientConfig {
510    /// Use in-cluster configuration (service account tokens, etc.)
511    InCluster {
512        /// The namespace to operate in
513        #[serde(skip_serializing_if = "Option::is_none")]
514        namespace: Option<String>,
515        /// Additional headers to include in requests
516        #[serde(skip_serializing_if = "Option::is_none")]
517        additional_headers: Option<HashMap<String, String>>,
518    },
519    /// Use kubeconfig file for configuration
520    Kubeconfig {
521        /// Path to kubeconfig file (optional, defaults to standard locations)
522        #[serde(skip_serializing_if = "Option::is_none")]
523        kubeconfig_path: Option<String>,
524        /// Context name to use (optional, defaults to current-context)
525        #[serde(skip_serializing_if = "Option::is_none")]
526        context: Option<String>,
527        /// Cluster name to use (optional, defaults to context's cluster)
528        #[serde(skip_serializing_if = "Option::is_none")]
529        cluster: Option<String>,
530        /// User name to use (optional, defaults to context's user)
531        #[serde(skip_serializing_if = "Option::is_none")]
532        user: Option<String>,
533        /// The namespace to operate in
534        #[serde(skip_serializing_if = "Option::is_none")]
535        namespace: Option<String>,
536        /// Additional headers to include in requests
537        #[serde(skip_serializing_if = "Option::is_none")]
538        additional_headers: Option<HashMap<String, String>>,
539    },
540    /// Manual configuration with explicit values
541    Manual {
542        /// The Kubernetes cluster server URL
543        server_url: String,
544        /// The cluster certificate authority data (base64 encoded)
545        certificate_authority_data: Option<String>,
546        /// Skip TLS verification (insecure)
547        insecure_skip_tls_verify: Option<bool>,
548        /// Client certificate data (base64 encoded) for mutual TLS
549        client_certificate_data: Option<String>,
550        /// Client key data (base64 encoded) for mutual TLS
551        client_key_data: Option<String>,
552        /// Bearer token for authentication
553        token: Option<String>,
554        /// Username for basic authentication
555        username: Option<String>,
556        /// Password for basic authentication
557        password: Option<String>,
558        /// The namespace to operate in
559        namespace: Option<String>,
560        /// Additional headers to include in requests
561        additional_headers: HashMap<String, String>,
562    },
563}
564
565impl std::fmt::Debug for KubernetesClientConfig {
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        match self {
568            KubernetesClientConfig::InCluster {
569                namespace,
570                additional_headers,
571            } => f
572                .debug_struct("KubernetesClientConfig::InCluster")
573                .field("namespace", namespace)
574                .field("additional_headers", additional_headers)
575                .finish(),
576            KubernetesClientConfig::Kubeconfig {
577                kubeconfig_path,
578                context,
579                cluster,
580                user,
581                namespace,
582                additional_headers,
583            } => f
584                .debug_struct("KubernetesClientConfig::Kubeconfig")
585                .field("kubeconfig_path", kubeconfig_path)
586                .field("context", context)
587                .field("cluster", cluster)
588                .field("user", user)
589                .field("namespace", namespace)
590                .field("additional_headers", additional_headers)
591                .finish(),
592            KubernetesClientConfig::Manual {
593                server_url,
594                certificate_authority_data,
595                insecure_skip_tls_verify,
596                client_certificate_data,
597                client_key_data,
598                token,
599                username,
600                password,
601                namespace,
602                additional_headers,
603            } => f
604                .debug_struct("KubernetesClientConfig::Manual")
605                .field("server_url", server_url)
606                .field("certificate_authority_data", certificate_authority_data)
607                .field("insecure_skip_tls_verify", insecure_skip_tls_verify)
608                .field("client_certificate_data", client_certificate_data)
609                .field(
610                    "client_key_data",
611                    &client_key_data.as_ref().map(|_| "[REDACTED]"),
612                )
613                .field("token", &token.as_ref().map(|_| "[REDACTED]"))
614                .field("username", username)
615                .field("password", &password.as_ref().map(|_| "[REDACTED]"))
616                .field("namespace", namespace)
617                .field("additional_headers", additional_headers)
618                .finish(),
619        }
620    }
621}
622
623/// Cloud-agnostic impersonation configuration
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
626#[serde(rename_all = "camelCase", tag = "platform")]
627pub enum ImpersonationConfig {
628    Aws(AwsImpersonationConfig),
629    Gcp(GcpImpersonationConfig),
630    Azure(AzureImpersonationConfig),
631    // Kubernetes doesn't support impersonation, so we don't include it here
632}
633
634/// Configuration for different cloud platform clients
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
636#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
637#[serde(rename_all = "camelCase", tag = "platform")]
638pub enum ClientConfig {
639    Aws(Box<AwsClientConfig>),
640    Gcp(Box<GcpClientConfig>),
641    Azure(Box<AzureClientConfig>),
642    Kubernetes(Box<KubernetesClientConfig>),
643    KubernetesCloud {
644        kubernetes: Box<KubernetesClientConfig>,
645        #[cfg_attr(feature = "openapi", schema(value_type = Object))]
646        cloud: Box<ClientConfig>,
647    },
648    Local {
649        /// State directory for local resources and deployment state
650        state_directory: String,
651    },
652    /// Machines platform - uses Horizon-backed controllers without cloud credentials.
653    #[serde(skip)]
654    Machines,
655    /// Test platform - uses mock controllers without real cloud APIs
656    #[serde(skip)]
657    Test,
658}
659
660impl ClientConfig {
661    /// Returns the platform enum for this configuration.
662    pub fn platform(&self) -> Platform {
663        match self {
664            ClientConfig::Aws(_) => Platform::Aws,
665            ClientConfig::Gcp(_) => Platform::Gcp,
666            ClientConfig::Azure(_) => Platform::Azure,
667            ClientConfig::Kubernetes(_) => Platform::Kubernetes,
668            ClientConfig::KubernetesCloud { .. } => Platform::Kubernetes,
669            ClientConfig::Local { .. } => Platform::Local,
670            ClientConfig::Machines => Platform::Machines,
671            ClientConfig::Test => Platform::Test,
672        }
673    }
674
675    pub fn config_for_platform(&self, platform: Platform) -> Option<ClientConfig> {
676        match self {
677            ClientConfig::KubernetesCloud { cloud, .. } => {
678                if platform == Platform::Kubernetes {
679                    Some(self.clone())
680                } else if cloud.platform() == platform {
681                    Some((**cloud).clone())
682                } else {
683                    None
684                }
685            }
686            config if config.platform() == platform => Some(config.clone()),
687            _ => None,
688        }
689    }
690
691    /// Returns the AWS configuration if this is an AWS client config.
692    pub fn aws_config(&self) -> Option<&AwsClientConfig> {
693        match self {
694            ClientConfig::Aws(config) => Some(config),
695            ClientConfig::KubernetesCloud { cloud, .. } => cloud.aws_config(),
696            _ => None,
697        }
698    }
699
700    /// Returns the GCP configuration if this is a GCP client config.
701    pub fn gcp_config(&self) -> Option<&GcpClientConfig> {
702        match self {
703            ClientConfig::Gcp(config) => Some(config),
704            ClientConfig::KubernetesCloud { cloud, .. } => cloud.gcp_config(),
705            _ => None,
706        }
707    }
708
709    /// Returns the Azure configuration if this is an Azure client config.
710    pub fn azure_config(&self) -> Option<&AzureClientConfig> {
711        match self {
712            ClientConfig::Azure(config) => Some(config),
713            ClientConfig::KubernetesCloud { cloud, .. } => cloud.azure_config(),
714            _ => None,
715        }
716    }
717
718    /// Returns the Kubernetes configuration if this is a Kubernetes client config.
719    pub fn kubernetes_config(&self) -> Option<&KubernetesClientConfig> {
720        match self {
721            ClientConfig::Kubernetes(config) => Some(config),
722            ClientConfig::KubernetesCloud { kubernetes, .. } => Some(kubernetes),
723            _ => None,
724        }
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::{
731        AwsClientConfig, AwsCredentials, AzureClientConfig, AzureCredentials, ClientConfig,
732        GcpClientConfig, GcpCredentials, KubernetesClientConfig,
733    };
734
735    #[test]
736    fn aws_credentials_wire_format_matches_manager_api_contract() {
737        let cases = [
738            (
739                AwsCredentials::AccessKeys {
740                    access_key_id: "access-key".to_string(),
741                    secret_access_key: "secret-key".to_string(),
742                    session_token: Some("session-token".to_string()),
743                },
744                serde_json::json!({
745                    "type": "accessKeys",
746                    "access_key_id": "access-key",
747                    "secret_access_key": "secret-key",
748                    "session_token": "session-token",
749                }),
750            ),
751            (
752                AwsCredentials::SessionCredentials {
753                    access_key_id: "access-key".to_string(),
754                    secret_access_key: "secret-key".to_string(),
755                    session_token: "session-token".to_string(),
756                    expires_at: "2099-01-01T00:00:00Z".to_string(),
757                },
758                serde_json::json!({
759                    "type": "sessionCredentials",
760                    "access_key_id": "access-key",
761                    "secret_access_key": "secret-key",
762                    "session_token": "session-token",
763                    "expires_at": "2099-01-01T00:00:00Z",
764                }),
765            ),
766        ];
767
768        for (credentials, expected) in cases {
769            let serialized = serde_json::to_value(&credentials).unwrap();
770            assert_eq!(serialized, expected);
771            assert_eq!(
772                serde_json::from_value::<AwsCredentials>(serialized).unwrap(),
773                credentials
774            );
775        }
776    }
777
778    #[test]
779    fn kubernetes_cloud_exposes_nested_aws_config() {
780        let config = ClientConfig::KubernetesCloud {
781            kubernetes: Box::new(KubernetesClientConfig::InCluster {
782                namespace: Some("test".to_string()),
783                additional_headers: None,
784            }),
785            cloud: Box::new(ClientConfig::Aws(Box::new(AwsClientConfig {
786                account_id: "123456789012".to_string(),
787                region: "us-east-2".to_string(),
788                credentials: AwsCredentials::AccessKeys {
789                    access_key_id: "access".to_string(),
790                    secret_access_key: "secret".to_string(),
791                    session_token: None,
792                },
793                service_overrides: None,
794            }))),
795        };
796
797        assert_eq!(config.platform(), crate::Platform::Kubernetes);
798        assert!(config.kubernetes_config().is_some());
799        assert_eq!(config.aws_config().unwrap().region, "us-east-2");
800        assert!(config.gcp_config().is_none());
801        assert!(config.azure_config().is_none());
802    }
803
804    #[test]
805    fn kubernetes_cloud_preserves_cloud_config_for_kubernetes_controllers() {
806        let config = ClientConfig::KubernetesCloud {
807            kubernetes: Box::new(KubernetesClientConfig::InCluster {
808                namespace: Some("test".to_string()),
809                additional_headers: None,
810            }),
811            cloud: Box::new(ClientConfig::Aws(Box::new(AwsClientConfig {
812                account_id: "123456789012".to_string(),
813                region: "us-east-2".to_string(),
814                credentials: AwsCredentials::AccessKeys {
815                    access_key_id: "access".to_string(),
816                    secret_access_key: "secret".to_string(),
817                    session_token: None,
818                },
819                service_overrides: None,
820            }))),
821        };
822
823        let kubernetes_config = config
824            .config_for_platform(crate::Platform::Kubernetes)
825            .unwrap();
826
827        assert!(matches!(
828            kubernetes_config,
829            ClientConfig::KubernetesCloud { .. }
830        ));
831        assert!(kubernetes_config.kubernetes_config().is_some());
832        assert_eq!(kubernetes_config.aws_config().unwrap().region, "us-east-2");
833    }
834
835    #[test]
836    fn kubernetes_cloud_exposes_nested_gcp_config() {
837        let config = ClientConfig::KubernetesCloud {
838            kubernetes: Box::new(KubernetesClientConfig::InCluster {
839                namespace: Some("test".to_string()),
840                additional_headers: None,
841            }),
842            cloud: Box::new(ClientConfig::Gcp(Box::new(GcpClientConfig {
843                project_id: "project".to_string(),
844                region: "us-central1".to_string(),
845                credentials: GcpCredentials::AccessToken {
846                    token: "token".to_string(),
847                },
848                service_overrides: None,
849                project_number: None,
850            }))),
851        };
852
853        assert_eq!(config.gcp_config().unwrap().project_id, "project");
854        assert!(config.aws_config().is_none());
855        assert!(config.azure_config().is_none());
856    }
857
858    #[test]
859    fn kubernetes_cloud_exposes_nested_azure_config() {
860        let config = ClientConfig::KubernetesCloud {
861            kubernetes: Box::new(KubernetesClientConfig::InCluster {
862                namespace: Some("test".to_string()),
863                additional_headers: None,
864            }),
865            cloud: Box::new(ClientConfig::Azure(Box::new(AzureClientConfig {
866                subscription_id: "sub".to_string(),
867                tenant_id: "tenant".to_string(),
868                region: Some("eastus".to_string()),
869                credentials: AzureCredentials::AccessToken {
870                    token: "token".to_string(),
871                },
872                service_overrides: None,
873            }))),
874        };
875
876        assert_eq!(config.azure_config().unwrap().subscription_id, "sub");
877        assert!(config.aws_config().is_none());
878        assert!(config.gcp_config().is_none());
879    }
880
881    #[test]
882    fn scoped_azure_tokens_debug_redacts_every_token() {
883        let credentials = AzureCredentials::ScopedAccessTokens {
884            tokens: std::collections::HashMap::from([
885                (
886                    "https://management.azure.com/.default".to_string(),
887                    "management-secret".to_string(),
888                ),
889                (
890                    "https://storage.azure.com/.default".to_string(),
891                    "storage-secret".to_string(),
892                ),
893            ]),
894        };
895
896        let debug = format!("{credentials:?}");
897        assert!(debug.contains("https://management.azure.com/.default"));
898        assert!(debug.contains("https://storage.azure.com/.default"));
899        assert!(!debug.contains("management-secret"));
900        assert!(!debug.contains("storage-secret"));
901        assert!(debug.contains("[REDACTED]"));
902    }
903}