From c9d1accdaeade15178afa6f14d9cf9697f2fe6dd Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Mon, 16 Feb 2026 21:14:24 +0100 Subject: [PATCH 1/2] feat: updated wizzard with env findings and private/public endpoint findings --- src/agent/mod.rs | 30 +- src/agent/tools/mod.rs | 18 +- .../platform/create_deployment_config.rs | 94 +- src/agent/tools/platform/deploy_service.rs | 290 ++++++- .../platform/list_deployment_capabilities.rs | 14 +- src/agent/tools/platform/mod.rs | 2 + src/agent/tools/platform/set_secrets.rs | 440 ++++++++++ src/platform/api/client.rs | 116 ++- src/platform/api/types.rs | 337 ++++++- src/server/routes.rs | 13 +- src/wizard/cloud_provider_data.rs | 169 +++- src/wizard/config_form.rs | 428 ++++++++- src/wizard/environment_creation.rs | 111 ++- src/wizard/environment_selection.rs | 1 + src/wizard/infrastructure_selection.rs | 149 +++- src/wizard/mod.rs | 10 +- src/wizard/orchestrator.rs | 128 ++- src/wizard/provider_selection.rs | 8 +- src/wizard/recommendations.rs | 147 +++- src/wizard/service_endpoints.rs | 821 ++++++++++++++++++ 20 files changed, 3197 insertions(+), 129 deletions(-) create mode 100644 src/agent/tools/platform/set_secrets.rs create mode 100644 src/wizard/service_endpoints.rs diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 7adf3c1c..6569b383 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -757,12 +757,13 @@ pub async fn run_interactive( .tool(ListHetznerAvailabilityTool::new()) // Deployment tools for service management .tool(CreateDeploymentConfigTool::new()) - .tool(DeployServiceTool::new(project_path_buf.clone())) + .tool(DeployServiceTool::with_context(project_path_buf.clone(), ExecutionContext::InteractiveCli)) .tool(ListDeploymentConfigsTool::new()) .tool(TriggerDeploymentTool::new()) .tool(GetDeploymentStatusTool::new()) .tool(ListDeploymentsTool::new()) - .tool(GetServiceLogsTool::new()); + .tool(GetServiceLogsTool::new()) + .tool(SetDeploymentSecretsTool::with_context(ExecutionContext::InteractiveCli)); // Add tools based on mode if is_planning { @@ -875,12 +876,13 @@ pub async fn run_interactive( .tool(ListHetznerAvailabilityTool::new()) // Deployment tools for service management .tool(CreateDeploymentConfigTool::new()) - .tool(DeployServiceTool::new(project_path_buf.clone())) + .tool(DeployServiceTool::with_context(project_path_buf.clone(), ExecutionContext::InteractiveCli)) .tool(ListDeploymentConfigsTool::new()) .tool(TriggerDeploymentTool::new()) .tool(GetDeploymentStatusTool::new()) .tool(ListDeploymentsTool::new()) - .tool(GetServiceLogsTool::new()); + .tool(GetServiceLogsTool::new()) + .tool(SetDeploymentSecretsTool::with_context(ExecutionContext::InteractiveCli)); // Add tools based on mode if is_planning { @@ -984,12 +986,13 @@ pub async fn run_interactive( .tool(ListHetznerAvailabilityTool::new()) // Deployment tools for service management .tool(CreateDeploymentConfigTool::new()) - .tool(DeployServiceTool::new(project_path_buf.clone())) + .tool(DeployServiceTool::with_context(project_path_buf.clone(), ExecutionContext::InteractiveCli)) .tool(ListDeploymentConfigsTool::new()) .tool(TriggerDeploymentTool::new()) .tool(GetDeploymentStatusTool::new()) .tool(ListDeploymentsTool::new()) - .tool(GetServiceLogsTool::new()); + .tool(GetServiceLogsTool::new()) + .tool(SetDeploymentSecretsTool::with_context(ExecutionContext::InteractiveCli)); // Add tools based on mode if is_planning { @@ -2479,12 +2482,13 @@ pub async fn run_query( .tool(ListHetznerAvailabilityTool::new()) // Deployment tools for service management .tool(CreateDeploymentConfigTool::new()) - .tool(DeployServiceTool::new(project_path_buf.clone())) + .tool(DeployServiceTool::with_context(project_path_buf.clone(), ExecutionContext::InteractiveCli)) .tool(ListDeploymentConfigsTool::new()) .tool(TriggerDeploymentTool::new()) .tool(GetDeploymentStatusTool::new()) .tool(ListDeploymentsTool::new()) - .tool(GetServiceLogsTool::new()); + .tool(GetServiceLogsTool::new()) + .tool(SetDeploymentSecretsTool::with_context(ExecutionContext::InteractiveCli)); // Add generation tools if this is a generation query if is_generation { @@ -2565,12 +2569,13 @@ pub async fn run_query( .tool(ListHetznerAvailabilityTool::new()) // Deployment tools for service management .tool(CreateDeploymentConfigTool::new()) - .tool(DeployServiceTool::new(project_path_buf.clone())) + .tool(DeployServiceTool::with_context(project_path_buf.clone(), ExecutionContext::InteractiveCli)) .tool(ListDeploymentConfigsTool::new()) .tool(TriggerDeploymentTool::new()) .tool(GetDeploymentStatusTool::new()) .tool(ListDeploymentsTool::new()) - .tool(GetServiceLogsTool::new()); + .tool(GetServiceLogsTool::new()) + .tool(SetDeploymentSecretsTool::with_context(ExecutionContext::InteractiveCli)); // Add generation tools if this is a generation query if is_generation { @@ -2640,12 +2645,13 @@ pub async fn run_query( .tool(ListHetznerAvailabilityTool::new()) // Deployment tools for service management .tool(CreateDeploymentConfigTool::new()) - .tool(DeployServiceTool::new(project_path_buf.clone())) + .tool(DeployServiceTool::with_context(project_path_buf.clone(), ExecutionContext::InteractiveCli)) .tool(ListDeploymentConfigsTool::new()) .tool(TriggerDeploymentTool::new()) .tool(GetDeploymentStatusTool::new()) .tool(ListDeploymentsTool::new()) - .tool(GetServiceLogsTool::new()); + .tool(GetServiceLogsTool::new()) + .tool(SetDeploymentSecretsTool::with_context(ExecutionContext::InteractiveCli)); // Add generation tools if this is a generation query if is_generation { diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index eba45470..9457f122 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -112,6 +112,21 @@ //! //! See `response.rs` for the complete response formatting infrastructure. +/// Execution context for tools that behave differently in CLI vs server mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionContext { + /// Interactive CLI — terminal available, inquire prompts work + InteractiveCli, + /// AG-UI server — no terminal, prompts would hang + HeadlessServer, +} + +impl ExecutionContext { + pub fn has_terminal(&self) -> bool { + matches!(self, Self::InteractiveCli) + } +} + mod analyze; pub mod background; pub mod compression; @@ -175,7 +190,8 @@ pub use platform::{ DeployServiceTool, GetDeploymentStatusTool, GetServiceLogsTool, ListDeploymentCapabilitiesTool, ListDeploymentConfigsTool, ListDeploymentsTool, ListHetznerAvailabilityTool, ListOrganizationsTool, ListProjectsTool, - OpenProviderSettingsTool, SelectProjectTool, TriggerDeploymentTool, + OpenProviderSettingsTool, SelectProjectTool, SetDeploymentSecretsTool, + TriggerDeploymentTool, }; pub use prometheus_connect::PrometheusConnectTool; pub use prometheus_discover::PrometheusDiscoverTool; diff --git a/src/agent/tools/platform/create_deployment_config.rs b/src/agent/tools/platform/create_deployment_config.rs index eefcfe68..ff42a5e2 100644 --- a/src/agent/tools/platform/create_deployment_config.rs +++ b/src/agent/tools/platform/create_deployment_config.rs @@ -8,8 +8,9 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::agent::tools::error::{ErrorCategory, format_error_for_llm}; -use crate::platform::api::types::CreateDeploymentConfigRequest; +use crate::platform::api::types::{CloudProvider, CloudRunnerConfigInput, CreateDeploymentConfigRequest, build_cloud_runner_config_v2}; use crate::platform::api::{PlatformApiClient, PlatformApiError}; +use std::str::FromStr; /// Arguments for the create deployment config tool #[derive(Debug, Deserialize)] @@ -28,7 +29,7 @@ pub struct CreateDeploymentConfigArgs { pub branch: String, /// Target type: "kubernetes" or "cloud_runner" pub target_type: String, - /// Cloud provider: "gcp" or "hetzner" + /// Cloud provider: "gcp", "hetzner", or "azure" pub provider: String, /// Environment ID for deployment pub environment_id: String, @@ -43,6 +44,16 @@ pub struct CreateDeploymentConfigArgs { /// Enable auto-deploy on push (defaults to true) #[serde(default = "default_auto_deploy")] pub auto_deploy_enabled: bool, + /// CPU allocation (for GCP Cloud Run or Azure Container Apps) + pub cpu: Option, + /// Memory allocation (for GCP Cloud Run or Azure Container Apps) + pub memory: Option, + /// Minimum instances/replicas + pub min_instances: Option, + /// Maximum instances/replicas + pub max_instances: Option, + /// Whether the service should be publicly accessible + pub is_public: Option, } fn default_auto_deploy() -> bool { @@ -84,6 +95,7 @@ A deployment config defines how to build and deploy a service, including: - Dockerfile location and build context - Target (Cloud Runner or Kubernetes) - Port configuration +- CPU/memory allocation (for Cloud Runner deployments) - Auto-deploy settings **Required Parameters:** @@ -94,7 +106,7 @@ A deployment config defines how to build and deploy a service, including: - port: Port the service listens on - branch: Git branch to deploy from (e.g., "main") - target_type: "kubernetes" or "cloud_runner" -- provider: "gcp" or "hetzner" +- provider: "gcp", "hetzner", or "azure" - environment_id: Environment to deploy to **Optional Parameters:** @@ -103,6 +115,11 @@ A deployment config defines how to build and deploy a service, including: - cluster_id: Required for kubernetes target - registry_id: Container registry ID (provisions new if not provided) - auto_deploy_enabled: Enable auto-deploy on push (default: true) +- cpu: CPU allocation (e.g., "1" for GCP Cloud Run, "0.5" for Azure ACA) +- memory: Memory allocation (e.g., "512Mi" for GCP, "1.0Gi" for Azure) +- min_instances: Minimum instances/replicas (default: 0) +- max_instances: Maximum instances/replicas (default: 10) +- is_public: Whether the service should be publicly accessible (default: true) **Prerequisites:** - User must be authenticated @@ -149,7 +166,7 @@ A deployment config defines how to build and deploy a service, including: }, "provider": { "type": "string", - "enum": ["gcp", "hetzner"], + "enum": ["gcp", "hetzner", "azure"], "description": "Cloud provider" }, "environment_id": { @@ -175,6 +192,26 @@ A deployment config defines how to build and deploy a service, including: "auto_deploy_enabled": { "type": "boolean", "description": "Enable auto-deploy on push (default: true)" + }, + "cpu": { + "type": "string", + "description": "CPU allocation (e.g., '1' for GCP Cloud Run, '0.5' for Azure ACA)" + }, + "memory": { + "type": "string", + "description": "Memory allocation (e.g., '512Mi' for GCP, '1.0Gi' for Azure)" + }, + "min_instances": { + "type": "integer", + "description": "Minimum instances/replicas (default: 0)" + }, + "max_instances": { + "type": "integer", + "description": "Maximum instances/replicas (default: 10)" + }, + "is_public": { + "type": "boolean", + "description": "Whether the service should be publicly accessible (default: true)" } }, "required": [ @@ -222,20 +259,20 @@ A deployment config defines how to build and deploy a service, including: args.target_type ), Some(vec![ - "Use 'cloud_runner' for GCP Cloud Run or Hetzner containers", + "Use 'cloud_runner' for GCP Cloud Run, Hetzner containers, or Azure Container Apps", "Use 'kubernetes' for deploying to a K8s cluster", ]), )); } // Validate provider - let valid_providers = ["gcp", "hetzner"]; + let valid_providers = ["gcp", "hetzner", "azure"]; if !valid_providers.contains(&args.provider.as_str()) { return Ok(format_error_for_llm( "create_deployment_config", ErrorCategory::ValidationFailed, &format!( - "Invalid provider '{}'. Must be 'gcp' or 'hetzner'", + "Invalid provider '{}'. Must be 'gcp', 'hetzner', or 'azure'", args.provider ), Some(vec![ @@ -266,6 +303,44 @@ A deployment config defines how to build and deploy a service, including: } }; + // Build cloud runner config if deploying to cloud_runner + let cloud_runner_config = if args.target_type == "cloud_runner" { + let provider_enum = CloudProvider::from_str(&args.provider).ok(); + + // Fetch provider_account_id from credentials when provider is azure or gcp + let mut gcp_project_id = None; + let mut subscription_id = None; + if let Some(ref provider) = provider_enum { + if matches!(provider, CloudProvider::Gcp | CloudProvider::Azure) { + if let Ok(credential) = client.check_provider_connection(provider, &args.project_id).await { + if let Some(cred) = credential { + match provider { + CloudProvider::Gcp => gcp_project_id = cred.provider_account_id, + CloudProvider::Azure => subscription_id = cred.provider_account_id, + _ => {} + } + } + } + } + } + + let config_input = CloudRunnerConfigInput { + provider: provider_enum, + region: None, // Region is set at environment level or by deploy_service + gcp_project_id, + cpu: args.cpu.clone(), + memory: args.memory.clone(), + min_instances: args.min_instances, + max_instances: args.max_instances, + is_public: args.is_public, + subscription_id, + ..Default::default() + }; + Some(build_cloud_runner_config_v2(&config_input)) + } else { + None + }; + // Build the request // Note: Send both field name variants (dockerfile/dockerfilePath, context/buildContext) // for backend compatibility - different endpoints may expect different field names @@ -286,8 +361,9 @@ A deployment config defines how to build and deploy a service, including: cluster_id: args.cluster_id.clone(), registry_id: args.registry_id.clone(), auto_deploy_enabled: args.auto_deploy_enabled, - is_public: None, - cloud_runner_config: None, + is_public: args.is_public, + cloud_runner_config, + secrets: None, }; // Create the deployment config diff --git a/src/agent/tools/platform/deploy_service.rs b/src/agent/tools/platform/deploy_service.rs index 24d9f199..948bbe46 100644 --- a/src/agent/tools/platform/deploy_service.rs +++ b/src/agent/tools/platform/deploy_service.rs @@ -5,22 +5,27 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::json; use std::path::PathBuf; use std::str::FromStr; +use crate::agent::tools::ExecutionContext; use crate::agent::tools::error::{ErrorCategory, format_error_for_llm}; use crate::analyzer::{AnalysisConfig, TechnologyCategory, analyze_project_with_config}; use crate::platform::api::types::{ - CloudProvider, CreateDeploymentConfigRequest, ProjectRepository, build_cloud_runner_config, + CloudProvider, CloudRunnerConfigInput, CreateDeploymentConfigRequest, DeploymentSecretInput, + ProjectRepository, build_cloud_runner_config_v2, }; + +use super::set_secrets::{SecretPromptResult, default_true, prompt_secret_value}; use crate::platform::api::{PlatformApiClient, PlatformApiError, TriggerDeploymentRequest}; use crate::platform::PlatformSession; use crate::wizard::{ RecommendationInput, recommend_deployment, get_provider_deployment_statuses, get_hetzner_regions_dynamic, get_hetzner_server_types_dynamic, HetznerFetchResult, - DynamicCloudRegion, DynamicMachineType, + DynamicCloudRegion, DynamicMachineType, discover_env_files, parse_env_file, + get_available_endpoints, filter_endpoints_for_provider, match_env_vars_to_services, }; use std::process::Command; @@ -30,12 +35,24 @@ struct HetznerAvailabilityData { server_types: Vec, } +/// A single secret/env var key input for the deploy tool +#[derive(Debug, Deserialize)] +pub struct SecretKeyInput { + /// Environment variable name + pub key: String, + /// Value to set. OMIT for secrets — user will be prompted in terminal. + pub value: Option, + /// Whether this is a secret (default: true for safety) + #[serde(default = "default_true")] + pub is_secret: bool, +} + /// Arguments for the deploy service tool #[derive(Debug, Deserialize)] pub struct DeployServiceArgs { /// Optional: specific subdirectory/service to deploy (for monorepos) pub path: Option, - /// Optional: override recommended provider (gcp, hetzner) + /// Optional: override recommended provider (gcp, hetzner, azure) pub provider: Option, /// Optional: override machine type selection pub machine_type: Option, @@ -47,10 +64,22 @@ pub struct DeployServiceArgs { /// Internal services can only be accessed within the cluster/network #[serde(default)] pub is_public: bool, + /// Optional: CPU allocation (for GCP Cloud Run / Azure ACA) + pub cpu: Option, + /// Optional: Memory allocation (for GCP Cloud Run / Azure ACA) + pub memory: Option, + /// Optional: min instances/replicas + pub min_instances: Option, + /// Optional: max instances/replicas + pub max_instances: Option, /// If true (default), show recommendation but don't deploy yet /// If false with settings, deploy immediately #[serde(default = "default_preview")] pub preview_only: bool, + /// Optional: environment variable keys to set during deployment. + /// For secrets (is_secret=true), values are collected via terminal prompt. + /// For non-secrets, include the value directly. + pub secret_keys: Option>, } fn default_preview() -> bool { @@ -70,15 +99,27 @@ pub struct DeployServiceError(String); /// 3. Generates smart recommendations with reasoning /// 4. Shows a preview for user confirmation /// 5. Creates deployment config and triggers deployment -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct DeployServiceTool { project_path: PathBuf, + execution_context: ExecutionContext, } impl DeployServiceTool { - /// Create a new DeployServiceTool + /// Create a new DeployServiceTool (defaults to InteractiveCli) pub fn new(project_path: PathBuf) -> Self { - Self { project_path } + Self { + project_path, + execution_context: ExecutionContext::InteractiveCli, + } + } + + /// Create with explicit execution context + pub fn with_context(project_path: PathBuf, ctx: ExecutionContext) -> Self { + Self { + project_path, + execution_context: ctx, + } } } @@ -109,7 +150,7 @@ Uses provided overrides or recommendation defaults to deploy immediately. **Parameters:** - path: Optional subdirectory for monorepo services -- provider: Override recommendation (gcp, hetzner) +- provider: Override recommendation (gcp, hetzner, azure) - machine_type: Override machine selection (e.g., cx22, e2-small) - region: Override region selection (e.g., nbg1, us-central1) - port: Override detected port @@ -146,6 +187,28 @@ User: "deploy this service" - Only deploy after user explicitly confirms the final settings with "yes", "deploy", "confirm" - A change request is NOT a deployment confirmation +**Multiple cloud providers:** +- The response includes connected_providers listing ALL connected providers (e.g. Hetzner AND Azure) +- ALWAYS mention all connected providers to the user, not just the recommended one +- The user can override the provider with the provider parameter +- If deploying related services, consider whether they should be on the same provider for private networking + +**Deployed service endpoints:** +- The response includes deployed_service_endpoints showing services already running in the project +- Services may have public URLs (reachable from anywhere) or private IPs (only reachable from the same cloud provider network) +- endpoint_suggestions maps detected env vars to deployed services (e.g. SENTIMENT_SERVICE_URL -> sentiment-analysis) +- Private endpoints are pre-filtered to only show services on the same provider network +- ALWAYS mention available endpoints when deploying services that have env vars matching deployed services + +**Environment variables (secret_keys) and .env files:** +- The preview response includes parsed_env_files: discovered .env files with their parsed keys/values +- If .env files are found, ALWAYS ask the user: "I found a .env file with N variables. Should I inject these into the deployment?" +- For non-secret vars from .env files, pass them as secret_keys with is_secret=false and include the value +- For secret vars (API keys, tokens, passwords), pass them as secret_keys with is_secret=true and omit the value — the user is prompted securely in the terminal +- Secret values from .env files are NEVER included in parsed_env_files or this conversation +- If no .env files found but detected_env_vars exist, mention those and ask user how to provide them +- NEVER ask the user to type secret values in chat + **Prerequisites:** - User must be authenticated (sync-ctl auth login) - A project must be selected (use select_project first) @@ -160,7 +223,7 @@ User: "deploy this service" }, "provider": { "type": "string", - "enum": ["gcp", "hetzner"], + "enum": ["gcp", "hetzner", "azure"], "description": "Override: cloud provider" }, "machine_type": { @@ -182,6 +245,29 @@ User: "deploy this service" "preview_only": { "type": "boolean", "description": "If true (default), show recommendation only. If false, deploy." + }, + "secret_keys": { + "type": "array", + "description": "Env vars to include in deployment. For secrets, omit value \u{2014} user is prompted in terminal.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Environment variable name" + }, + "value": { + "type": "string", + "description": "Omit for secrets \u{2014} user will be prompted securely in terminal." + }, + "is_secret": { + "type": "boolean", + "description": "Whether this is a secret (default: true). Secrets are prompted in terminal.", + "default": true + } + }, + "required": ["key"] + } } } }), @@ -331,7 +417,7 @@ User: "deploy this service" ErrorCategory::ResourceUnavailable, "No cloud providers connected", Some(vec![ - "Connect GCP or Hetzner in platform settings", + "Connect a cloud provider (GCP, Hetzner, or Azure) in platform settings", "Use open_provider_settings to configure a provider", ]), )); @@ -485,6 +571,7 @@ User: "deploy this service" vec![ "To deploy with these settings: call deploy_service with preview_only=false".to_string(), "To customize: specify provider, machine_type, region, or port parameters".to_string(), + "Check parsed_env_files — if .env files were found, ask user whether to inject them as secret_keys".to_string(), "To see more options: check the hetzner_availability section for current pricing".to_string(), ] ) @@ -619,6 +706,67 @@ User: "deploy this service" }) }); + // Discover .env files and parse their contents for agent surfacing + let discovered_env_files_raw = discover_env_files(&analysis_path); + let discovered_env_file_paths: Vec = discovered_env_files_raw + .iter() + .map(|p| p.display().to_string()) + .collect(); + + // Parse each .env file so the LLM can present keys to the user + let parsed_env_files: Vec = discovered_env_files_raw + .iter() + .filter_map(|rel_path| { + let abs_path = analysis_path.join(rel_path); + match parse_env_file(&abs_path) { + Ok(entries) if !entries.is_empty() => Some(json!({ + "file": rel_path.display().to_string(), + "variable_count": entries.len(), + "variables": entries.iter().map(|e| json!({ + "key": e.key, + "is_secret": e.is_secret, + // Only include values for non-secret vars — secrets are + // never exposed to the LLM conversation. + "value": if e.is_secret { None } else { Some(&e.value) }, + })).collect::>(), + })), + Ok(_) => None, // empty file + Err(e) => { + tracing::debug!("Could not parse env file {:?}: {}", rel_path, e); + None + } + } + }) + .collect(); + + // Fetch deployed services and compute endpoint suggestions + let deployed_endpoints = match client.list_deployments(&project_id, Some(50)).await { + Ok(paginated) => get_available_endpoints(&paginated.data), + Err(e) => { + tracing::debug!("Could not fetch deployments for endpoint matching: {}", e); + Vec::new() + } + }; + let deployed_endpoints: Vec<_> = deployed_endpoints + .into_iter() + .filter(|ep| ep.service_name != service_name) + .collect(); + // Only show private endpoints from the same cloud provider — private + // IPs are not reachable across different provider networks. + let deployed_endpoints = filter_endpoints_for_provider( + deployed_endpoints, + final_provider_for_check.as_str(), + ); + + let detected_env_var_names: Vec = analysis + .environment_variables + .iter() + .map(|e| e.name.clone()) + .collect(); + + let endpoint_suggestions = + match_env_vars_to_services(&detected_env_var_names, &deployed_endpoints); + let response = json!({ "status": "recommendation", "deployment_mode": deployment_mode, @@ -628,6 +776,17 @@ User: "deploy this service" "name": resolved_env_name, "is_production": is_production, }, + "connected_providers": capabilities.iter() + .filter(|s| s.provider.is_available() && s.is_connected) + .map(|s| json!({ + "provider": s.provider.as_str(), + "display_name": s.provider.display_name(), + "cloud_runner_available": s.cloud_runner_available, + "clusters": s.clusters.len(), + "registries": s.registries.len(), + "summary": s.summary, + })) + .collect::>(), "production_warning": production_warning, "existing_config": existing_config.map(|c| json!({ "id": c.id, @@ -647,6 +806,12 @@ User: "deploy this service" "health_endpoint": recommendation.health_check_path, "has_dockerfile": has_dockerfile, "has_kubernetes": has_k8s, + "detected_env_vars": analysis.environment_variables.iter().map(|e| json!({ + "name": e.name, + "required": e.required, + "has_default": e.default_value.is_some(), + "description": e.description, + })).collect::>(), }, "recommendation": { "provider": recommendation.provider.as_str(), @@ -708,6 +873,22 @@ User: "deploy this service" }, }, "service_name": service_name, + "discovered_env_files": discovered_env_file_paths, + "parsed_env_files": parsed_env_files, + "deployed_service_endpoints": deployed_endpoints.iter().map(|ep| json!({ + "service_name": ep.service_name, + "url": ep.url, + "is_private": ep.is_private, + "status": ep.status, + })).collect::>(), + "endpoint_suggestions": endpoint_suggestions.iter().map(|s| json!({ + "env_var": s.env_var_name, + "service_name": s.service.service_name, + "url": s.service.url, + "is_private": s.service.is_private, + "confidence": format!("{:?}", s.confidence), + "reason": s.reason, + })).collect::>(), "next_steps": next_steps, "confirmation_prompt": if existing_config.is_some() { format!( @@ -975,13 +1156,81 @@ User: "deploy this service" args.path ); - let cloud_runner_config = build_cloud_runner_config( - &final_provider, - &final_region, - &final_machine, - args.is_public, - recommendation.health_check_path.as_deref(), - ); + // Fetch provider_account_id from credentials for GCP/Azure + let mut gcp_project_id = None; + let mut subscription_id = None; + if matches!(final_provider, CloudProvider::Gcp | CloudProvider::Azure) { + if let Ok(Some(cred)) = client.check_provider_connection(&final_provider, &project_id).await { + match final_provider { + CloudProvider::Gcp => gcp_project_id = cred.provider_account_id, + CloudProvider::Azure => subscription_id = cred.provider_account_id, + _ => {} + } + } + } + + // Determine CPU/memory from args or recommendation + let final_cpu = args.cpu.clone() + .or_else(|| recommendation.cpu.clone()); + let final_memory = args.memory.clone() + .or_else(|| recommendation.memory.clone()); + + let config_input = CloudRunnerConfigInput { + provider: Some(final_provider.clone()), + region: Some(final_region.clone()), + server_type: if final_provider == CloudProvider::Hetzner { Some(final_machine.clone()) } else { None }, + gcp_project_id, + cpu: final_cpu.clone(), + memory: final_memory.clone(), + min_instances: args.min_instances, + max_instances: args.max_instances, + allow_unauthenticated: Some(args.is_public), + subscription_id, + is_public: Some(args.is_public), + health_check_path: recommendation.health_check_path.clone(), + ..Default::default() + }; + let cloud_runner_config = build_cloud_runner_config_v2(&config_input); + + // Resolve secrets if provided + let secrets = if let Some(ref keys) = args.secret_keys { + let mut resolved = Vec::new(); + for sk in keys { + let value = match &sk.value { + Some(v) => v.clone(), + None if self.execution_context.has_terminal() => { + match prompt_secret_value(&sk.key) { + SecretPromptResult::Value(v) => v, + SecretPromptResult::Skipped => continue, + SecretPromptResult::Cancelled => { + return Ok(format_error_for_llm( + "deploy_service", + ErrorCategory::ValidationFailed, + "Secret entry cancelled by user", + Some(vec!["The user cancelled secret input. Try again when ready."]), + )); + } + } + } + None => continue, // server mode, skip secrets without values + }; + resolved.push(DeploymentSecretInput { + key: sk.key.clone(), + value, + is_secret: sk.is_secret, + }); + } + if resolved.is_empty() { None } else { Some(resolved) } + } else { + None + }; + + // SECURITY: Pre-compute response info (keys only, no values) before moving secrets + let secrets_set_info = secrets.as_ref().map(|s| { + s.iter() + .map(|si| json!({"key": si.key, "is_secret": si.is_secret})) + .collect::>() + }); let config_request = CreateDeploymentConfigRequest { project_id: project_id.clone(), @@ -1002,6 +1251,7 @@ User: "deploy this service" auto_deploy_enabled: true, is_public: Some(args.is_public), cloud_runner_config: Some(cloud_runner_config), + secrets, }; // Create config @@ -1040,6 +1290,7 @@ User: "deploy this service" "dockerfile_path": dockerfile_path, "build_context": build_context, }, + "secrets_set": secrets_set_info, "message": format!( "NEW deployment started for '{}' on {} environment. Task ID: {}", service_name, resolved_env_name, response.backstage_task_id @@ -1255,7 +1506,12 @@ mod tests { region: None, port: None, is_public: false, + cpu: None, + memory: None, + min_instances: None, + max_instances: None, preview_only: true, + secret_keys: None, }; let result = tool.call(args).await.unwrap(); diff --git a/src/agent/tools/platform/list_deployment_capabilities.rs b/src/agent/tools/platform/list_deployment_capabilities.rs index c6d129bc..b46a8808 100644 --- a/src/agent/tools/platform/list_deployment_capabilities.rs +++ b/src/agent/tools/platform/list_deployment_capabilities.rs @@ -71,8 +71,8 @@ targets are available (clusters, registries, Cloud Run). - summary: Human-readable status **Provider Availability:** -- Available now: GCP, Hetzner -- Coming soon: AWS, Azure, Scaleway, Cyso Cloud +- Available now: GCP, Hetzner, Azure +- Coming soon: AWS, Scaleway, Cyso Cloud **Use Cases:** - Before creating a deployment, check what options are available @@ -173,7 +173,7 @@ targets are available (clusters, registries, Cloud Run). // Build summary let summary = if available_connected_count == 0 { - "No available providers connected. Connect GCP or Hetzner in platform settings.".to_string() + "No available providers connected. Connect GCP, Hetzner, or Azure in platform settings.".to_string() } else { let mut parts = vec![format!("{} provider{} ready", available_connected_count, if available_connected_count == 1 { "" } else { "s" })]; if total_clusters > 0 { @@ -193,19 +193,19 @@ targets are available (clusters, registries, Cloud Run). "available_connected_count": available_connected_count, "total_clusters": total_clusters, "total_registries": total_registries, - "coming_soon_providers": ["AWS", "Azure", "Scaleway", "Cyso Cloud"], + "coming_soon_providers": ["AWS", "Scaleway", "Cyso Cloud"], "next_steps": if available_connected_count > 0 { vec![ "Use analyze_project to discover Dockerfiles in the project", "Use create_deployment_config to create a deployment configuration", "For Cloud Run deployments, no cluster is needed", - "Note: AWS, Azure, Scaleway, and Cyso Cloud are coming soon" + "Note: AWS, Scaleway, and Cyso Cloud are coming soon" ] } else { vec![ - "Use open_provider_settings to connect GCP or Hetzner", + "Use open_provider_settings to connect GCP, Hetzner, or Azure", "After connecting, run this tool again to see available options", - "Note: AWS, Azure, Scaleway, and Cyso Cloud are coming soon" + "Note: AWS, Scaleway, and Cyso Cloud are coming soon" ] } }); diff --git a/src/agent/tools/platform/mod.rs b/src/agent/tools/platform/mod.rs index 0c162fd5..98d0ae72 100644 --- a/src/agent/tools/platform/mod.rs +++ b/src/agent/tools/platform/mod.rs @@ -77,6 +77,7 @@ mod list_projects; mod open_provider_settings; mod provision_registry; mod select_project; +mod set_secrets; mod trigger_deployment; pub use analyze_codebase::AnalyzeCodebaseTool; @@ -96,4 +97,5 @@ pub use list_projects::ListProjectsTool; pub use open_provider_settings::OpenProviderSettingsTool; pub use provision_registry::ProvisionRegistryTool; pub use select_project::SelectProjectTool; +pub use set_secrets::SetDeploymentSecretsTool; pub use trigger_deployment::TriggerDeploymentTool; diff --git a/src/agent/tools/platform/set_secrets.rs b/src/agent/tools/platform/set_secrets.rs new file mode 100644 index 00000000..1d1da009 --- /dev/null +++ b/src/agent/tools/platform/set_secrets.rs @@ -0,0 +1,440 @@ +//! Set deployment secrets tool for the agent +//! +//! Allows the agent to set environment variables and secrets on a deployment config. +//! SECURITY: Secret values are NEVER returned in tool responses. Only key names are confirmed. +//! For secrets (is_secret=true), values are collected via terminal prompt — the LLM never sees them. + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::Deserialize; +use serde_json::json; + +use crate::agent::tools::ExecutionContext; +use crate::agent::tools::error::{ErrorCategory, format_error_for_llm}; +use crate::platform::api::types::DeploymentSecretInput; +use crate::platform::api::{PlatformApiClient, PlatformApiError}; + +/// Result of prompting the user for a secret value in the terminal. +pub(super) enum SecretPromptResult { + /// User entered a non-empty value + Value(String), + /// User skipped this secret (Esc or empty input) + Skipped, + /// User cancelled all secret entry (Ctrl+C) + Cancelled, +} + +/// Prompt the user for a secret value using masked terminal input. +/// +/// The value is collected directly from the terminal and never enters the LLM context. +pub(super) fn prompt_secret_value(key_name: &str) -> SecretPromptResult { + use colored::Colorize; + use inquire::{InquireError, Password, PasswordDisplayMode}; + + println!(); + println!( + " {} Enter value for {} {}", + "\u{1f512}".dimmed(), + key_name.cyan(), + "(hidden \u{2014} not visible to AI agent)".dimmed() + ); + + match Password::new(key_name) + .with_display_mode(PasswordDisplayMode::Masked) + .with_help_message("Esc to skip, Ctrl+C to cancel all") + .without_confirmation() + .prompt() + { + Ok(v) if v.trim().is_empty() => SecretPromptResult::Skipped, + Ok(v) => { + println!(" {} {} set", "\u{2713}".green(), key_name.cyan()); + SecretPromptResult::Value(v) + } + Err(InquireError::OperationCanceled) => SecretPromptResult::Skipped, + Err(InquireError::OperationInterrupted) => SecretPromptResult::Cancelled, + Err(_) => SecretPromptResult::Cancelled, + } +} + +/// A single secret argument from the agent +#[derive(Debug, Deserialize)] +pub struct SecretArg { + /// Environment variable name + pub key: String, + /// Environment variable value. + /// OMIT for secrets (is_secret=true) — user will be prompted in terminal. + /// Provide for non-secrets (NODE_ENV, PORT, etc.) + pub value: Option, + /// Whether this is a secret (masked in responses). Default: true for safety + #[serde(default = "default_true")] + pub is_secret: bool, +} + +pub(super) fn default_true() -> bool { + true +} + +/// Arguments for the set deployment secrets tool +#[derive(Debug, Deserialize)] +pub struct SetDeploymentSecretsArgs { + /// Deployment config ID to set secrets on + pub config_id: String, + /// Environment variables to set + pub secrets: Vec, +} + +/// Error type for set deployment secrets operations +#[derive(Debug, thiserror::Error)] +#[error("Set deployment secrets error: {0}")] +pub struct SetDeploymentSecretsError(String); + +/// Tool to set environment variables and secrets on a deployment configuration. +/// +/// SECURITY: Secret values are sent securely to the backend and stored encrypted. +/// Values are NEVER included in tool responses - only key names are confirmed. +/// For secrets, values are collected via terminal prompt — the LLM never sees them. +#[derive(Debug, Clone)] +pub struct SetDeploymentSecretsTool { + execution_context: ExecutionContext, +} + +impl SetDeploymentSecretsTool { + /// Create a new SetDeploymentSecretsTool (defaults to InteractiveCli) + pub fn new() -> Self { + Self { + execution_context: ExecutionContext::InteractiveCli, + } + } + + /// Create with explicit execution context + pub fn with_context(ctx: ExecutionContext) -> Self { + Self { + execution_context: ctx, + } + } +} + +impl Default for SetDeploymentSecretsTool { + fn default() -> Self { + Self::new() + } +} + +impl Tool for SetDeploymentSecretsTool { + const NAME: &'static str = "set_deployment_secrets"; + + type Error = SetDeploymentSecretsError; + type Args = SetDeploymentSecretsArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: r#"Set environment variables and secrets on a deployment configuration. + +Secret values are sent securely to the backend and stored encrypted. +Values are NEVER returned in tool responses - only key names are confirmed. + +The is_secret flag (default: true) controls: +- true: Value masked as "********" in UI and API responses, passed via secure terraform -var flags +- false: Value visible in UI, stored in GitOps ConfigMap + +For secrets (is_secret=true): OMIT the "value" field. The user will be +prompted securely in the terminal. The value goes directly to the backend. +NEVER ask the user to type secret values in chat. + +For non-secrets (is_secret=false): Include the "value" field directly. + +Common secrets: DATABASE_URL, API_KEY, JWT_SECRET, REDIS_URL, etc. +Common non-secrets: NODE_ENV, PORT, LOG_LEVEL, APP_NAME, etc. + +**Parameters:** +- config_id: The deployment config ID (get from deploy_service or list_deployment_configs) +- secrets: Array of {key, value?, is_secret} objects + +**Prerequisites:** +- User must be authenticated via `sync-ctl auth login` +- A deployment config must exist (create one with deploy_service first) + +**Example:** +Set DATABASE_URL as a secret (value omitted — prompted in terminal) and NODE_ENV as a plain env var: +```json +{ + "config_id": "config-123", + "secrets": [ + {"key": "DATABASE_URL", "is_secret": true}, + {"key": "NODE_ENV", "value": "production", "is_secret": false} + ] +} +``` + +**IMPORTANT - After setting secrets:** +- Trigger a new deployment for the secrets to take effect +- Use trigger_deployment or deploy_service with preview_only=false"# + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "config_id": { + "type": "string", + "description": "The deployment config ID to set secrets on" + }, + "secrets": { + "type": "array", + "description": "Environment variables to set. For secrets, omit value \u{2014} user is prompted in terminal.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Environment variable name (e.g., DATABASE_URL)" + }, + "value": { + "type": "string", + "description": "Environment variable value. Omit for secrets \u{2014} user will be prompted securely in terminal." + }, + "is_secret": { + "type": "boolean", + "description": "Whether this is a secret (default: true). Secrets are masked in UI and API responses.", + "default": true + } + }, + "required": ["key"] + } + } + }, + "required": ["config_id", "secrets"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + // Validate config_id + if args.config_id.trim().is_empty() { + return Ok(format_error_for_llm( + "set_deployment_secrets", + ErrorCategory::ValidationFailed, + "config_id cannot be empty", + Some(vec![ + "Use list_deployment_configs to find valid config IDs", + "Or deploy a service first with deploy_service", + ]), + )); + } + + // Validate secrets list + if args.secrets.is_empty() { + return Ok(format_error_for_llm( + "set_deployment_secrets", + ErrorCategory::ValidationFailed, + "secrets array cannot be empty", + Some(vec!["Provide at least one secret with key and value"]), + )); + } + + // Validate key format + for secret in &args.secrets { + if secret.key.trim().is_empty() { + return Ok(format_error_for_llm( + "set_deployment_secrets", + ErrorCategory::ValidationFailed, + "Secret key cannot be empty", + Some(vec!["Each secret must have a non-empty key name"]), + )); + } + } + + // Resolve values — prompt for missing secret values in CLI mode + let mut resolved_secrets: Vec = Vec::new(); + for secret in &args.secrets { + let value = match &secret.value { + Some(v) => v.clone(), + None if self.execution_context.has_terminal() => { + match prompt_secret_value(&secret.key) { + SecretPromptResult::Value(v) => v, + SecretPromptResult::Skipped => continue, + SecretPromptResult::Cancelled => { + return Ok(format_error_for_llm( + "set_deployment_secrets", + ErrorCategory::ValidationFailed, + "Secret entry cancelled by user", + Some(vec!["The user cancelled secret input. Try again when ready."]), + )); + } + } + } + None => { + return Ok(format_error_for_llm( + "set_deployment_secrets", + ErrorCategory::ValidationFailed, + &format!( + "Value required for secret '{}' in server mode (no terminal available)", + secret.key + ), + Some(vec![ + "In server mode, all secrets must include a value", + "The frontend should collect secret values via its own password UI", + ]), + )); + } + }; + resolved_secrets.push(DeploymentSecretInput { + key: secret.key.clone(), + value, + is_secret: secret.is_secret, + }); + } + + if resolved_secrets.is_empty() { + return Ok(format_error_for_llm( + "set_deployment_secrets", + ErrorCategory::ValidationFailed, + "All secrets were skipped", + Some(vec!["Provide at least one secret value when prompted"]), + )); + } + + // Create the API client + let client = match PlatformApiClient::new() { + Ok(c) => c, + Err(e) => { + return Ok(format_api_error("set_deployment_secrets", e)); + } + }; + + // Call the API + match client + .update_deployment_config_secrets(&args.config_id, &resolved_secrets) + .await + { + Ok(()) => { + let secret_count = resolved_secrets.iter().filter(|s| s.is_secret).count(); + let plain_count = resolved_secrets.len() - secret_count; + + // SECURITY: Response contains ONLY keys, never values + let secrets_set: Vec = resolved_secrets + .iter() + .map(|s| { + json!({ + "key": s.key, + "is_secret": s.is_secret, + }) + }) + .collect(); + + let result = json!({ + "success": true, + "config_id": args.config_id, + "secrets_set": secrets_set, + "message": format!( + "Set {} environment variable(s) ({} secret, {} plain)", + resolved_secrets.len(), + secret_count, + plain_count + ), + "next_steps": [ + "Trigger a new deployment for the secrets to take effect", + format!("Use trigger_deployment with config_id '{}'", args.config_id), + ], + }); + + serde_json::to_string_pretty(&result) + .map_err(|e| SetDeploymentSecretsError(format!("Failed to serialize: {}", e))) + } + Err(e) => Ok(format_api_error("set_deployment_secrets", e)), + } + } +} + +/// Format a PlatformApiError for LLM consumption +fn format_api_error(tool_name: &str, error: PlatformApiError) -> String { + match error { + PlatformApiError::Unauthorized => format_error_for_llm( + tool_name, + ErrorCategory::PermissionDenied, + "Not authenticated - please run `sync-ctl auth login` first", + Some(vec![ + "The user needs to authenticate with the Syncable platform", + "Run: sync-ctl auth login", + ]), + ), + PlatformApiError::NotFound(msg) => format_error_for_llm( + tool_name, + ErrorCategory::ResourceUnavailable, + &format!("Deployment config not found: {}", msg), + Some(vec![ + "The config_id may be incorrect", + "Use list_deployment_configs to find valid config IDs", + ]), + ), + PlatformApiError::PermissionDenied(msg) => format_error_for_llm( + tool_name, + ErrorCategory::PermissionDenied, + &format!("Permission denied: {}", msg), + Some(vec!["Contact the project admin for access"]), + ), + PlatformApiError::RateLimited => format_error_for_llm( + tool_name, + ErrorCategory::ResourceUnavailable, + "Rate limit exceeded - please try again later", + Some(vec!["Wait a moment before retrying"]), + ), + PlatformApiError::HttpError(e) => format_error_for_llm( + tool_name, + ErrorCategory::NetworkError, + &format!("Network error: {}", e), + Some(vec!["Check network connectivity"]), + ), + PlatformApiError::ParseError(msg) => format_error_for_llm( + tool_name, + ErrorCategory::InternalError, + &format!("Failed to parse API response: {}", msg), + Some(vec!["This may be a temporary API issue"]), + ), + PlatformApiError::ApiError { status, message } => format_error_for_llm( + tool_name, + ErrorCategory::ExternalCommandFailed, + &format!("API error ({}): {}", status, message), + Some(vec!["Check the error message for details"]), + ), + PlatformApiError::ServerError { status, message } => format_error_for_llm( + tool_name, + ErrorCategory::ExternalCommandFailed, + &format!("Server error ({}): {}", status, message), + Some(vec!["Try again later"]), + ), + PlatformApiError::ConnectionFailed => format_error_for_llm( + tool_name, + ErrorCategory::NetworkError, + "Could not connect to Syncable API", + Some(vec!["Check your internet connection"]), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_name() { + assert_eq!(SetDeploymentSecretsTool::NAME, "set_deployment_secrets"); + } + + #[test] + fn test_tool_creation() { + let tool = SetDeploymentSecretsTool::new(); + assert!(format!("{:?}", tool).contains("SetDeploymentSecretsTool")); + } + + #[test] + fn test_tool_with_context() { + let tool = SetDeploymentSecretsTool::with_context(ExecutionContext::HeadlessServer); + assert!(format!("{:?}", tool).contains("SetDeploymentSecretsTool")); + } + + #[test] + fn test_default_is_secret_true() { + assert!(default_true()); + } +} diff --git a/src/platform/api/client.rs b/src/platform/api/client.rs index 9a97835f..a053d95b 100644 --- a/src/platform/api/client.rs +++ b/src/platform/api/client.rs @@ -8,11 +8,11 @@ use super::types::{ ApiErrorResponse, ArtifactRegistry, AvailableRepositoriesResponse, CloudCredentialStatus, CloudProvider, ClusterEntity, ConnectRepositoryRequest, ConnectRepositoryResponse, CreateDeploymentConfigRequest, CreateDeploymentConfigResponse, CreateRegistryRequest, - CreateRegistryResponse, DeploymentConfig, DeploymentTaskStatus, Environment, GenericResponse, - GetLogsResponse, GitHubInstallationUrlResponse, GitHubInstallationsResponse, - InitializeGitOpsRequest, InitializeGitOpsResponse, Organization, PaginatedDeployments, Project, - ProjectRepositoriesResponse, RegistryTaskStatus, TriggerDeploymentRequest, - TriggerDeploymentResponse, UserProfile, + CreateRegistryResponse, DeploymentConfig, DeploymentSecretInput, DeploymentTaskStatus, + Environment, GenericResponse, GetLogsResponse, GitHubInstallationUrlResponse, + GitHubInstallationsResponse, InitializeGitOpsRequest, InitializeGitOpsResponse, Organization, + PaginatedDeployments, Project, ProjectRepositoriesResponse, RegistryTaskStatus, + TriggerDeploymentRequest, TriggerDeploymentResponse, UserProfile, }; use crate::auth::credentials; use reqwest::Client; @@ -279,6 +279,53 @@ impl PlatformApiClient { Err(last_error.expect("retry loop should have set last_error")) } + /// Make an authenticated PUT request with a JSON body + /// Only retries on network errors (before request completes), not on server responses, + /// since PUT requests may not be idempotent. + async fn put(&self, path: &str, body: &B) -> Result { + let token = Self::get_auth_token()?; + let url = format!("{}{}", self.api_url, path); + + let mut last_error = None; + let mut backoff_ms = INITIAL_BACKOFF_MS; + + for attempt in 0..=MAX_RETRIES { + let result = self + .http_client + .put(&url) + .bearer_auth(&token) + .json(body) + .send() + .await; + + match result { + Ok(response) => { + // Got a response - don't retry PUT even on server errors + return self.handle_response(response).await; + } + Err(e) => { + // Network error before request completed - safe to retry + let platform_error = PlatformApiError::HttpError(e); + if attempt < MAX_RETRIES { + eprintln!( + "Network error (attempt {}/{}), retrying in {}ms...", + attempt + 1, + MAX_RETRIES + 1, + backoff_ms + ); + last_error = Some(platform_error); + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + } else { + return Err(platform_error); + } + } + } + } + + Err(last_error.expect("retry loop should have set last_error")) + } + /// Handle the HTTP response, converting errors appropriately async fn handle_response( &self, @@ -546,6 +593,7 @@ impl PlatformApiClient { name: &str, environment_type: &str, cluster_id: Option<&str>, + provider_regions: Option<&std::collections::HashMap>, ) -> Result { let mut request = serde_json::json!({ "projectId": project_id, @@ -557,6 +605,10 @@ impl PlatformApiClient { request["clusterId"] = serde_json::json!(cid); } + if let Some(regions) = provider_regions { + request["providerRegions"] = serde_json::json!(regions); + } + let response: GenericResponse = self.post("/api/environments", &request).await?; Ok(response.data) @@ -653,6 +705,27 @@ impl PlatformApiClient { Ok(response.data.config) } + /// Update environment variables / secrets on a deployment config + /// + /// SECURITY NOTE: This sends secret values over HTTPS to the backend. + /// The backend stores them encrypted. API responses mask secret values. + /// + /// Endpoint: PUT /api/deployment-configs/:configId/secrets + pub async fn update_deployment_config_secrets( + &self, + config_id: &str, + secrets: &[DeploymentSecretInput], + ) -> Result<()> { + let body = serde_json::json!({ + "configId": config_id, + "secrets": secrets, + }); + let _response: GenericResponse = self + .put(&format!("/api/deployment-configs/{}/secrets", config_id), &body) + .await?; + Ok(()) + } + /// Trigger a deployment using a deployment config /// /// Starts a new deployment for the specified config. Optionally specify @@ -708,7 +781,8 @@ impl PlatformApiClient { Some(l) => format!("/api/deployments/project/{}?limit={}", project_id, l), None => format!("/api/deployments/project/{}", project_id), }; - self.get(&path).await + let response: GenericResponse = self.get(&path).await?; + Ok(response.data) } /// Get container logs for a deployed service @@ -1144,6 +1218,7 @@ mod tests { let name = "staging"; let environment_type = "cloud"; let cluster_id: Option<&str> = None; + let provider_regions: Option<&std::collections::HashMap> = None; let mut request = serde_json::json!({ "projectId": project_id, @@ -1155,8 +1230,37 @@ mod tests { request["clusterId"] = serde_json::json!(cid); } + if let Some(regions) = provider_regions { + request["providerRegions"] = serde_json::json!(regions); + } + let json_str = request.to_string(); assert!(json_str.contains("\"environmentType\":\"cloud\"")); assert!(!json_str.contains("clusterId")); + assert!(!json_str.contains("providerRegions")); + } + + #[test] + fn test_create_environment_request_with_provider_regions() { + let project_id = "proj-123"; + let name = "staging"; + let environment_type = "cloud"; + + let mut provider_regions = std::collections::HashMap::new(); + provider_regions.insert("gcp".to_string(), "us-central1".to_string()); + provider_regions.insert("azure".to_string(), "eastus".to_string()); + + let mut request = serde_json::json!({ + "projectId": project_id, + "name": name, + "environmentType": environment_type, + }); + + request["providerRegions"] = serde_json::json!(&provider_regions); + + let json_str = request.to_string(); + assert!(json_str.contains("\"providerRegions\"")); + assert!(json_str.contains("\"gcp\":\"us-central1\"")); + assert!(json_str.contains("\"azure\":\"eastus\"")); } } diff --git a/src/platform/api/types.rs b/src/platform/api/types.rs index 178bc946..e1aced61 100644 --- a/src/platform/api/types.rs +++ b/src/platform/api/types.rs @@ -158,10 +158,10 @@ impl CloudProvider { /// Returns whether this provider is currently available for deployment /// - /// Returns `true` for GCP and Hetzner (currently supported). - /// Returns `false` for AWS, Azure, Scaleway, Cyso (coming soon). + /// Returns `true` for GCP, Hetzner, and Azure (currently supported). + /// Returns `false` for AWS, Scaleway, Cyso (coming soon). pub fn is_available(&self) -> bool { - matches!(self, CloudProvider::Gcp | CloudProvider::Hetzner) + matches!(self, CloudProvider::Gcp | CloudProvider::Hetzner | CloudProvider::Azure) } } @@ -203,6 +203,10 @@ pub struct CloudCredentialStatus { pub id: String, /// The cloud provider this credential is for (lowercase: gcp, aws, azure, hetzner) pub provider: String, + /// Provider account identifier (GCP project ID or Azure subscription ID) + /// Used to pass to cloud runner config without prompting user + #[serde(default)] + pub provider_account_id: Option, // NOTE: Never include tokens/secrets here - this is intentionally minimal } @@ -243,12 +247,40 @@ pub struct Environment { /// When the environment was last updated #[serde(default)] pub updated_at: Option, + /// Per-provider default regions (e.g., { "gcp": "us-central1", "azure": "eastus" }) + #[serde(default)] + pub provider_regions: Option>, } fn default_true() -> bool { true } +// ============================================================================= +// Deployment Secret Types +// ============================================================================= + +/// Environment variable / secret for a deployment config +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentSecretInput { + /// Environment variable name (e.g., "DATABASE_URL") + pub key: String, + /// Environment variable value + pub value: String, + /// If true, value is masked in API responses and passed as a Terraform -var secret + #[serde(default)] + pub is_secret: bool, +} + +/// Request to update secrets on an existing deployment config +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkUpdateSecretsRequest { + pub config_id: String, + pub secrets: Vec, +} + // ============================================================================= // Deployment Types // ============================================================================= @@ -361,6 +393,12 @@ pub struct DeployedService { pub commit_sha: Option, /// Public URL of the deployed service pub public_url: Option, + /// Private IP of the deployed service (for internal/private network access) + #[serde(default)] + pub private_ip: Option, + /// Cloud provider this service is deployed on (e.g. "hetzner", "gcp", "azure") + #[serde(default)] + pub cloud_provider: Option, /// When this deployment was created pub created_at: DateTime, } @@ -708,10 +746,17 @@ pub struct WizardDeploymentConfig { pub region: Option, /// Machine/Instance type for Cloud Runner (e.g., "cx22" for Hetzner, "e2-small" for GCP) pub machine_type: Option, + /// CPU allocation for Cloud Run / ACA (e.g., "2", "0.5") + pub cpu: Option, + /// Memory allocation for Cloud Run / ACA (e.g., "2Gi", "1.0Gi") + pub memory: Option, /// Whether the service should be publicly accessible pub is_public: bool, /// Health check endpoint path (optional, e.g., "/health" or "/healthz") pub health_check_path: Option, + /// Environment variables and secrets for the deployment + #[serde(default)] + pub secrets: Vec, } impl WizardDeploymentConfig { @@ -914,6 +959,166 @@ pub fn build_cloud_runner_config( } } +/// Input for building provider-specific cloud runner config (v2) +/// +/// Replaces the old positional-argument function with a struct that +/// supports all three providers (GCP Cloud Run, Azure ACA, Hetzner). +#[derive(Debug, Clone, Default)] +pub struct CloudRunnerConfigInput { + /// Cloud provider + pub provider: Option, + /// Region/location for deployment + pub region: Option, + /// Server type (Hetzner only) + pub server_type: Option, + /// GCP project ID (from credentials API) + pub gcp_project_id: Option, + /// CPU allocation (GCP Cloud Run / Azure ACA) + pub cpu: Option, + /// Memory allocation (GCP Cloud Run / Azure ACA) + pub memory: Option, + /// Min instances/replicas (GCP Cloud Run / Azure ACA) + pub min_instances: Option, + /// Max instances/replicas (GCP Cloud Run / Azure ACA) + pub max_instances: Option, + /// Concurrency (GCP Cloud Run only) + pub concurrency: Option, + /// Timeout in seconds (GCP Cloud Run only) + pub timeout_seconds: Option, + /// Ingress settings (GCP Cloud Run only) + pub ingress_settings: Option, + /// Allow unauthenticated access (GCP Cloud Run) + pub allow_unauthenticated: Option, + /// CPU boost on startup (GCP Cloud Run only) + pub cpu_boost: Option, + /// VPC connector (GCP Cloud Run only) + pub vpc_connector: Option, + /// Azure subscription ID (from credentials API) + pub subscription_id: Option, + /// Whether the service is publicly accessible + pub is_public: Option, + /// Health check endpoint path + pub health_check_path: Option, +} + +/// Build the cloud runner config in the provider-nested structure expected by backend (v2). +/// +/// Supports all three providers with full configuration: +/// - **GCP Cloud Run**: `{ "gcp": { region, projectId, cpu, memory, minInstances, ... } }` +/// - **Azure ACA**: `{ "azure": { location, subscriptionId, deploymentType, cpu, memory, ... }, "cpu": "...", "memory": "..." }` +/// - **Hetzner**: `{ "hetzner": { location, serverType } }` (unchanged) +pub fn build_cloud_runner_config_v2(input: &CloudRunnerConfigInput) -> serde_json::Value { + let provider = match &input.provider { + Some(p) => p, + None => return serde_json::json!({}), + }; + + match provider { + CloudProvider::Gcp => { + let mut gcp_config = serde_json::Map::new(); + + if let Some(ref region) = input.region { + gcp_config.insert("region".to_string(), serde_json::json!(region)); + } + if let Some(ref project_id) = input.gcp_project_id { + gcp_config.insert("projectId".to_string(), serde_json::json!(project_id)); + } + if let Some(ref cpu) = input.cpu { + gcp_config.insert("cpu".to_string(), serde_json::json!(cpu)); + } + if let Some(ref memory) = input.memory { + gcp_config.insert("memory".to_string(), serde_json::json!(memory)); + } + if let Some(min) = input.min_instances { + gcp_config.insert("minInstances".to_string(), serde_json::json!(min)); + } + if let Some(max) = input.max_instances { + gcp_config.insert("maxInstances".to_string(), serde_json::json!(max)); + } + if let Some(conc) = input.concurrency { + gcp_config.insert("concurrency".to_string(), serde_json::json!(conc)); + } + if let Some(timeout) = input.timeout_seconds { + gcp_config.insert("timeoutSeconds".to_string(), serde_json::json!(timeout)); + } + if let Some(ref ingress) = input.ingress_settings { + gcp_config.insert("ingressSettings".to_string(), serde_json::json!(ingress)); + } + if let Some(allow) = input.allow_unauthenticated { + gcp_config.insert("allowUnauthenticated".to_string(), serde_json::json!(allow)); + } else if let Some(is_pub) = input.is_public { + gcp_config.insert("allowUnauthenticated".to_string(), serde_json::json!(is_pub)); + } + if let Some(boost) = input.cpu_boost { + gcp_config.insert("cpuBoost".to_string(), serde_json::json!(boost)); + } + if let Some(ref vpc) = input.vpc_connector { + gcp_config.insert("vpcConnector".to_string(), serde_json::json!(vpc)); + } + if let Some(ref path) = input.health_check_path { + gcp_config.insert("healthCheckPath".to_string(), serde_json::json!(path)); + } + + serde_json::json!({ "gcp": serde_json::Value::Object(gcp_config) }) + } + CloudProvider::Azure => { + let mut azure_config = serde_json::Map::new(); + + if let Some(ref region) = input.region { + azure_config.insert("location".to_string(), serde_json::json!(region)); + } + if let Some(ref sub_id) = input.subscription_id { + azure_config.insert("subscriptionId".to_string(), serde_json::json!(sub_id)); + } + azure_config.insert("deploymentType".to_string(), serde_json::json!("azure_container_app")); + if let Some(ref cpu) = input.cpu { + azure_config.insert("cpu".to_string(), serde_json::json!(cpu)); + } + if let Some(ref memory) = input.memory { + azure_config.insert("memory".to_string(), serde_json::json!(memory)); + } + if let Some(is_pub) = input.is_public { + azure_config.insert("isPublic".to_string(), serde_json::json!(is_pub)); + } + if let Some(min) = input.min_instances { + azure_config.insert("minReplicas".to_string(), serde_json::json!(min)); + } + if let Some(max) = input.max_instances { + azure_config.insert("maxReplicas".to_string(), serde_json::json!(max)); + } + + // Hoist cpu/memory to top level (matching frontend behavior) + let mut top_level = serde_json::Map::new(); + top_level.insert("azure".to_string(), serde_json::Value::Object(azure_config)); + if let Some(ref cpu) = input.cpu { + top_level.insert("cpu".to_string(), serde_json::json!(cpu)); + } + if let Some(ref memory) = input.memory { + top_level.insert("memory".to_string(), serde_json::json!(memory)); + } + + serde_json::Value::Object(top_level) + } + CloudProvider::Hetzner => { + serde_json::json!({ + "hetzner": { + "location": input.region.as_deref().unwrap_or(""), + "serverType": input.server_type.as_deref().unwrap_or("") + } + }) + } + // For other providers, use a generic structure + _ => { + serde_json::json!({ + provider.as_str(): { + "region": input.region.as_deref().unwrap_or(""), + "isPublic": input.is_public.unwrap_or(false) + } + }) + } + } +} + /// Request body for creating a new deployment configuration #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -966,6 +1171,9 @@ pub struct CreateDeploymentConfigRequest { /// Backend expects: `{ "gcp": {...} }` or `{ "hetzner": {...} }` #[serde(skip_serializing_if = "Option::is_none")] pub cloud_runner_config: Option, + /// Environment variables and secrets for the deployment + #[serde(skip_serializing_if = "Option::is_none")] + pub secrets: Option>, } /// Provider deployment availability status for the wizard @@ -1427,10 +1635,10 @@ mod tests { // Available providers assert!(CloudProvider::Gcp.is_available()); assert!(CloudProvider::Hetzner.is_available()); + assert!(CloudProvider::Azure.is_available()); // Coming soon providers assert!(!CloudProvider::Aws.is_available()); - assert!(!CloudProvider::Azure.is_available()); assert!(!CloudProvider::Scaleway.is_available()); assert!(!CloudProvider::Cyso.is_available()); } @@ -1440,15 +1648,25 @@ mod tests { let status = CloudCredentialStatus { id: "cred-123".to_string(), provider: "gcp".to_string(), + provider_account_id: Some("my-gcp-project".to_string()), }; let json = serde_json::to_string(&status).unwrap(); assert!(json.contains("\"id\":\"cred-123\"")); assert!(json.contains("\"provider\":\"gcp\"")); + assert!(json.contains("\"providerAccountId\":\"my-gcp-project\"")); // Verify no tokens/secrets in serialized output assert!(!json.contains("token")); assert!(!json.contains("secret")); - assert!(!json.contains("key")); + } + + #[test] + fn test_cloud_credential_status_deserialization_without_account_id() { + let json = r#"{"id":"cred-456","provider":"azure"}"#; + let status: CloudCredentialStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.id, "cred-456"); + assert_eq!(status.provider, "azure"); + assert!(status.provider_account_id.is_none()); } // ========================================================================= @@ -1576,6 +1794,7 @@ mod tests { is_active: true, created_at: Some("2024-01-01T00:00:00Z".to_string()), updated_at: Some("2024-01-01T00:00:00Z".to_string()), + provider_regions: None, }; let json = serde_json::to_string(&env).unwrap(); @@ -1643,6 +1862,7 @@ mod tests { auto_deploy_enabled: true, is_public: None, cloud_runner_config: None, + secrets: None, }; let json = serde_json::to_string(&request).unwrap(); @@ -1651,6 +1871,7 @@ mod tests { // Optional None fields should be skipped assert!(!json.contains("clusterId")); assert!(!json.contains("isPublic")); + assert!(!json.contains("secrets")); } // ========================================================================= @@ -1716,4 +1937,110 @@ mod tests { assert_eq!(hetzner.get("serverType").and_then(|v| v.as_str()), Some("cx32")); // Hetzner config doesn't include health check path in current implementation } + + // ========================================================================= + // Cloud Runner Config V2 Builder Tests + // ========================================================================= + + #[test] + fn test_build_cloud_runner_config_v2_gcp() { + let input = CloudRunnerConfigInput { + provider: Some(CloudProvider::Gcp), + region: Some("us-central1".to_string()), + gcp_project_id: Some("my-project".to_string()), + cpu: Some("2".to_string()), + memory: Some("2Gi".to_string()), + min_instances: Some(0), + max_instances: Some(10), + allow_unauthenticated: Some(true), + health_check_path: Some("/health".to_string()), + ..Default::default() + }; + let config = build_cloud_runner_config_v2(&input); + let gcp = config.get("gcp").expect("should have gcp key"); + assert_eq!(gcp.get("region").and_then(|v| v.as_str()), Some("us-central1")); + assert_eq!(gcp.get("projectId").and_then(|v| v.as_str()), Some("my-project")); + assert_eq!(gcp.get("cpu").and_then(|v| v.as_str()), Some("2")); + assert_eq!(gcp.get("memory").and_then(|v| v.as_str()), Some("2Gi")); + assert_eq!(gcp.get("minInstances").and_then(|v| v.as_i64()), Some(0)); + assert_eq!(gcp.get("maxInstances").and_then(|v| v.as_i64()), Some(10)); + assert_eq!(gcp.get("allowUnauthenticated").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(gcp.get("healthCheckPath").and_then(|v| v.as_str()), Some("/health")); + } + + #[test] + fn test_build_cloud_runner_config_v2_azure() { + let input = CloudRunnerConfigInput { + provider: Some(CloudProvider::Azure), + region: Some("eastus".to_string()), + subscription_id: Some("sub-123".to_string()), + cpu: Some("0.5".to_string()), + memory: Some("1.0Gi".to_string()), + is_public: Some(true), + min_instances: Some(0), + max_instances: Some(5), + ..Default::default() + }; + let config = build_cloud_runner_config_v2(&input); + let azure = config.get("azure").expect("should have azure key"); + assert_eq!(azure.get("location").and_then(|v| v.as_str()), Some("eastus")); + assert_eq!(azure.get("subscriptionId").and_then(|v| v.as_str()), Some("sub-123")); + assert_eq!(azure.get("deploymentType").and_then(|v| v.as_str()), Some("azure_container_app")); + assert_eq!(azure.get("cpu").and_then(|v| v.as_str()), Some("0.5")); + assert_eq!(azure.get("memory").and_then(|v| v.as_str()), Some("1.0Gi")); + assert_eq!(azure.get("isPublic").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(azure.get("minReplicas").and_then(|v| v.as_i64()), Some(0)); + assert_eq!(azure.get("maxReplicas").and_then(|v| v.as_i64()), Some(5)); + // Top-level hoisted cpu/memory + assert_eq!(config.get("cpu").and_then(|v| v.as_str()), Some("0.5")); + assert_eq!(config.get("memory").and_then(|v| v.as_str()), Some("1.0Gi")); + } + + #[test] + fn test_build_cloud_runner_config_v2_hetzner() { + let input = CloudRunnerConfigInput { + provider: Some(CloudProvider::Hetzner), + region: Some("nbg1".to_string()), + server_type: Some("cx22".to_string()), + ..Default::default() + }; + let config = build_cloud_runner_config_v2(&input); + let hetzner = config.get("hetzner").expect("should have hetzner key"); + assert_eq!(hetzner.get("location").and_then(|v| v.as_str()), Some("nbg1")); + assert_eq!(hetzner.get("serverType").and_then(|v| v.as_str()), Some("cx22")); + } + + #[test] + fn test_build_cloud_runner_config_v2_no_provider() { + let input = CloudRunnerConfigInput::default(); + let config = build_cloud_runner_config_v2(&input); + assert_eq!(config, serde_json::json!({})); + } + + #[test] + fn test_environment_provider_regions() { + let json = r#"{ + "id": "env-1", + "name": "staging", + "projectId": "proj-1", + "environmentType": "cloud", + "providerRegions": {"gcp": "us-central1", "azure": "eastus"} + }"#; + let env: Environment = serde_json::from_str(json).unwrap(); + let regions = env.provider_regions.unwrap(); + assert_eq!(regions.get("gcp"), Some(&"us-central1".to_string())); + assert_eq!(regions.get("azure"), Some(&"eastus".to_string())); + } + + #[test] + fn test_environment_without_provider_regions() { + let json = r#"{ + "id": "env-1", + "name": "staging", + "projectId": "proj-1", + "environmentType": "cloud" + }"#; + let env: Environment = serde_json::from_str(json).unwrap(); + assert!(env.provider_regions.is_none()); + } } diff --git a/src/server/routes.rs b/src/server/routes.rs index b9a170d8..cb945fbd 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -471,6 +471,7 @@ mod tests { #[tokio::test] async fn test_post_message_accepted() { use crate::server::ServerState; + use http::StatusCode; let state = ServerState::new(); let mut msg_rx = state.take_message_receiver().await.expect("Should get receiver"); @@ -495,10 +496,8 @@ mod tests { // Call post_message handler with raw JSON let response = post_message(State(state), Json(input_json)).await; - // Verify response - assert_eq!(response.0["status"], "accepted"); - assert_eq!(response.0["thread_id"], thread_id.to_string()); - assert_eq!(response.0["run_id"], run_id.to_string()); + // Verify response is SSE stream (HTTP 200) + assert_eq!(response.status(), StatusCode::OK); // Verify message was routed let received = msg_rx.recv().await.expect("Should receive message"); @@ -508,6 +507,7 @@ mod tests { #[tokio::test] async fn test_post_message_copilotkit_envelope() { use crate::server::ServerState; + use http::StatusCode; let state = ServerState::new(); let mut msg_rx = state.take_message_receiver().await.expect("Should get receiver"); @@ -529,9 +529,8 @@ mod tests { // Call post_message handler let response = post_message(State(state), Json(input_json)).await; - // Verify response - assert_eq!(response.0["status"], "accepted"); - assert_eq!(response.0["thread_id"], "thread-123"); + // Verify response is SSE stream (HTTP 200) + assert_eq!(response.status(), StatusCode::OK); // Verify message was routed let received = msg_rx.recv().await.expect("Should receive message"); diff --git a/src/wizard/cloud_provider_data.rs b/src/wizard/cloud_provider_data.rs index 398c08ae..643a24cd 100644 --- a/src/wizard/cloud_provider_data.rs +++ b/src/wizard/cloud_provider_data.rs @@ -35,6 +35,109 @@ pub struct MachineType { pub description: Option<&'static str>, } +// ============================================================================= +// Azure Container Apps - Resource Pairs +// ============================================================================= + +/// Azure Container Apps paired CPU/memory combo +#[derive(Debug, Clone)] +pub struct AcaResourcePair { + /// CPU allocation (e.g., "0.25") + pub cpu: &'static str, + /// Memory allocation (e.g., "0.5Gi") + pub memory: &'static str, + /// Display label (e.g., "0.25 vCPU, 0.5 GB") + pub label: &'static str, +} + +/// Azure Container Apps resource pairs (fixed by Azure, 8 combos) +pub static ACA_RESOURCE_PAIRS: &[AcaResourcePair] = &[ + AcaResourcePair { cpu: "0.25", memory: "0.5Gi", label: "0.25 vCPU, 0.5 GB" }, + AcaResourcePair { cpu: "0.5", memory: "1.0Gi", label: "0.5 vCPU, 1 GB" }, + AcaResourcePair { cpu: "0.75", memory: "1.5Gi", label: "0.75 vCPU, 1.5 GB" }, + AcaResourcePair { cpu: "1.0", memory: "2.0Gi", label: "1 vCPU, 2 GB" }, + AcaResourcePair { cpu: "1.25", memory: "2.5Gi", label: "1.25 vCPU, 2.5 GB" }, + AcaResourcePair { cpu: "1.5", memory: "3.0Gi", label: "1.5 vCPU, 3 GB" }, + AcaResourcePair { cpu: "1.75", memory: "3.5Gi", label: "1.75 vCPU, 3.5 GB" }, + AcaResourcePair { cpu: "2.0", memory: "4.0Gi", label: "2 vCPU, 4 GB" }, +]; + +/// Azure regions (Container Apps supported regions) +pub static AZURE_REGIONS: &[CloudRegion] = &[ + // Americas + CloudRegion { id: "eastus", name: "East US", location: "Virginia" }, + CloudRegion { id: "eastus2", name: "East US 2", location: "Virginia" }, + CloudRegion { id: "westus", name: "West US", location: "California" }, + CloudRegion { id: "westus2", name: "West US 2", location: "Washington" }, + CloudRegion { id: "westus3", name: "West US 3", location: "Arizona" }, + CloudRegion { id: "centralus", name: "Central US", location: "Iowa" }, + CloudRegion { id: "canadacentral", name: "Canada Central", location: "Toronto" }, + CloudRegion { id: "brazilsouth", name: "Brazil South", location: "São Paulo" }, + // Europe + CloudRegion { id: "westeurope", name: "West Europe", location: "Netherlands" }, + CloudRegion { id: "northeurope", name: "North Europe", location: "Ireland" }, + CloudRegion { id: "uksouth", name: "UK South", location: "London" }, + CloudRegion { id: "ukwest", name: "UK West", location: "Cardiff" }, + CloudRegion { id: "germanywestcentral", name: "Germany West Central", location: "Frankfurt" }, + CloudRegion { id: "francecentral", name: "France Central", location: "Paris" }, + CloudRegion { id: "swedencentral", name: "Sweden Central", location: "Gävle" }, + // Asia Pacific + CloudRegion { id: "eastasia", name: "East Asia", location: "Hong Kong" }, + CloudRegion { id: "southeastasia", name: "Southeast Asia", location: "Singapore" }, + CloudRegion { id: "japaneast", name: "Japan East", location: "Tokyo" }, + CloudRegion { id: "japanwest", name: "Japan West", location: "Osaka" }, + CloudRegion { id: "koreacentral", name: "Korea Central", location: "Seoul" }, + CloudRegion { id: "australiaeast", name: "Australia East", location: "Sydney" }, + CloudRegion { id: "centralindia", name: "Central India", location: "Pune" }, +]; + +// ============================================================================= +// GCP Cloud Run - CPU/Memory Constraints +// ============================================================================= + +/// GCP Cloud Run CPU/memory constraint +#[derive(Debug, Clone)] +pub struct CloudRunCpuMemory { + /// CPU allocation (e.g., "1") + pub cpu: &'static str, + /// Available memory options for this CPU level + pub memory_options: &'static [&'static str], + /// Default memory for this CPU level + pub default_memory: &'static str, +} + +/// GCP Cloud Run CPU/memory constraints (matching frontend CLOUD_RUN_MEMORY_CONSTRAINTS) +pub static CLOUD_RUN_CPU_MEMORY: &[CloudRunCpuMemory] = &[ + CloudRunCpuMemory { cpu: "1", memory_options: &["128Mi", "256Mi", "512Mi", "1Gi", "2Gi", "4Gi"], default_memory: "512Mi" }, + CloudRunCpuMemory { cpu: "2", memory_options: &["256Mi", "512Mi", "1Gi", "2Gi", "4Gi", "8Gi"], default_memory: "2Gi" }, + CloudRunCpuMemory { cpu: "4", memory_options: &["512Mi", "1Gi", "2Gi", "4Gi", "8Gi", "16Gi"], default_memory: "4Gi" }, + CloudRunCpuMemory { cpu: "6", memory_options: &["1Gi", "2Gi", "4Gi", "8Gi", "16Gi", "24Gi"], default_memory: "8Gi" }, + CloudRunCpuMemory { cpu: "8", memory_options: &["2Gi", "4Gi", "8Gi", "16Gi", "24Gi", "32Gi"], default_memory: "16Gi" }, +]; + +// ============================================================================= +// Validation Helpers +// ============================================================================= + +/// Validate that a CPU/memory pair is valid for Azure Container Apps +pub fn validate_aca_cpu_memory(cpu: &str, memory: &str) -> bool { + ACA_RESOURCE_PAIRS.iter().any(|p| p.cpu == cpu && p.memory == memory) +} + +/// Validate that a CPU/memory pair is valid for GCP Cloud Run +pub fn validate_cloud_run_cpu_memory(cpu: &str, memory: &str) -> bool { + CLOUD_RUN_CPU_MEMORY.iter().any(|c| c.cpu == cpu && c.memory_options.contains(&memory)) +} + +/// Get available memory options for a given GCP Cloud Run CPU level +pub fn get_cloud_run_memory_for_cpu(cpu: &str) -> &'static [&'static str] { + CLOUD_RUN_CPU_MEMORY + .iter() + .find(|c| c.cpu == cpu) + .map(|c| c.memory_options) + .unwrap_or(&[]) +} + // ============================================================================= // GCP (Google Cloud Platform) - Static data // ============================================================================= @@ -85,7 +188,8 @@ pub fn get_regions_for_provider(provider: &CloudProvider) -> &'static [CloudRegi match provider { CloudProvider::Hetzner => &[], // Use dynamic fetching for Hetzner CloudProvider::Gcp => GCP_REGIONS, - _ => &[], // AWS, Azure not yet supported + CloudProvider::Azure => AZURE_REGIONS, + _ => &[], // AWS not yet supported } } @@ -104,15 +208,18 @@ pub fn get_default_region(provider: &CloudProvider) -> &'static str { match provider { CloudProvider::Hetzner => "nbg1", CloudProvider::Gcp => "us-central1", + CloudProvider::Azure => "eastus", _ => "", } } /// Get default machine type for a provider +/// For Azure, returns the default CPU value (used with ACA resource pairs) pub fn get_default_machine_type(provider: &CloudProvider) -> &'static str { match provider { CloudProvider::Hetzner => "cx22", CloudProvider::Gcp => "e2-small", + CloudProvider::Azure => "0.5", _ => "", } } @@ -449,8 +556,68 @@ mod tests { fn test_defaults() { assert_eq!(get_default_region(&CloudProvider::Hetzner), "nbg1"); assert_eq!(get_default_region(&CloudProvider::Gcp), "us-central1"); + assert_eq!(get_default_region(&CloudProvider::Azure), "eastus"); assert_eq!(get_default_machine_type(&CloudProvider::Hetzner), "cx22"); assert_eq!(get_default_machine_type(&CloudProvider::Gcp), "e2-small"); + assert_eq!(get_default_machine_type(&CloudProvider::Azure), "0.5"); + } + + #[test] + fn test_azure_regions() { + assert!(!AZURE_REGIONS.is_empty()); + assert_eq!(AZURE_REGIONS.len(), 22); + assert!(AZURE_REGIONS.iter().any(|r| r.id == "eastus")); + assert!(AZURE_REGIONS.iter().any(|r| r.id == "westeurope")); + } + + #[test] + fn test_azure_regions_via_provider() { + let regions = get_regions_for_provider(&CloudProvider::Azure); + assert!(!regions.is_empty()); + assert_eq!(regions.len(), 22); + } + + #[test] + fn test_aca_resource_pairs() { + assert_eq!(ACA_RESOURCE_PAIRS.len(), 8); + assert_eq!(ACA_RESOURCE_PAIRS[0].cpu, "0.25"); + assert_eq!(ACA_RESOURCE_PAIRS[0].memory, "0.5Gi"); + assert_eq!(ACA_RESOURCE_PAIRS[7].cpu, "2.0"); + assert_eq!(ACA_RESOURCE_PAIRS[7].memory, "4.0Gi"); + } + + #[test] + fn test_validate_aca_cpu_memory() { + assert!(validate_aca_cpu_memory("0.5", "1.0Gi")); + assert!(validate_aca_cpu_memory("2.0", "4.0Gi")); + assert!(!validate_aca_cpu_memory("0.5", "4.0Gi")); // invalid pair + assert!(!validate_aca_cpu_memory("3.0", "8.0Gi")); // non-existent + } + + #[test] + fn test_cloud_run_cpu_memory() { + assert_eq!(CLOUD_RUN_CPU_MEMORY.len(), 5); + assert_eq!(CLOUD_RUN_CPU_MEMORY[0].cpu, "1"); + assert_eq!(CLOUD_RUN_CPU_MEMORY[0].default_memory, "512Mi"); + } + + #[test] + fn test_validate_cloud_run_cpu_memory() { + assert!(validate_cloud_run_cpu_memory("2", "4Gi")); + assert!(validate_cloud_run_cpu_memory("1", "512Mi")); + assert!(!validate_cloud_run_cpu_memory("1", "16Gi")); // too big for 1 CPU + assert!(!validate_cloud_run_cpu_memory("3", "4Gi")); // non-existent CPU + } + + #[test] + fn test_get_cloud_run_memory_for_cpu() { + let options = get_cloud_run_memory_for_cpu("1"); + assert_eq!(options.len(), 6); + assert!(options.contains(&"512Mi")); + assert!(options.contains(&"4Gi")); + + let empty = get_cloud_run_memory_for_cpu("99"); + assert!(empty.is_empty()); } #[test] diff --git a/src/wizard/config_form.rs b/src/wizard/config_form.rs index 90ccc635..b45edf2e 100644 --- a/src/wizard/config_form.rs +++ b/src/wizard/config_form.rs @@ -1,10 +1,121 @@ //! Deployment configuration form for the wizard use crate::analyzer::DiscoveredDockerfile; -use crate::platform::api::types::{CloudProvider, DeploymentTarget, WizardDeploymentConfig}; +use crate::platform::api::types::{ + CloudProvider, DeploymentSecretInput, DeploymentTarget, WizardDeploymentConfig, +}; use crate::wizard::render::display_step_header; use colored::Colorize; -use inquire::{Confirm, InquireError, Text}; +use inquire::{Confirm, InquireError, Select, Text}; +use std::path::{Path, PathBuf}; + +const IGNORED_DIRS: &[&str] = &[ + "node_modules", + ".git", + "target", + "vendor", + "dist", + ".next", + ".nuxt", + "__pycache__", + ".venv", + "venv", +]; +const MAX_DEPTH: usize = 3; + +/// Discover `.env` files in the project directory (max depth 3, skipping common build dirs). +/// +/// Returns paths relative to `root`, sorted. +pub fn discover_env_files(root: &Path) -> Vec { + let mut found = Vec::new(); + walk_for_env_files(root, root, 0, &mut found); + found.sort(); + found +} + +fn walk_for_env_files(root: &Path, dir: &Path, depth: usize, found: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if path.is_file() && name_str.starts_with(".env") && !name_str.starts_with(".envrc") { + if let Ok(rel) = path.strip_prefix(root) { + found.push(rel.to_path_buf()); + } + } else if path.is_dir() && !IGNORED_DIRS.contains(&name_str.as_ref()) { + walk_for_env_files(root, &path, depth + 1, found); + } + } +} + +/// Parsed entry from a `.env` file. +#[derive(Debug, Clone)] +pub struct EnvFileEntry { + pub key: String, + pub value: String, + pub is_secret: bool, +} + +/// Parse a `.env` file into key/value entries. +/// +/// Skips empty lines and comments (`#`). Strips surrounding quotes from values. +/// Each entry is tagged with `is_secret` based on key patterns. +pub fn parse_env_file(path: &Path) -> Result, std::io::Error> { + let content = std::fs::read_to_string(path)?; + let entries = content + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + let key = key.trim().to_string(); + let value = value.trim().to_string(); + let value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .map(|v| v.to_string()) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|v| v.strip_suffix('\'')) + .map(|v| v.to_string()) + }) + .unwrap_or(value); + if key.is_empty() { + return None; + } + Some(EnvFileEntry { + is_secret: is_likely_secret(&key), + key, + value, + }) + }) + .collect(); + Ok(entries) +} + +/// Count non-empty, non-comment KEY=VALUE lines in a file. +fn count_env_vars_in_file(path: &Path) -> usize { + std::fs::read_to_string(path) + .map(|c| { + c.lines() + .filter(|l| { + let l = l.trim(); + !l.is_empty() && !l.starts_with('#') && l.contains('=') + }) + .count() + }) + .unwrap_or(0) +} /// Result of config form step #[derive(Debug, Clone)] @@ -34,6 +145,8 @@ pub fn collect_config( discovered_dockerfile: &DiscoveredDockerfile, region: Option, machine_type: Option, + cpu: Option, + memory: Option, step_number: u8, ) -> ConfigFormResult { display_step_header( @@ -56,7 +169,11 @@ pub fn collect_config( if let Some(ref r) = region { println!(" {} Region: {}", "│".dimmed(), r.cyan()); } - if let Some(ref m) = machine_type { + if let Some(ref c) = cpu { + if let Some(ref m) = memory { + println!(" {} Resources: {} vCPU / {}", "│".dimmed(), c.cyan(), m.cyan()); + } + } else if let Some(ref m) = machine_type { println!(" {} Machine: {}", "│".dimmed(), m.cyan()); } println!(); @@ -179,8 +296,11 @@ pub fn collect_config( auto_deploy, region, machine_type, + cpu, + memory, is_public, health_check_path, + secrets: Vec::new(), // Populated by collect_env_vars() in orchestrator }; println!("\n{} Configuration complete: {}", "✓".green(), service_name); @@ -188,6 +308,308 @@ pub fn collect_config( ConfigFormResult::Completed(config) } +/// Collect environment variables interactively +/// +/// Auto-discovers `.env` files in the project directory and presents them +/// as selectable options alongside manual entry. Uses `is_likely_secret()` +/// per-key instead of marking all values as secret. +/// +/// Returns collected env vars, or empty vec if user skips. +pub fn collect_env_vars(project_path: &Path) -> Vec { + println!(); + println!( + "{}", + "─── Environment Variables ──────────────────────".dimmed() + ); + + let wants_env_vars = match Confirm::new("Add environment variables?") + .with_default(false) + .with_help_message("Configure env vars / secrets for the deployment") + .prompt() + { + Ok(v) => v, + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + return Vec::new(); + } + Err(_) => return Vec::new(), + }; + + if !wants_env_vars { + return Vec::new(); + } + + // Auto-discover .env files + let discovered = discover_env_files(project_path); + + // Build select options + let mut options: Vec = Vec::new(); + + if !discovered.is_empty() { + println!( + "\n Found {} .env file(s):\n", + discovered.len().to_string().cyan() + ); + for f in &discovered { + let abs = project_path.join(f); + let count = count_env_vars_in_file(&abs); + let label = format!( + " {:<30} {} vars", + f.display(), + count.to_string().cyan() + ); + println!(" {}", label); + options.push(format!("{:<30} {} vars", f.display(), count)); + } + println!(); + } + + options.push("Enter path manually...".to_string()); + options.push("Manual entry (key/value)".to_string()); + + let method = match Select::new("How would you like to add env vars?", options.clone()).prompt() + { + Ok(m) => m, + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + return Vec::new(); + } + Err(_) => return Vec::new(), + }; + + if method == "Manual entry (key/value)" { + return collect_env_vars_manually(); + } + + if method == "Enter path manually..." { + return collect_env_vars_from_file(project_path, None); + } + + // User picked a discovered file — extract the path portion (before the var count) + let idx = options.iter().position(|o| o == &method).unwrap_or(0); + if idx < discovered.len() { + let rel = &discovered[idx]; + let abs = project_path.join(rel); + collect_env_vars_from_file(project_path, Some(&abs)) + } else { + Vec::new() + } +} + +/// Collect env vars via manual key/value entry +fn collect_env_vars_manually() -> Vec { + let mut secrets = Vec::new(); + + loop { + let key = match Text::new("Variable name:") + .with_help_message("e.g., DATABASE_URL, API_KEY, NODE_ENV") + .prompt() + { + Ok(k) if k.trim().is_empty() => break, + Ok(k) => k.trim().to_uppercase(), + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + break; + } + Err(_) => break, + }; + + let value = match Text::new("Value:") + .with_help_message("The environment variable value") + .prompt() + { + Ok(v) => v, + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + break; + } + Err(_) => break, + }; + + let is_secret = match Confirm::new("Is this a secret?") + .with_default(is_likely_secret(&key)) + .with_help_message("Secrets are masked in UI and API responses") + .prompt() + { + Ok(v) => v, + Err(_) => is_likely_secret(&key), + }; + + println!( + " {} {} {}", + "✓".green(), + key.cyan(), + if is_secret { + "(secret)".dimmed().to_string() + } else { + "".to_string() + } + ); + + secrets.push(DeploymentSecretInput { + key, + value, + is_secret, + }); + + let add_another = match Confirm::new("Add another?") + .with_default(false) + .prompt() + { + Ok(v) => v, + Err(_) => false, + }; + + if !add_another { + break; + } + } + + secrets +} + +/// Collect env vars by loading and parsing a .env file. +/// +/// If `resolved_path` is `Some`, the file is read directly (user picked a discovered file). +/// Otherwise the user is prompted for a path. +fn collect_env_vars_from_file( + project_path: &Path, + resolved_path: Option<&Path>, +) -> Vec { + let (abs_path, display_path) = if let Some(p) = resolved_path { + (p.to_path_buf(), p.display().to_string()) + } else { + let file_path = match Text::new("Path to .env file:") + .with_default(".env") + .with_help_message("Relative or absolute path to your .env file") + .prompt() + { + Ok(p) => p, + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + return Vec::new(); + } + Err(_) => return Vec::new(), + }; + let p = Path::new(&file_path); + let abs = if p.is_absolute() { + p.to_path_buf() + } else { + project_path.join(p) + }; + (abs, file_path) + }; + + let content = match std::fs::read_to_string(&abs_path) { + Ok(c) => c, + Err(e) => { + println!("{} Failed to read file: {}", "✗".red(), e); + return Vec::new(); + } + }; + + let secrets: Vec = content + .lines() + .filter_map(|line| { + let line = line.trim(); + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') { + return None; + } + // Parse KEY=VALUE (handle quoted values) + let (key, value) = line.split_once('=')?; + let key = key.trim().to_string(); + let value = value.trim().to_string(); + // Strip surrounding quotes from value + let value = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .map(|v| v.to_string()) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|v| v.strip_suffix('\'')) + .map(|v| v.to_string()) + }) + .unwrap_or(value); + + if key.is_empty() { + return None; + } + + Some(DeploymentSecretInput { + is_secret: is_likely_secret(&key), + key, + value, + }) + }) + .collect(); + + if secrets.is_empty() { + println!("{} No variables found in file", "⚠".yellow()); + return Vec::new(); + } + + // Show loaded keys (NOT values) for confirmation + println!(); + println!( + " Loaded {} variable(s) from {}:", + secrets.len().to_string().cyan(), + display_path.dimmed() + ); + for s in &secrets { + if s.is_secret { + println!(" {} {} {}", "•".dimmed(), s.key.cyan(), "(secret)".dimmed()); + } else { + println!(" {} {}", "•".dimmed(), s.key.cyan()); + } + } + println!(); + + let secret_count = secrets.iter().filter(|s| s.is_secret).count(); + let plain_count = secrets.len() - secret_count; + if secret_count > 0 { + println!( + " {} {} secret(s), {} plain variable(s)", + "ℹ".blue(), + secret_count.to_string().yellow(), + plain_count.to_string().cyan() + ); + } + + let confirm = match Confirm::new("Use these variables?") + .with_default(true) + .prompt() + { + Ok(v) => v, + Err(_) => false, + }; + + if confirm { + secrets + } else { + Vec::new() + } +} + +/// Check if a key name looks like it should be a secret +fn is_likely_secret(key: &str) -> bool { + let key_upper = key.to_uppercase(); + let secret_patterns = [ + "_KEY", + "_SECRET", + "_TOKEN", + "_PASSWORD", + "_PASSWD", + "_PWD", + "DATABASE_URL", + "REDIS_URL", + "MONGODB_URI", + "CONNECTION_STRING", + "_CREDENTIALS", + "_AUTH", + "_PRIVATE", + "API_KEY", + "APIKEY", + ]; + secret_patterns.iter().any(|p| key_upper.contains(p)) +} + /// Get current git branch name fn get_current_branch() -> Option { std::process::Command::new("git") diff --git a/src/wizard/environment_creation.rs b/src/wizard/environment_creation.rs index 103205f4..42cfedf1 100644 --- a/src/wizard/environment_creation.rs +++ b/src/wizard/environment_creation.rs @@ -4,11 +4,13 @@ //! with target type selection (Kubernetes or Cloud Runner). use crate::platform::api::client::PlatformApiClient; -use crate::platform::api::types::{ClusterSummary, Environment}; +use crate::platform::api::types::{CloudProvider, ClusterSummary, Environment}; +use crate::wizard::cloud_provider_data::{get_default_region, get_regions_for_provider}; use crate::wizard::provider_selection::get_provider_deployment_statuses; use crate::wizard::render::{display_step_header, wizard_render_config}; use colored::Colorize; -use inquire::{InquireError, Select, Text}; +use inquire::{InquireError, MultiSelect, Select, Text}; +use std::collections::HashMap; /// Environment type for the API /// "cluster" = Kubernetes cluster @@ -154,6 +156,13 @@ pub async fn create_environment_wizard( None }; + // Step 4 (Cloud Runner only): Optional provider region defaults + let provider_regions = if env_type == EnvironmentType::Cloud { + select_provider_regions() + } else { + None + }; + // Create the environment via API println!("\n{}", "Creating environment...".dimmed()); @@ -163,6 +172,7 @@ pub async fn create_environment_wizard( &name, env_type.as_str(), cluster_id.as_deref(), + provider_regions.as_ref(), ) .await { @@ -271,6 +281,102 @@ async fn get_available_clusters_for_project( Ok(all_clusters) } +/// Interactive provider region selection for Cloud Runner environments +/// +/// Asks user which providers they want to set default regions for, +/// then presents region list per provider. +fn select_provider_regions() -> Option> { + display_step_header( + 4, + "Provider Regions (Optional)", + "Set default regions for each cloud provider. Press Esc to skip.", + ); + + let available_providers = [ + ("GCP", CloudProvider::Gcp), + ("Hetzner", CloudProvider::Hetzner), + ("Azure", CloudProvider::Azure), + ]; + + let provider_labels: Vec = available_providers + .iter() + .map(|(label, _)| label.to_string()) + .collect(); + + let selected = match MultiSelect::new( + "Select providers to set default regions for:", + provider_labels, + ) + .with_render_config(wizard_render_config()) + .with_help_message("Space to select, Enter to confirm, Esc to skip") + .prompt() + { + Ok(s) if !s.is_empty() => s, + _ => return None, + }; + + let mut regions = HashMap::new(); + + for provider_label in &selected { + let (_, provider) = available_providers + .iter() + .find(|(label, _)| label == provider_label) + .unwrap(); + + let provider_regions = get_regions_for_provider(provider); + let default_region = get_default_region(provider); + + if provider_regions.is_empty() { + // For providers with dynamic regions (Hetzner), use a text input + let region = match Text::new(&format!("{} region:", provider_label)) + .with_default(default_region) + .with_render_config(wizard_render_config()) + .prompt() + { + Ok(r) => r, + Err(_) => continue, + }; + regions.insert(provider.as_str().to_string(), region); + } else { + let region_labels: Vec = provider_regions + .iter() + .map(|r| format!("{} - {} ({})", r.id, r.name, r.location)) + .collect(); + + let default_idx = provider_regions + .iter() + .position(|r| r.id == default_region) + .unwrap_or(0); + + let region = match Select::new( + &format!("{} region:", provider_label), + region_labels, + ) + .with_render_config(wizard_render_config()) + .with_starting_cursor(default_idx) + .prompt() + { + Ok(r) => { + // Extract region ID from the display string (before first " - ") + r.split(" - ").next().unwrap_or(default_region).to_string() + } + Err(_) => continue, + }; + regions.insert(provider.as_str().to_string(), region); + } + } + + if regions.is_empty() { + None + } else { + println!("\n{} Provider regions configured:", "✓".green()); + for (provider, region) in ®ions { + println!(" {}: {}", provider, region.bold()); + } + Some(regions) + } +} + #[cfg(test)] mod tests { use super::*; @@ -288,6 +394,7 @@ mod tests { is_active: true, created_at: None, updated_at: None, + provider_regions: None, }); assert!(matches!(created, EnvironmentCreationResult::Created(_))); diff --git a/src/wizard/environment_selection.rs b/src/wizard/environment_selection.rs index 5d5e5420..609ef050 100644 --- a/src/wizard/environment_selection.rs +++ b/src/wizard/environment_selection.rs @@ -140,6 +140,7 @@ mod tests { is_active: true, created_at: None, updated_at: None, + provider_regions: None, }; let _ = EnvironmentSelectionResult::Selected(env); let _ = EnvironmentSelectionResult::CreateNew; diff --git a/src/wizard/infrastructure_selection.rs b/src/wizard/infrastructure_selection.rs index 0a3f4210..73696493 100644 --- a/src/wizard/infrastructure_selection.rs +++ b/src/wizard/infrastructure_selection.rs @@ -14,6 +14,7 @@ use crate::wizard::cloud_provider_data::{ get_hetzner_regions_dynamic, get_hetzner_server_types_dynamic, get_machine_types_for_provider, get_regions_for_provider, DynamicCloudRegion, DynamicMachineType, HetznerFetchResult, + ACA_RESOURCE_PAIRS, CLOUD_RUN_CPU_MEMORY, }; use crate::wizard::render::{display_step_header, wizard_render_config}; use colored::Colorize; @@ -27,6 +28,8 @@ pub enum InfrastructureSelectionResult { Selected { region: String, machine_type: String, + cpu: Option, + memory: Option, }, /// User wants to go back Back, @@ -95,11 +98,13 @@ pub async fn select_infrastructure( None => return InfrastructureSelectionResult::Back, }; - // Then select machine type + // Then select machine type (returns machine_type, optional cpu, optional memory) match select_machine_type(provider, ®ion, client, project_id).await { - Some(machine_type) => InfrastructureSelectionResult::Selected { + Some((machine_type, cpu, memory)) => InfrastructureSelectionResult::Selected { region, machine_type, + cpu, + memory, }, None => InfrastructureSelectionResult::Back, } @@ -121,6 +126,8 @@ pub fn select_infrastructure_sync( Some(machine_type) => InfrastructureSelectionResult::Selected { region, machine_type, + cpu: None, + memory: None, }, None => InfrastructureSelectionResult::Back, } @@ -302,12 +309,14 @@ fn select_region_static(provider: &CloudProvider, step_number: u8) -> Option, project_id: Option<&str>, -) -> Option { +) -> Option<(String, Option, Option)> { println!(); println!( "{}", @@ -330,7 +339,8 @@ async fn select_machine_type( ); return None; } - return select_machine_type_from_dynamic(machine_types, provider, region); + return select_machine_type_from_dynamic(machine_types, provider, region) + .map(|m| (m, None, None)); } HetznerFetchResult::NoCredentials => { println!( @@ -361,8 +371,125 @@ async fn select_machine_type( } } - // For other providers: Use static data - select_machine_type_static(provider) + // Non-Hetzner providers: Azure ACA and GCP Cloud Run have custom selection UIs + match provider { + CloudProvider::Azure => select_aca_resource_pair() + .map(|(machine, cpu, mem)| (machine, Some(cpu), Some(mem))), + CloudProvider::Gcp => select_cloud_run_resources() + .map(|(machine, cpu, mem)| (machine, Some(cpu), Some(mem))), + _ => select_machine_type_static(provider).map(|m| (m, None, None)), + } +} + +/// Select Azure Container Apps resource pair (CPU + memory combo) +/// +/// Returns (machine_type_id, cpu, memory) e.g. ("0.5-cpu-1.0Gi-mem", "0.5", "1.0Gi") +fn select_aca_resource_pair() -> Option<(String, String, String)> { + let pairs = ACA_RESOURCE_PAIRS; + if pairs.is_empty() { + println!( + "\n{} No Azure Container Apps resource options available.", + "⚠".yellow() + ); + return None; + } + + let labels: Vec = pairs.iter().map(|p| p.label.to_string()).collect(); + // Default to index 1 (0.5 vCPU / 1 GB) + let default_index = 1; + + let selection = Select::new("Select resource allocation:", labels) + .with_render_config(wizard_render_config()) + .with_starting_cursor(default_index) + .with_help_message("Azure Container Apps fixed CPU/memory pairs") + .prompt(); + + match selection { + Ok(selected_label) => { + let pair = pairs.iter().find(|p| p.label == selected_label)?; + let machine_type_id = format!("{}-cpu-{}-mem", pair.cpu, pair.memory); + println!( + "\n{} Selected: {} vCPU / {}", + "✓".green(), + pair.cpu.cyan(), + pair.memory.cyan() + ); + Some((machine_type_id, pair.cpu.to_string(), pair.memory.to_string())) + } + Err(InquireError::OperationCanceled) => None, + Err(InquireError::OperationInterrupted) => None, + Err(_) => None, + } +} + +/// Select GCP Cloud Run resources (two-step: CPU then memory) +/// +/// Returns (machine_type_id, cpu, memory) e.g. ("2-cpu-2Gi-mem", "2", "2Gi") +fn select_cloud_run_resources() -> Option<(String, String, String)> { + let cpu_levels = CLOUD_RUN_CPU_MEMORY; + if cpu_levels.is_empty() { + println!( + "\n{} No Cloud Run CPU options available.", + "⚠".yellow() + ); + return None; + } + + // Step 1: Select CPU + let cpu_labels: Vec = cpu_levels + .iter() + .map(|c| format!("{} vCPU", c.cpu)) + .collect(); + + let cpu_selection = Select::new("Select CPU allocation:", cpu_labels) + .with_render_config(wizard_render_config()) + .with_starting_cursor(0) // Default to 1 vCPU + .with_help_message("Cloud Run CPU allocation") + .prompt(); + + let selected_cpu = match cpu_selection { + Ok(label) => { + let cpu_str = label.replace(" vCPU", ""); + cpu_levels.iter().find(|c| c.cpu == cpu_str)? + } + Err(InquireError::OperationCanceled) => return None, + Err(InquireError::OperationInterrupted) => return None, + Err(_) => return None, + }; + + // Step 2: Select memory for that CPU level + let memory_options: Vec = selected_cpu + .memory_options + .iter() + .map(|m| m.to_string()) + .collect(); + + let default_mem_index = memory_options + .iter() + .position(|m| m == selected_cpu.default_memory) + .unwrap_or(0); + + let mem_selection = Select::new("Select memory allocation:", memory_options) + .with_render_config(wizard_render_config()) + .with_starting_cursor(default_mem_index) + .with_help_message("Memory must be compatible with selected CPU") + .prompt(); + + match mem_selection { + Ok(selected_memory) => { + let machine_type_id = format!("{}-cpu-{}-mem", selected_cpu.cpu, selected_memory); + println!( + "\n{} Selected: {} vCPU / {}", + "✓".green(), + selected_cpu.cpu.cyan(), + selected_memory.cyan() + ); + Some((machine_type_id, selected_cpu.cpu.to_string(), selected_memory)) + } + Err(InquireError::OperationCanceled) => None, + Err(InquireError::OperationInterrupted) => None, + Err(_) => None, + } } /// Select machine type from dynamic data with pricing info @@ -554,9 +681,19 @@ mod tests { let selected = InfrastructureSelectionResult::Selected { region: "nbg1".to_string(), machine_type: "cx22".to_string(), + cpu: None, + memory: None, }; matches!(selected, InfrastructureSelectionResult::Selected { .. }); + let selected_with_resources = InfrastructureSelectionResult::Selected { + region: "eastus".to_string(), + machine_type: "0.5-cpu-1.0Gi-mem".to_string(), + cpu: Some("0.5".to_string()), + memory: Some("1.0Gi".to_string()), + }; + matches!(selected_with_resources, InfrastructureSelectionResult::Selected { .. }); + let _ = InfrastructureSelectionResult::Back; let _ = InfrastructureSelectionResult::Cancelled; } diff --git a/src/wizard/mod.rs b/src/wizard/mod.rs index 25d54e24..7942fb0a 100644 --- a/src/wizard/mod.rs +++ b/src/wizard/mod.rs @@ -16,6 +16,7 @@ mod registry_provisioning; mod registry_selection; mod render; mod repository_selection; +mod service_endpoints; mod target_selection; pub use cloud_provider_data::{ @@ -28,7 +29,10 @@ pub use cloud_provider_data::{ DynamicCloudRegion, DynamicMachineType, HetznerFetchResult, }; pub use cluster_selection::{select_cluster, ClusterSelectionResult}; -pub use config_form::{collect_config, ConfigFormResult}; +pub use config_form::{ + collect_config, collect_env_vars, discover_env_files, parse_env_file, ConfigFormResult, + EnvFileEntry, +}; pub use dockerfile_selection::{select_dockerfile, DockerfileSelectionResult}; pub use environment_creation::{create_environment_wizard, EnvironmentCreationResult}; pub use environment_selection::{select_environment, EnvironmentSelectionResult}; @@ -47,4 +51,8 @@ pub use recommendations::{ RecommendationAlternatives, RecommendationInput, RegionOption, }; pub use render::{count_badge, display_step_header, status_indicator, wizard_render_config}; +pub use service_endpoints::{ + collect_service_endpoint_env_vars, filter_endpoints_for_provider, get_available_endpoints, + match_env_vars_to_services, AvailableServiceEndpoint, EndpointSuggestion, MatchConfidence, +}; pub use target_selection::{select_target, TargetSelectionResult}; diff --git a/src/wizard/orchestrator.rs b/src/wizard/orchestrator.rs index ec5172a7..9d97ef16 100644 --- a/src/wizard/orchestrator.rs +++ b/src/wizard/orchestrator.rs @@ -2,16 +2,20 @@ use crate::analyzer::discover_dockerfiles_for_deployment; use crate::platform::api::types::{ - build_cloud_runner_config, ConnectRepositoryRequest, CreateDeploymentConfigRequest, - DeploymentTarget, ProjectRepository, TriggerDeploymentRequest, WizardDeploymentConfig, + build_cloud_runner_config_v2, CloudProvider, CloudRunnerConfigInput, + ConnectRepositoryRequest, CreateDeploymentConfigRequest, DeploymentTarget, + ProjectRepository, TriggerDeploymentRequest, WizardDeploymentConfig, }; use crate::platform::api::PlatformApiClient; use crate::wizard::{ - collect_config, get_provider_deployment_statuses, provision_registry, select_cluster, - select_dockerfile, select_infrastructure, select_provider, select_registry, select_repository, - select_target, ClusterSelectionResult, ConfigFormResult, DockerfileSelectionResult, - InfrastructureSelectionResult, ProviderSelectionResult, RegistryProvisioningResult, - RegistrySelectionResult, RepositorySelectionResult, TargetSelectionResult, + collect_config, collect_env_vars, collect_service_endpoint_env_vars, + filter_endpoints_for_provider, get_available_endpoints, + get_provider_deployment_statuses, provision_registry, + select_cluster, select_dockerfile, select_infrastructure, select_provider, select_registry, + select_repository, select_target, ClusterSelectionResult, ConfigFormResult, + DockerfileSelectionResult, InfrastructureSelectionResult, ProviderSelectionResult, + RegistryProvisioningResult, RegistrySelectionResult, RepositorySelectionResult, + TargetSelectionResult, }; use colored::Colorize; use inquire::{Confirm, InquireError}; @@ -176,14 +180,16 @@ pub async fn run_wizard( }; // Step 3: Infrastructure selection for Cloud Runner OR Cluster selection for K8s - let (cluster_id, region, machine_type) = if target == DeploymentTarget::CloudRunner { + let (cluster_id, region, machine_type, cpu, memory) = if target == DeploymentTarget::CloudRunner { // Cloud Runner: Select region and machine type // Pass client and project_id for dynamic Hetzner availability fetching match select_infrastructure(&provider, 3, Some(client), Some(project_id)).await { InfrastructureSelectionResult::Selected { region, machine_type, - } => (None, Some(region), Some(machine_type)), + cpu, + memory, + } => (None, Some(region), Some(machine_type), cpu, memory), InfrastructureSelectionResult::Back => { // Go back (restart wizard for simplicity) return Box::pin(run_wizard(client, project_id, environment_id, project_path)).await; @@ -193,7 +199,7 @@ pub async fn run_wizard( } else { // Kubernetes: Select cluster match select_cluster(&provider_status.clusters) { - ClusterSelectionResult::Selected(c) => (Some(c.id), None, None), + ClusterSelectionResult::Selected(c) => (Some(c.id), None, None, None, None), ClusterSelectionResult::Back => { // Go back to target selection (restart wizard for simplicity) return Box::pin(run_wizard(client, project_id, environment_id, project_path)) @@ -317,6 +323,8 @@ pub async fn run_wizard( &selected_dockerfile, region.clone(), machine_type.clone(), + cpu.clone(), + memory.clone(), 6, ) { ConfigFormResult::Completed(config) => config, @@ -327,6 +335,46 @@ pub async fn run_wizard( ConfigFormResult::Cancelled => return WizardResult::Cancelled, }; + // Step 6.5: Environment variables (optional) + let secrets = collect_env_vars(project_path); + let mut config = config; + config.secrets = secrets; + + // Step 6.6: Offer deployed service endpoints as env vars + let available_endpoints = match client.list_deployments(project_id, Some(50)).await { + Ok(paginated) => { + log::debug!( + "Fetched {} deployment record(s) for endpoint discovery", + paginated.data.len() + ); + get_available_endpoints(&paginated.data) + } + Err(e) => { + log::debug!("Could not fetch deployments for endpoint injection: {}", e); + Vec::new() + } + }; + + // Exclude the service being deployed + let service_being_deployed = config.service_name.as_deref().unwrap_or(""); + let available_endpoints: Vec<_> = available_endpoints + .into_iter() + .filter(|ep| ep.service_name != service_being_deployed) + .collect(); + // Only show private endpoints from the same cloud provider + let available_endpoints = + filter_endpoints_for_provider(available_endpoints, provider.as_str()); + + if !available_endpoints.is_empty() { + let endpoint_vars = collect_service_endpoint_env_vars(&available_endpoints); + for ep_var in endpoint_vars { + // Don't override vars already set by .env or manual entry + if !config.secrets.iter().any(|s| s.key == ep_var.key) { + config.secrets.push(ep_var); + } + } + } + // Show summary display_summary(&config); @@ -372,14 +420,45 @@ pub async fn run_wizard( registry_id: registry_id.clone(), auto_deploy_enabled: config.auto_deploy, is_public: Some(config.is_public), + secrets: if config.secrets.is_empty() { + None + } else { + Some(config.secrets.clone()) + }, cloud_runner_config: if target == DeploymentTarget::CloudRunner { - Some(build_cloud_runner_config( - &provider, - region.as_deref().unwrap_or(""), - machine_type.as_deref().unwrap_or(""), - config.is_public, - config.health_check_path.as_deref(), - )) + // Fetch provider credential for GCP project ID / Azure subscription ID + let (gcp_project_id, subscription_id) = match provider { + CloudProvider::Gcp | CloudProvider::Azure => { + match client.check_provider_connection(&provider, project_id).await { + Ok(Some(cred)) => match provider { + CloudProvider::Gcp => (cred.provider_account_id, None), + CloudProvider::Azure => (None, cred.provider_account_id), + _ => (None, None), + }, + _ => (None, None), + } + } + _ => (None, None), + }; + + let config_input = CloudRunnerConfigInput { + provider: Some(provider.clone()), + region: region.clone(), + server_type: if provider == CloudProvider::Hetzner { + machine_type.clone() + } else { + None + }, + gcp_project_id, + cpu: config.cpu.clone(), + memory: config.memory.clone(), + allow_unauthenticated: Some(config.is_public), + subscription_id, + is_public: Some(config.is_public), + health_check_path: config.health_check_path.clone(), + ..Default::default() + }; + Some(build_cloud_runner_config_v2(&config_input)) } else { None }, @@ -511,7 +590,11 @@ fn display_summary(config: &WizardDeploymentConfig) { if let Some(ref region) = config.region { println!(" Region: {}", region.cyan()); } - if let Some(ref machine) = config.machine_type { + if let Some(ref cpu) = config.cpu { + if let Some(ref mem) = config.memory { + println!(" Resources: {} vCPU / {}", cpu.cyan(), mem.cyan()); + } + } else if let Some(ref machine) = config.machine_type { println!(" Machine: {}", machine.cyan()); } if let Some(ref branch) = config.branch { @@ -539,6 +622,15 @@ fn display_summary(config: &WizardDeploymentConfig) { "No".yellow() } ); + if !config.secrets.is_empty() { + let secret_count = config.secrets.iter().filter(|s| s.is_secret).count(); + let env_count = config.secrets.len() - secret_count; + println!( + " Env vars: {} env, {} secret", + env_count.to_string().cyan(), + secret_count.to_string().yellow() + ); + } println!( "{}", diff --git a/src/wizard/provider_selection.rs b/src/wizard/provider_selection.rs index 5e2cbbce..0c1bad49 100644 --- a/src/wizard/provider_selection.rs +++ b/src/wizard/provider_selection.rs @@ -91,9 +91,9 @@ pub async fn get_provider_deployment_statuses( // Provider is connected if it has cloud credentials (NOT just resources) let is_connected = connected_providers.contains(provider.as_str()); - // Cloud Runner available for GCP and Hetzner when connected + // Cloud Runner available for GCP, Hetzner, and Azure when connected let cloud_runner_available = - is_connected && matches!(provider, CloudProvider::Gcp | CloudProvider::Hetzner); + is_connected && matches!(provider, CloudProvider::Gcp | CloudProvider::Hetzner | CloudProvider::Azure); let summary = build_status_summary(&clusters, ®istries, cloud_runner_available); @@ -202,7 +202,7 @@ pub fn select_provider(statuses: &[ProviderDeploymentStatus]) -> ProviderSelecti ); println!( " {}", - "Note: GCP and Hetzner are currently available. AWS, Azure, Scaleway, and Cyso Cloud are coming soon.".dimmed() + "Note: GCP, Hetzner, and Azure are currently available. AWS, Scaleway, and Cyso Cloud are coming soon.".dimmed() ); return ProviderSelectionResult::Cancelled; } @@ -231,7 +231,7 @@ pub fn select_provider(statuses: &[ProviderDeploymentStatus]) -> ProviderSelecti println!( "\n{}", format!( - "{} is coming soon! Currently only GCP and Hetzner are available.", + "{} is coming soon! Currently only GCP, Hetzner, and Azure are available.", selected_status.provider.display_name() ) .yellow() diff --git a/src/wizard/recommendations.rs b/src/wizard/recommendations.rs index 152cbb70..e44422b5 100644 --- a/src/wizard/recommendations.rs +++ b/src/wizard/recommendations.rs @@ -24,11 +24,16 @@ pub struct DeploymentRecommendation { /// Why this target was recommended pub target_reasoning: String, - /// Recommended machine type (provider-specific) + /// Recommended machine type (provider-specific, used for Hetzner) pub machine_type: String, /// Why this machine type was recommended pub machine_reasoning: String, + /// Recommended CPU allocation (for GCP Cloud Run / Azure ACA) + pub cpu: Option, + /// Recommended memory allocation (for GCP Cloud Run / Azure ACA) + pub memory: Option, + /// Recommended region pub region: String, /// Why this region was recommended @@ -99,7 +104,7 @@ pub fn recommend_deployment(input: RecommendationInput) -> DeploymentRecommendat let (target, target_reasoning) = select_target(&input); // 3. Select machine type based on detected framework - let (machine_type, machine_reasoning) = select_machine_type(&input.analysis, &provider); + let machine_result = select_machine_type(&input.analysis, &provider); // 4. Select region let (region, region_reasoning) = select_region(&provider, input.user_region_hint.as_deref()); @@ -121,8 +126,10 @@ pub fn recommend_deployment(input: RecommendationInput) -> DeploymentRecommendat provider_reasoning, target, target_reasoning, - machine_type, - machine_reasoning, + machine_type: machine_result.machine_type, + machine_reasoning: machine_result.reasoning, + cpu: machine_result.cpu, + memory: machine_result.memory, region, region_reasoning, port, @@ -152,22 +159,59 @@ fn select_provider(input: &RecommendationInput) -> (CloudProvider, String) { // Check which providers are available let has_hetzner = input.available_providers.contains(&CloudProvider::Hetzner); let has_gcp = input.available_providers.contains(&CloudProvider::Gcp); + let has_azure = input.available_providers.contains(&CloudProvider::Azure); + + // Build list of connected provider names for reasoning + let connected: Vec<&str> = input + .available_providers + .iter() + .filter(|p| p.is_available()) + .map(|p| p.display_name()) + .collect(); + let also_available = if connected.len() > 1 { + format!( + ". Also connected: {}", + connected + .iter() + .copied() + .collect::>() + .join(", ") + ) + } else { + String::new() + }; if has_hetzner && has_gcp { - // Both available - prefer Hetzner for cost-effectiveness ( CloudProvider::Hetzner, - "Hetzner recommended: Cost-effective for web services, European data centers".to_string(), + format!( + "Hetzner recommended: Cost-effective for web services, European data centers{}", + also_available + ), ) } else if has_hetzner { ( CloudProvider::Hetzner, - "Hetzner selected: Only available connected provider".to_string(), + format!( + "Hetzner recommended: Cost-effective dedicated servers with predictable pricing{}", + also_available + ), ) } else if has_gcp { ( CloudProvider::Gcp, - "GCP selected: Only available connected provider".to_string(), + format!( + "GCP recommended: Scalable serverless options with Cloud Run{}", + also_available + ), + ) + } else if has_azure { + ( + CloudProvider::Azure, + format!( + "Azure recommended: Container Apps with auto-scaling and scale-to-zero{}", + also_available + ), ) } else { // Fallback - shouldn't happen in practice @@ -197,14 +241,22 @@ fn select_target(input: &RecommendationInput) -> (DeploymentTarget, String) { ) } +/// Machine type selection result with optional CPU/memory for Cloud Run / ACA +struct MachineTypeResult { + machine_type: String, + reasoning: String, + cpu: Option, + memory: Option, +} + /// Select machine type based on detected framework characteristics -fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> (String, String) { +fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> MachineTypeResult { // Detect framework type to determine resource needs let framework_info = get_framework_resource_hint(analysis); - let (machine_type, reasoning) = match provider { + match provider { CloudProvider::Hetzner => { - match framework_info.memory_requirement { + let (machine_type, reasoning) = match framework_info.memory_requirement { MemoryRequirement::Low => ( "cx23".to_string(), format!("cx23 (2 vCPU, 4GB) recommended: {} services are memory-efficient", framework_info.name), @@ -217,34 +269,65 @@ fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> "cx43".to_string(), format!("cx43 (8 vCPU, 16GB) recommended: {} requires significant memory (JVM, ML, etc.)", framework_info.name), ), - } + }; + MachineTypeResult { machine_type, reasoning, cpu: None, memory: None } } CloudProvider::Gcp => { - match framework_info.memory_requirement { + // Use Cloud Run CPU/memory instead of Compute Engine machine types + let (cpu, mem, reasoning) = match framework_info.memory_requirement { MemoryRequirement::Low => ( - "e2-small".to_string(), - format!("e2-small (0.5 vCPU, 2GB) recommended: {} services are lightweight", framework_info.name), + "1", "512Mi", + format!("Cloud Run 1 vCPU / 512Mi recommended: {} services are lightweight", framework_info.name), ), MemoryRequirement::Medium => ( - "e2-medium".to_string(), - format!("e2-medium (1 vCPU, 4GB) recommended: {} may need moderate resources", framework_info.name), + "2", "2Gi", + format!("Cloud Run 2 vCPU / 2Gi recommended: {} may need moderate resources", framework_info.name), ), MemoryRequirement::High => ( - "e2-standard-2".to_string(), - format!("e2-standard-2 (2 vCPU, 8GB) recommended: {} requires significant memory", framework_info.name), + "4", "8Gi", + format!("Cloud Run 4 vCPU / 8Gi recommended: {} requires significant memory", framework_info.name), ), + }; + MachineTypeResult { + machine_type: format!("{}-cpu-{}mem", cpu, mem), + reasoning, + cpu: Some(cpu.to_string()), + memory: Some(mem.to_string()), + } + } + CloudProvider::Azure => { + // Use Azure Container Apps resource pairs + let (cpu, mem, reasoning) = match framework_info.memory_requirement { + MemoryRequirement::Low => ( + "0.5", "1.0Gi", + format!("ACA 0.5 vCPU / 1 GB recommended: {} services are lightweight", framework_info.name), + ), + MemoryRequirement::Medium => ( + "1.0", "2.0Gi", + format!("ACA 1 vCPU / 2 GB recommended: {} may need moderate resources", framework_info.name), + ), + MemoryRequirement::High => ( + "2.0", "4.0Gi", + format!("ACA 2 vCPU / 4 GB recommended: {} requires significant memory", framework_info.name), + ), + }; + MachineTypeResult { + machine_type: format!("{}-cpu-{}mem", cpu, mem), + reasoning, + cpu: Some(cpu.to_string()), + memory: Some(mem.to_string()), } } _ => { // Fallback for unsupported providers - ( - get_default_machine_type(provider).to_string(), - "Default machine type selected".to_string(), - ) + MachineTypeResult { + machine_type: get_default_machine_type(provider).to_string(), + reasoning: "Default machine type selected".to_string(), + cpu: None, + memory: None, + } } - }; - - (machine_type, reasoning) + } } /// Memory requirement categories @@ -352,9 +435,10 @@ fn get_framework_resource_hint(analysis: &ProjectAnalysis) -> FrameworkResourceH /// Select region based on user hint or defaults fn select_region(provider: &CloudProvider, user_hint: Option<&str>) -> (String, String) { if let Some(hint) = user_hint { - // Validate hint is a valid region for this provider let regions = get_regions_for_provider(provider); - if regions.iter().any(|r| r.id == hint) { + // For providers with dynamic regions (empty static list), accept the hint as-is. + // For providers with static region lists, validate against the list. + if regions.is_empty() || regions.iter().any(|r| r.id == hint) { return ( hint.to_string(), format!("{} selected: User preference", hint), @@ -366,6 +450,7 @@ fn select_region(provider: &CloudProvider, user_hint: Option<&str>) -> (String, let reasoning = match provider { CloudProvider::Hetzner => format!("{} (Nuremberg) selected: Default EU region, low latency for European users", default_region), CloudProvider::Gcp => format!("{} (Iowa) selected: Default US region, good general-purpose choice", default_region), + CloudProvider::Azure => format!("{} (Virginia) selected: Default US region, broad service availability", default_region), _ => format!("{} selected: Default region for provider", default_region), }; @@ -580,7 +665,7 @@ mod tests { let rec = recommend_deployment(input); // Express should get a small machine - assert!(rec.machine_type == "cx23" || rec.machine_type == "e2-small"); + assert!(rec.machine_type == "cx23" || rec.machine_type.contains("1-cpu") || rec.machine_type == "e2-small"); assert_eq!(rec.port, 3000); assert!(rec.machine_reasoning.contains("Express")); } @@ -712,9 +797,11 @@ mod tests { fn test_alternatives_populated() { let analysis = create_minimal_analysis(); + // Use GCP-only so static machine types and regions are populated + // (Hetzner uses dynamic types/regions via API, so its alternatives are empty) let input = RecommendationInput { analysis, - available_providers: vec![CloudProvider::Hetzner, CloudProvider::Gcp], + available_providers: vec![CloudProvider::Gcp], has_existing_k8s: false, user_region_hint: None, }; diff --git a/src/wizard/service_endpoints.rs b/src/wizard/service_endpoints.rs new file mode 100644 index 00000000..deeff827 --- /dev/null +++ b/src/wizard/service_endpoints.rs @@ -0,0 +1,821 @@ +//! Service endpoint discovery and env var matching for inter-service linking +//! +//! When deploying service A that calls service B, this module discovers +//! already-deployed services, shows their public URLs, and offers to inject +//! them as environment variables. + +use crate::platform::api::types::{DeployedService, DeploymentSecretInput}; +use crate::wizard::render::wizard_render_config; +use colored::Colorize; +use inquire::{Confirm, InquireError, MultiSelect, Text}; + +/// A deployed service with a reachable URL (public or private network). +#[derive(Debug, Clone)] +pub struct AvailableServiceEndpoint { + pub service_name: String, + /// The URL to use for connecting — either public URL or private IP. + pub url: String, + /// Whether this endpoint is a private network address (no public URL). + pub is_private: bool, + /// Cloud provider this service runs on (e.g. "hetzner", "gcp", "azure"). + pub cloud_provider: Option, + pub status: String, +} + +/// Confidence level for an env-var-to-service match. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum MatchConfidence { + Low, + Medium, + High, +} + +/// A suggested mapping: env var -> deployed service URL. +#[derive(Debug, Clone)] +pub struct EndpointSuggestion { + pub env_var_name: String, + pub service: AvailableServiceEndpoint, + pub confidence: MatchConfidence, + pub reason: String, +} + +// --------------------------------------------------------------------------- +// Suffixes that indicate a URL-like env var +// --------------------------------------------------------------------------- + +const URL_SUFFIXES: &[&str] = &[ + "_URL", + "_SERVICE_URL", + "_ENDPOINT", + "_HOST", + "_BASE", + "_BASE_URL", + "_API_URL", + "_URI", +]; + +// --------------------------------------------------------------------------- +// Core functions +// --------------------------------------------------------------------------- + +/// Filter deployments down to services that have a reachable URL (public or +/// private) and are not in a known-bad state. +/// +/// The `list_deployments` API may return multiple records per service (one per +/// deploy attempt). We deduplicate by `service_name`, keeping the most recent +/// record (the API returns most-recent-first). +/// +/// A service is included if it has a `public_url` OR a `private_ip` (for +/// internal services deployed on a private network without public access). +pub fn get_available_endpoints(deployments: &[DeployedService]) -> Vec { + const EXCLUDED_STATUSES: &[&str] = &[ + "failed", + "cancelled", + "canceled", + "pending", + "processing", + "building", + "deploying", + "generating", + "deleting", + "deleted", + ]; + + let mut seen_services = std::collections::HashSet::new(); + + deployments + .iter() + .filter_map(|d| { + // Deduplicate: keep only the first (most recent) record per service + if !seen_services.insert(d.service_name.clone()) { + return None; + } + + let status_lower = d.status.to_lowercase(); + if EXCLUDED_STATUSES.iter().any(|&s| status_lower == s) { + log::debug!( + "Skipping service '{}' (status: {}): excluded status", + d.service_name, + d.status + ); + return None; + } + + // Prefer public URL; fall back to private IP + let public_url = d.public_url.as_deref().unwrap_or("").trim(); + let private_ip = d.private_ip.as_deref().unwrap_or("").trim(); + + if !public_url.is_empty() { + log::debug!( + "Available endpoint: '{}' -> {} (public, status: {})", + d.service_name, + public_url, + d.status + ); + Some(AvailableServiceEndpoint { + service_name: d.service_name.clone(), + url: public_url.to_string(), + is_private: false, + cloud_provider: d.cloud_provider.clone(), + status: d.status.clone(), + }) + } else if !private_ip.is_empty() { + // Build a usable URL from the private IP. + // Services on Hetzner private networks are reachable by IP from + // other services on the same network. + let url = format!("http://{}", private_ip); + log::debug!( + "Available endpoint: '{}' -> {} (private, status: {})", + d.service_name, + url, + d.status + ); + Some(AvailableServiceEndpoint { + service_name: d.service_name.clone(), + url, + is_private: true, + cloud_provider: d.cloud_provider.clone(), + status: d.status.clone(), + }) + } else { + log::debug!( + "Skipping service '{}' (status: {}): no public_url or private_ip", + d.service_name, + d.status + ); + None + } + }) + .collect() +} + +/// Filter endpoints so that private-network endpoints only appear when they +/// share the same cloud provider as the service being deployed. +/// +/// Public endpoints are always kept regardless of provider — they're reachable +/// from anywhere. Private endpoints are only reachable within the same provider +/// network. +pub fn filter_endpoints_for_provider( + endpoints: Vec, + target_provider: &str, +) -> Vec { + let target = target_provider.to_lowercase(); + endpoints + .into_iter() + .filter(|ep| { + if !ep.is_private { + // Public URLs are reachable from any provider + return true; + } + // Private IPs are only useful on the same provider network + ep.cloud_provider + .as_ref() + .map(|p| p.to_lowercase() == target) + .unwrap_or(false) + }) + .collect() +} + +/// Check whether an env var name looks like it holds a URL. +pub fn is_url_env_var(name: &str) -> bool { + let upper = name.to_uppercase(); + URL_SUFFIXES.iter().any(|suffix| upper.ends_with(suffix)) +} + +/// Strip the URL-like suffix from an env var name to extract a service hint. +/// +/// `SENTIMENT_SERVICE_URL` -> `"sentiment"` +/// `API_BASE` -> `"api"` +/// `NODE_ENV` -> `None` +pub fn extract_service_hint(env_var_name: &str) -> Option { + let upper = env_var_name.to_uppercase(); + + // Try suffixes longest-first so _SERVICE_URL is tried before _URL + let mut suffixes: Vec<&&str> = URL_SUFFIXES.iter().collect(); + suffixes.sort_by(|a, b| b.len().cmp(&a.len())); + + for suffix in suffixes { + if upper.ends_with(suffix) { + let prefix = &upper[..upper.len() - suffix.len()]; + if prefix.is_empty() { + return None; + } + return Some(prefix.to_lowercase()); + } + } + None +} + +/// Normalize a name for matching: lowercase, strip `-` and `_`. +fn normalize(s: &str) -> String { + s.to_lowercase().replace(['-', '_'], "") +} + +/// Split a name into tokens on `_` and `-`. +fn tokenize(s: &str) -> Vec { + s.to_lowercase() + .split(|c: char| c == '_' || c == '-') + .filter(|t| !t.is_empty()) + .map(String::from) + .collect() +} + +/// Match a service hint against a service name. +/// +/// Returns `None` if there is no meaningful overlap. +pub fn match_hint_to_service(hint: &str, service_name: &str) -> Option { + let nh = normalize(hint); + let ns = normalize(service_name); + + if nh.is_empty() || ns.is_empty() { + return None; + } + + // Exact match or hint is prefix of service (normalized) + if nh == ns || ns.starts_with(&nh) { + return Some(MatchConfidence::High); + } + + // One contains the other (normalized, no separators) + if ns.contains(&nh) || nh.contains(&ns) { + return Some(MatchConfidence::Medium); + } + + // Check if either normalized form is a prefix of the other + // (catches "contacts" ~ "contactintelligence" via shared stem) + if nh.starts_with(&ns) || ns.starts_with(&nh) { + return Some(MatchConfidence::Medium); + } + + // Token overlap: exact or prefix match between tokens + let hint_tokens = tokenize(hint); + let svc_tokens = tokenize(service_name); + let overlap = hint_tokens + .iter() + .filter(|ht| { + svc_tokens.iter().any(|st| { + st == *ht || st.starts_with(ht.as_str()) || ht.starts_with(st.as_str()) + }) + }) + .count(); + + if overlap == 0 { + return None; + } + + let max_tokens = hint_tokens.len().max(svc_tokens.len()); + if overlap * 2 >= max_tokens { + Some(MatchConfidence::Medium) + } else { + Some(MatchConfidence::Low) + } +} + +/// For each URL-like env var, find the best matching deployed service. +/// +/// Returns suggestions sorted by confidence (highest first). +pub fn match_env_vars_to_services( + env_var_names: &[String], + endpoints: &[AvailableServiceEndpoint], +) -> Vec { + let mut suggestions = Vec::new(); + + for var_name in env_var_names { + if !is_url_env_var(var_name) { + continue; + } + let hint = match extract_service_hint(var_name) { + Some(h) => h, + None => continue, + }; + + // Find best match + let mut best: Option<(MatchConfidence, &AvailableServiceEndpoint)> = None; + for ep in endpoints { + if let Some(conf) = match_hint_to_service(&hint, &ep.service_name) { + if best.as_ref().map_or(true, |(bc, _)| conf > *bc) { + best = Some((conf, ep)); + } + } + } + + if let Some((confidence, ep)) = best { + suggestions.push(EndpointSuggestion { + env_var_name: var_name.clone(), + service: ep.clone(), + confidence, + reason: format!( + "Env var '{}' (hint '{}') matches service '{}' ({:?})", + var_name, hint, ep.service_name, confidence + ), + }); + } + } + + suggestions.sort_by(|a, b| b.confidence.cmp(&a.confidence)); + suggestions +} + +/// Generate a default env var name for a service. +/// +/// `"sentiment-analysis"` -> `"SENTIMENT_ANALYSIS_URL"` +pub fn suggest_env_var_name(service_name: &str) -> String { + let base = service_name + .to_uppercase() + .replace('-', "_"); + format!("{}_URL", base) +} + +// --------------------------------------------------------------------------- +// Wizard UI +// --------------------------------------------------------------------------- + +/// Interactive prompt to link deployed service URLs as env vars. +/// +/// Shows available endpoints, lets the user select which to link, and +/// prompts for each env var name. Returns `DeploymentSecretInput` entries +/// with `is_secret: false` (URLs are not secrets). +pub fn collect_service_endpoint_env_vars( + endpoints: &[AvailableServiceEndpoint], +) -> Vec { + if endpoints.is_empty() { + return Vec::new(); + } + + println!(); + println!( + "{}", + "─── Deployed Service Endpoints ────────────────────".dimmed() + ); + println!( + " Found {} running service(s) with reachable URLs:", + endpoints.len().to_string().cyan() + ); + for ep in endpoints { + let access_label = if ep.is_private { " (private network)" } else { "" }; + println!( + " {} {:<30} {}{}", + "●".green(), + ep.service_name.cyan(), + ep.url.dimmed(), + access_label.yellow() + ); + } + println!(); + + // Ask if user wants to link any + let wants_link = match Confirm::new("Link any deployed service URLs as env vars?") + .with_default(true) + .with_help_message("Inject deployed service URLs as environment variables") + .prompt() + { + Ok(v) => v, + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + return Vec::new(); + } + Err(_) => return Vec::new(), + }; + + if !wants_link { + return Vec::new(); + } + + // Build labels for multi-select + let labels: Vec = endpoints + .iter() + .map(|ep| { + let suffix = if ep.is_private { " [private]" } else { "" }; + format!("{} ({}){}", ep.service_name, ep.url, suffix) + }) + .collect(); + + let selected = match MultiSelect::new("Select services to link:", labels.clone()) + .with_render_config(wizard_render_config()) + .with_help_message("Space to toggle, Enter to confirm") + .prompt() + { + Ok(s) if !s.is_empty() => s, + Ok(_) => return Vec::new(), + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + return Vec::new(); + } + Err(_) => return Vec::new(), + }; + + // Map selected labels back to endpoints + let mut result = Vec::new(); + for sel_label in &selected { + let idx = match labels.iter().position(|l| l == sel_label) { + Some(i) => i, + None => continue, + }; + let ep = &endpoints[idx]; + let default_name = suggest_env_var_name(&ep.service_name); + + let var_name = match Text::new(&format!("Env var name for '{}':", ep.service_name)) + .with_default(&default_name) + .with_help_message("Environment variable name to hold this service URL") + .prompt() + { + Ok(name) => name.trim().to_uppercase(), + Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => { + break; + } + Err(_) => break, + }; + + if var_name.is_empty() { + continue; + } + + let private_note = if ep.is_private { " (private network)" } else { "" }; + println!( + " {} {} = {}{}", + "✓".green(), + var_name.cyan(), + ep.url.dimmed(), + private_note.yellow() + ); + + result.push(DeploymentSecretInput { + key: var_name, + value: ep.url.clone(), + is_secret: false, + }); + } + + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_service_hint() { + assert_eq!( + extract_service_hint("SENTIMENT_SERVICE_URL"), + Some("sentiment".to_string()) + ); + assert_eq!( + extract_service_hint("API_BASE"), + Some("api".to_string()) + ); + assert_eq!(extract_service_hint("NODE_ENV"), None); + assert_eq!( + extract_service_hint("CONTACTS_API_URL"), + Some("contacts".to_string()) + ); + assert_eq!( + extract_service_hint("BACKEND_ENDPOINT"), + Some("backend".to_string()) + ); + } + + #[test] + fn test_match_hint_exact() { + assert_eq!( + match_hint_to_service("sentiment", "sentiment"), + Some(MatchConfidence::High) + ); + } + + #[test] + fn test_match_hint_prefix() { + assert_eq!( + match_hint_to_service("sentiment", "sentiment-analysis"), + Some(MatchConfidence::High) + ); + } + + #[test] + fn test_match_hint_containment() { + assert_eq!( + match_hint_to_service("contacts", "contact-intelligence"), + Some(MatchConfidence::Medium) + ); + } + + #[test] + fn test_no_match() { + assert_eq!( + match_hint_to_service("database", "sentiment-analysis"), + None + ); + } + + #[test] + fn test_is_url_env_var() { + assert!(is_url_env_var("DATABASE_URL")); + assert!(is_url_env_var("BACKEND_SERVICE_URL")); + assert!(is_url_env_var("API_ENDPOINT")); + assert!(is_url_env_var("SERVICE_HOST")); + assert!(is_url_env_var("API_BASE")); + assert!(is_url_env_var("APP_BASE_URL")); + assert!(is_url_env_var("BACKEND_API_URL")); + assert!(is_url_env_var("SERVICE_URI")); + assert!(!is_url_env_var("NODE_ENV")); + assert!(!is_url_env_var("PORT")); + assert!(!is_url_env_var("DEBUG")); + } + + #[test] + fn test_suggest_env_var_name() { + assert_eq!( + suggest_env_var_name("sentiment-analysis"), + "SENTIMENT_ANALYSIS_URL" + ); + assert_eq!(suggest_env_var_name("backend"), "BACKEND_URL"); + assert_eq!( + suggest_env_var_name("contact-intelligence"), + "CONTACT_INTELLIGENCE_URL" + ); + } + + #[test] + fn test_match_env_vars_to_services() { + let endpoints = vec![ + AvailableServiceEndpoint { + service_name: "sentiment-analysis".to_string(), + url: "https://sentiment-abc.syncable.dev".to_string(), + is_private: false, + cloud_provider: Some("hetzner".to_string()), + status: "running".to_string(), + }, + AvailableServiceEndpoint { + service_name: "contact-intelligence".to_string(), + url: "https://contact-def.syncable.dev".to_string(), + is_private: false, + cloud_provider: Some("hetzner".to_string()), + status: "running".to_string(), + }, + ]; + + let env_vars = vec![ + "SENTIMENT_SERVICE_URL".to_string(), + "CONTACTS_API_URL".to_string(), + "NODE_ENV".to_string(), // not a URL var + "DATABASE_URL".to_string(), // no matching service + ]; + + let suggestions = match_env_vars_to_services(&env_vars, &endpoints); + + // SENTIMENT_SERVICE_URL should match sentiment-analysis + let sent = suggestions + .iter() + .find(|s| s.env_var_name == "SENTIMENT_SERVICE_URL"); + assert!(sent.is_some()); + assert_eq!(sent.unwrap().service.service_name, "sentiment-analysis"); + assert_eq!(sent.unwrap().confidence, MatchConfidence::High); + + // CONTACTS_API_URL should match contact-intelligence + let cont = suggestions + .iter() + .find(|s| s.env_var_name == "CONTACTS_API_URL"); + assert!(cont.is_some()); + assert_eq!(cont.unwrap().service.service_name, "contact-intelligence"); + + // NODE_ENV should not be in suggestions (not a URL var) + assert!(suggestions + .iter() + .all(|s| s.env_var_name != "NODE_ENV")); + } + + #[test] + fn test_get_available_endpoints() { + use crate::platform::api::types::DeployedService; + use chrono::Utc; + + let deployments = vec![ + DeployedService { + id: "1".to_string(), + project_id: "p1".to_string(), + service_name: "running-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "running".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://running.example.com".to_string()), + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + DeployedService { + id: "2".to_string(), + project_id: "p1".to_string(), + service_name: "no-url-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "running".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: None, + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + DeployedService { + id: "3".to_string(), + project_id: "p1".to_string(), + service_name: "failed-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "failed".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://failed.example.com".to_string()), + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + DeployedService { + id: "4".to_string(), + project_id: "p1".to_string(), + service_name: "healthy-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "healthy".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://healthy.example.com".to_string()), + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + ]; + + let endpoints = get_available_endpoints(&deployments); + assert_eq!(endpoints.len(), 2); + assert_eq!(endpoints[0].service_name, "running-svc"); + assert_eq!(endpoints[1].service_name, "healthy-svc"); + } + + #[test] + fn test_get_available_endpoints_includes_private_ip() { + use crate::platform::api::types::DeployedService; + use chrono::Utc; + + let deployments = vec![ + DeployedService { + id: "1".to_string(), + project_id: "p1".to_string(), + service_name: "public-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "healthy".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://public.example.com".to_string()), + private_ip: Some("10.0.0.2".to_string()), + cloud_provider: Some("hetzner".to_string()), + created_at: Utc::now(), + }, + DeployedService { + id: "2".to_string(), + project_id: "p1".to_string(), + service_name: "internal-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "healthy".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: None, + private_ip: Some("10.0.0.3".to_string()), + cloud_provider: Some("hetzner".to_string()), + created_at: Utc::now(), + }, + DeployedService { + id: "3".to_string(), + project_id: "p1".to_string(), + service_name: "ghost-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "healthy".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: None, + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + ]; + + let endpoints = get_available_endpoints(&deployments); + assert_eq!(endpoints.len(), 2); + + // Public service uses public URL, not private IP + assert_eq!(endpoints[0].service_name, "public-svc"); + assert_eq!(endpoints[0].url, "https://public.example.com"); + assert!(!endpoints[0].is_private); + + // Internal service uses private IP + assert_eq!(endpoints[1].service_name, "internal-svc"); + assert_eq!(endpoints[1].url, "http://10.0.0.3"); + assert!(endpoints[1].is_private); + } + + #[test] + fn test_get_available_endpoints_deduplicates() { + use crate::platform::api::types::DeployedService; + use chrono::Utc; + + // Simulate API returning two records for same service (most recent first) + let deployments = vec![ + DeployedService { + id: "2".to_string(), + project_id: "p1".to_string(), + service_name: "backend".to_string(), + repository_full_name: "org/repo".to_string(), + status: "running".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://backend.example.com".to_string()), + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + DeployedService { + id: "1".to_string(), + project_id: "p1".to_string(), + service_name: "backend".to_string(), + repository_full_name: "org/repo".to_string(), + status: "failed".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://backend-old.example.com".to_string()), + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }, + ]; + + let endpoints = get_available_endpoints(&deployments); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].url, "https://backend.example.com"); + } + + #[test] + fn test_get_available_endpoints_accepts_unknown_statuses() { + use crate::platform::api::types::DeployedService; + use chrono::Utc; + + // A service with an unexpected status but a public URL should be included + let deployments = vec![DeployedService { + id: "1".to_string(), + project_id: "p1".to_string(), + service_name: "api-svc".to_string(), + repository_full_name: "org/repo".to_string(), + status: "succeeded".to_string(), + backstage_task_id: None, + commit_sha: None, + public_url: Some("https://api.example.com".to_string()), + private_ip: None, + cloud_provider: None, + created_at: Utc::now(), + }]; + + let endpoints = get_available_endpoints(&deployments); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].service_name, "api-svc"); + } + + #[test] + fn test_filter_endpoints_for_provider() { + let endpoints = vec![ + // Public endpoint on Azure — should always be kept + AvailableServiceEndpoint { + service_name: "azure-api".to_string(), + url: "https://azure-api.example.com".to_string(), + is_private: false, + cloud_provider: Some("azure".to_string()), + status: "healthy".to_string(), + }, + // Private endpoint on Hetzner — should be kept when deploying to Hetzner + AvailableServiceEndpoint { + service_name: "hetzner-worker".to_string(), + url: "http://10.0.0.5".to_string(), + is_private: true, + cloud_provider: Some("hetzner".to_string()), + status: "healthy".to_string(), + }, + // Private endpoint on Azure — should NOT be kept when deploying to Hetzner + AvailableServiceEndpoint { + service_name: "azure-internal".to_string(), + url: "http://10.1.0.5".to_string(), + is_private: true, + cloud_provider: Some("azure".to_string()), + status: "healthy".to_string(), + }, + ]; + + // Deploying to Hetzner: keep public endpoints + Hetzner private only + let filtered = filter_endpoints_for_provider(endpoints.clone(), "hetzner"); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].service_name, "azure-api"); // public, always kept + assert_eq!(filtered[1].service_name, "hetzner-worker"); // same provider + + // Deploying to Azure: keep public endpoints + Azure private only + let filtered = filter_endpoints_for_provider(endpoints, "azure"); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].service_name, "azure-api"); // public + assert_eq!(filtered[1].service_name, "azure-internal"); // same provider + } +} From af9bc183e8b9303aa870de744966788803ba8095 Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Mon, 16 Feb 2026 21:53:26 +0100 Subject: [PATCH 2/2] feat: private network service discovery added --- src/agent/tools/platform/deploy_service.rs | 36 +++ src/platform/api/client.rs | 38 ++- src/platform/api/types.rs | 47 +++ src/wizard/mod.rs | 4 +- src/wizard/service_endpoints.rs | 324 ++++++++++++++++++++- 5 files changed, 440 insertions(+), 9 deletions(-) diff --git a/src/agent/tools/platform/deploy_service.rs b/src/agent/tools/platform/deploy_service.rs index 948bbe46..9647cc99 100644 --- a/src/agent/tools/platform/deploy_service.rs +++ b/src/agent/tools/platform/deploy_service.rs @@ -26,6 +26,7 @@ use crate::wizard::{ get_hetzner_regions_dynamic, get_hetzner_server_types_dynamic, HetznerFetchResult, DynamicCloudRegion, DynamicMachineType, discover_env_files, parse_env_file, get_available_endpoints, filter_endpoints_for_provider, match_env_vars_to_services, + extract_network_endpoints, }; use std::process::Command; @@ -200,6 +201,14 @@ User: "deploy this service" - Private endpoints are pre-filtered to only show services on the same provider network - ALWAYS mention available endpoints when deploying services that have env vars matching deployed services +**Private networks (project_networks):** +- The response includes project_networks showing provisioned VPCs/networks for the target provider +- Each network includes connection_details with key/value pairs (VPC_ID, SUBNET_ID, DEFAULT_DOMAIN, etc.) +- If networks have useful connection details (e.g., a default domain, VPC connector), mention them to the user +- Ask the user if they want to inject any network details as environment variables +- Network details are NOT secrets — they are infrastructure identifiers +- Private networks enable service-to-service communication on the same provider + **Environment variables (secret_keys) and .env files:** - The preview response includes parsed_env_files: discovered .env files with their parsed keys/values - If .env files are found, ALWAYS ask the user: "I found a .env file with N variables. Should I inject these into the deployment?" @@ -767,6 +776,21 @@ User: "deploy this service" let endpoint_suggestions = match_env_vars_to_services(&detected_env_var_names, &deployed_endpoints); + // Fetch project networks for the target provider + let project_networks = match client.list_project_networks(&project_id).await { + Ok(nets) => nets, + Err(e) => { + tracing::debug!("Could not fetch project networks: {}", e); + Vec::new() + } + }; + + let network_endpoints = extract_network_endpoints( + &project_networks, + final_provider_for_check.as_str(), + Some(&resolved_env_id), + ); + let response = json!({ "status": "recommendation", "deployment_mode": deployment_mode, @@ -889,6 +913,18 @@ User: "deploy this service" "confidence": format!("{:?}", s.confidence), "reason": s.reason, })).collect::>(), + "project_networks": network_endpoints.iter().map(|ne| json!({ + "network_id": ne.network_id, + "cloud_provider": ne.cloud_provider, + "region": ne.region, + "status": ne.status, + "environment_id": ne.environment_id, + "connection_details": ne.connection_details.iter().map(|(k, v)| json!({ + "key": k, + "value": v, + "suggested_env_var": k, + })).collect::>(), + })).collect::>(), "next_steps": next_steps, "confirmation_prompt": if existing_config.is_some() { format!( diff --git a/src/platform/api/client.rs b/src/platform/api/client.rs index a053d95b..eb6df118 100644 --- a/src/platform/api/client.rs +++ b/src/platform/api/client.rs @@ -6,13 +6,14 @@ use super::error::{PlatformApiError, Result}; use super::types::{ ApiErrorResponse, ArtifactRegistry, AvailableRepositoriesResponse, CloudCredentialStatus, - CloudProvider, ClusterEntity, ConnectRepositoryRequest, ConnectRepositoryResponse, - CreateDeploymentConfigRequest, CreateDeploymentConfigResponse, CreateRegistryRequest, - CreateRegistryResponse, DeploymentConfig, DeploymentSecretInput, DeploymentTaskStatus, - Environment, GenericResponse, GetLogsResponse, GitHubInstallationUrlResponse, - GitHubInstallationsResponse, InitializeGitOpsRequest, InitializeGitOpsResponse, Organization, - PaginatedDeployments, Project, ProjectRepositoriesResponse, RegistryTaskStatus, - TriggerDeploymentRequest, TriggerDeploymentResponse, UserProfile, + CloudProvider, CloudRunnerNetwork, ClusterEntity, ConnectRepositoryRequest, + ConnectRepositoryResponse, CreateDeploymentConfigRequest, CreateDeploymentConfigResponse, + CreateRegistryRequest, CreateRegistryResponse, DeploymentConfig, DeploymentSecretInput, + DeploymentTaskStatus, Environment, GenericResponse, GetLogsResponse, + GitHubInstallationUrlResponse, GitHubInstallationsResponse, InitializeGitOpsRequest, + InitializeGitOpsResponse, Organization, PaginatedDeployments, Project, + ProjectRepositoriesResponse, RegistryTaskStatus, TriggerDeploymentRequest, + TriggerDeploymentResponse, UserProfile, }; use crate::auth::credentials; use reqwest::Client; @@ -1021,6 +1022,29 @@ impl PlatformApiClient { .await } + // ========================================================================= + // Cloud Runner Network API methods + // ========================================================================= + + /// List all cloud runner networks for a project + /// + /// Returns VPCs, subnets, Azure Container App Environments, GCP VPC Connectors, etc. + /// Use this to discover private networking infrastructure provisioned for the project. + /// + /// Endpoint: GET /api/v1/cloud-runner/projects/:projectId/networks + pub async fn list_project_networks( + &self, + project_id: &str, + ) -> Result> { + let response: GenericResponse> = self + .get(&format!( + "/api/v1/cloud-runner/projects/{}/networks", + project_id + )) + .await?; + Ok(response.data) + } + // ========================================================================= // Health Check API methods // ========================================================================= diff --git a/src/platform/api/types.rs b/src/platform/api/types.rs index e1aced61..afef5b5e 100644 --- a/src/platform/api/types.rs +++ b/src/platform/api/types.rs @@ -1515,6 +1515,53 @@ pub struct ServerTypesResponse { pub data: Vec, } +// ============================================================================= +// Cloud Runner Network Types +// ============================================================================= + +/// A provisioned cloud runner network (VPC/subnet/domain) +/// +/// Represents infrastructure networking resources provisioned for a project, +/// including VPCs, subnets, Azure Container App Environments, GCP VPC Connectors, etc. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudRunnerNetwork { + /// Unique network identifier + pub id: String, + /// Project this network belongs to + pub project_id: String, + /// Organization this network belongs to + pub organization_id: String, + /// Environment this network is scoped to (None for shared/default networks) + pub environment_id: Option, + /// Cloud provider (e.g., "hetzner", "gcp", "azure") + pub cloud_provider: String, + /// Region where the network is provisioned + pub region: String, + /// VPC identifier (provider-specific) + pub vpc_id: Option, + /// VPC display name + pub vpc_name: Option, + /// Subnet identifier + pub subnet_id: Option, + /// GCP VPC Connector identifier + pub vpc_connector_id: Option, + /// GCP VPC Connector name + pub vpc_connector_name: Option, + /// Azure resource group name + pub resource_group_name: Option, + /// Azure Container App Environment identifier + pub container_app_environment_id: Option, + /// Azure Container App Environment name + pub container_app_environment_name: Option, + /// Default domain for services on this network (e.g., Azure ACA default domain) + pub default_domain: Option, + /// Network status (e.g., "ready", "provisioning", "error") + pub status: String, + /// Error message if status is "error" + pub error_message: Option, +} + // ============================================================================= // Hetzner Options Types (from /api/v1/cloud-runner/hetzner/options) // ============================================================================= diff --git a/src/wizard/mod.rs b/src/wizard/mod.rs index 7942fb0a..c28cb5fd 100644 --- a/src/wizard/mod.rs +++ b/src/wizard/mod.rs @@ -52,7 +52,9 @@ pub use recommendations::{ }; pub use render::{count_badge, display_step_header, status_indicator, wizard_render_config}; pub use service_endpoints::{ - collect_service_endpoint_env_vars, filter_endpoints_for_provider, get_available_endpoints, + collect_network_endpoint_env_vars, collect_service_endpoint_env_vars, + extract_network_endpoints, filter_endpoints_for_provider, get_available_endpoints, match_env_vars_to_services, AvailableServiceEndpoint, EndpointSuggestion, MatchConfidence, + NetworkEndpointInfo, }; pub use target_selection::{select_target, TargetSelectionResult}; diff --git a/src/wizard/service_endpoints.rs b/src/wizard/service_endpoints.rs index deeff827..16a3b1e5 100644 --- a/src/wizard/service_endpoints.rs +++ b/src/wizard/service_endpoints.rs @@ -4,7 +4,7 @@ //! already-deployed services, shows their public URLs, and offers to inject //! them as environment variables. -use crate::platform::api::types::{DeployedService, DeploymentSecretInput}; +use crate::platform::api::types::{CloudRunnerNetwork, DeployedService, DeploymentSecretInput}; use crate::wizard::render::wizard_render_config; use colored::Colorize; use inquire::{Confirm, InquireError, MultiSelect, Text}; @@ -447,6 +447,175 @@ pub fn collect_service_endpoint_env_vars( result } +// --------------------------------------------------------------------------- +// Network endpoint discovery +// --------------------------------------------------------------------------- + +/// A network resource with its connection-relevant details. +/// +/// Extracted from `CloudRunnerNetwork` records, filtered for the target +/// provider and environment. Contains key-value pairs of useful connection +/// info (VPC_ID, DEFAULT_DOMAIN, etc.) that can be injected as env vars. +#[derive(Debug, Clone)] +pub struct NetworkEndpointInfo { + pub network_id: String, + pub cloud_provider: String, + pub region: String, + pub status: String, + pub environment_id: Option, + /// Key-value pairs of useful connection info for this network + /// e.g., ("NETWORK_VPC_ID", "12345"), ("NETWORK_DEFAULT_DOMAIN", "my-app.azurecontainerapps.io") + pub connection_details: Vec<(String, String)>, +} + +/// Extract useful connection details from cloud runner networks. +/// +/// Returns only networks that are "ready" and on the target provider. +/// Optionally filters by environment ID (shared/default networks with no +/// environment_id are always included). +pub fn extract_network_endpoints( + networks: &[CloudRunnerNetwork], + target_provider: &str, + target_environment_id: Option<&str>, +) -> Vec { + networks + .iter() + .filter(|n| { + n.status == "ready" + && n.cloud_provider.eq_ignore_ascii_case(target_provider) + && (target_environment_id.is_none() + || n.environment_id.as_deref() == target_environment_id + || n.environment_id.is_none()) // shared/default networks + }) + .map(|n| { + let mut details = Vec::new(); + + // Provider-generic connection details + if let Some(ref vpc_id) = n.vpc_id { + details.push(("NETWORK_VPC_ID".to_string(), vpc_id.clone())); + } + if let Some(ref vpc_name) = n.vpc_name { + details.push(("NETWORK_VPC_NAME".to_string(), vpc_name.clone())); + } + if let Some(ref subnet_id) = n.subnet_id { + details.push(("NETWORK_SUBNET_ID".to_string(), subnet_id.clone())); + } + // Azure-specific + if let Some(ref cae_name) = n.container_app_environment_name { + details.push(( + "AZURE_CONTAINER_APP_ENV_NAME".to_string(), + cae_name.clone(), + )); + } + if let Some(ref domain) = n.default_domain { + details.push(("NETWORK_DEFAULT_DOMAIN".to_string(), domain.clone())); + } + if let Some(ref rg) = n.resource_group_name { + details.push(("AZURE_RESOURCE_GROUP".to_string(), rg.clone())); + } + // GCP-specific + if let Some(ref connector_name) = n.vpc_connector_name { + details.push(("GCP_VPC_CONNECTOR".to_string(), connector_name.clone())); + } + + NetworkEndpointInfo { + network_id: n.id.clone(), + cloud_provider: n.cloud_provider.clone(), + region: n.region.clone(), + status: n.status.clone(), + environment_id: n.environment_id.clone(), + connection_details: details, + } + }) + .collect() +} + +/// Interactive prompt to offer network connection details as env vars. +/// +/// Shows discovered network info and lets the user select which to inject. +/// Returns `DeploymentSecretInput` entries with `is_secret: false` (network +/// identifiers are infrastructure metadata, not secrets). +pub fn collect_network_endpoint_env_vars( + network_endpoints: &[NetworkEndpointInfo], +) -> Vec { + if network_endpoints.is_empty() { + return Vec::new(); + } + + // Flatten all connection details across networks + let all_details: Vec<(&NetworkEndpointInfo, &str, &str)> = network_endpoints + .iter() + .flat_map(|ne| { + ne.connection_details + .iter() + .map(move |(k, v)| (ne, k.as_str(), v.as_str())) + }) + .collect(); + + if all_details.is_empty() { + return Vec::new(); + } + + println!(); + println!( + "{}", + "─── Private Network Resources ────────────────────".dimmed() + ); + for ne in network_endpoints { + println!( + " {} {} network in {} ({})", + "●".green(), + ne.cloud_provider.cyan(), + ne.region, + ne.status, + ); + for (k, v) in &ne.connection_details { + println!(" {} = {}", k.dimmed(), v); + } + } + println!(); + + let wants_inject = match Confirm::new("Inject any network details as env vars?") + .with_default(false) + .with_help_message("Add network identifiers like VPC_ID, DEFAULT_DOMAIN as env vars") + .prompt() + { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + if !wants_inject { + return Vec::new(); + } + + let labels: Vec = all_details + .iter() + .map(|(ne, k, v)| format!("{} = {} [{}]", k, v, ne.cloud_provider)) + .collect(); + + let selected = match MultiSelect::new("Select network details to inject:", labels.clone()) + .with_render_config(wizard_render_config()) + .with_help_message("Space to toggle, Enter to confirm") + .prompt() + { + Ok(s) if !s.is_empty() => s, + _ => return Vec::new(), + }; + + selected + .iter() + .filter_map(|label| { + let idx = labels.iter().position(|l| l == label)?; + let (_, key, value) = &all_details[idx]; + Some(DeploymentSecretInput { + key: key.to_string(), + value: value.to_string(), + is_secret: false, + }) + }) + .collect() +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -818,4 +987,157 @@ mod tests { assert_eq!(filtered[0].service_name, "azure-api"); // public assert_eq!(filtered[1].service_name, "azure-internal"); // same provider } + + // ========================================================================= + // Network endpoint tests + // ========================================================================= + + fn make_network( + id: &str, + provider: &str, + region: &str, + status: &str, + env_id: Option<&str>, + ) -> CloudRunnerNetwork { + CloudRunnerNetwork { + id: id.to_string(), + project_id: "proj-1".to_string(), + organization_id: "org-1".to_string(), + environment_id: env_id.map(String::from), + cloud_provider: provider.to_string(), + region: region.to_string(), + vpc_id: None, + vpc_name: None, + subnet_id: None, + vpc_connector_id: None, + vpc_connector_name: None, + resource_group_name: None, + container_app_environment_id: None, + container_app_environment_name: None, + default_domain: None, + status: status.to_string(), + error_message: None, + } + } + + #[test] + fn test_extract_network_endpoints_filters_by_provider_and_status() { + let networks = vec![ + { + let mut n = make_network("n1", "hetzner", "nbg1", "ready", Some("env-1")); + n.vpc_id = Some("vpc-123".to_string()); + n.subnet_id = Some("subnet-456".to_string()); + n + }, + // Different provider — should be excluded + { + let mut n = make_network("n2", "gcp", "us-central1", "ready", Some("env-1")); + n.vpc_connector_name = Some("my-connector".to_string()); + n + }, + // Same provider but not ready — should be excluded + { + let mut n = make_network("n3", "hetzner", "fsn1", "provisioning", Some("env-1")); + n.vpc_id = Some("vpc-789".to_string()); + n + }, + ]; + + let endpoints = extract_network_endpoints(&networks, "hetzner", Some("env-1")); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].network_id, "n1"); + assert_eq!(endpoints[0].cloud_provider, "hetzner"); + assert_eq!(endpoints[0].connection_details.len(), 2); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "NETWORK_VPC_ID" && v == "vpc-123")); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "NETWORK_SUBNET_ID" && v == "subnet-456")); + } + + #[test] + fn test_extract_network_endpoints_azure() { + let networks = vec![{ + let mut n = make_network("n1", "azure", "eastus", "ready", Some("env-1")); + n.container_app_environment_name = Some("my-cae".to_string()); + n.default_domain = Some("my-app.azurecontainerapps.io".to_string()); + n.resource_group_name = Some("rg-prod".to_string()); + n + }]; + + let endpoints = extract_network_endpoints(&networks, "azure", Some("env-1")); + assert_eq!(endpoints.len(), 1); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "AZURE_CONTAINER_APP_ENV_NAME" && v == "my-cae")); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "NETWORK_DEFAULT_DOMAIN" + && v == "my-app.azurecontainerapps.io")); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "AZURE_RESOURCE_GROUP" && v == "rg-prod")); + } + + #[test] + fn test_extract_network_endpoints_hetzner() { + let networks = vec![{ + let mut n = make_network("n1", "hetzner", "nbg1", "ready", None); + n.vpc_id = Some("hetz-vpc-1".to_string()); + n.subnet_id = Some("hetz-sub-1".to_string()); + n + }]; + + let endpoints = extract_network_endpoints(&networks, "hetzner", Some("env-1")); + // Shared network (no environment_id) should be included + assert_eq!(endpoints.len(), 1); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "NETWORK_VPC_ID" && v == "hetz-vpc-1")); + assert!(endpoints[0] + .connection_details + .iter() + .any(|(k, v)| k == "NETWORK_SUBNET_ID" && v == "hetz-sub-1")); + } + + #[test] + fn test_extract_network_endpoints_gcp() { + let networks = vec![{ + let mut n = make_network("n1", "gcp", "us-central1", "ready", Some("env-1")); + n.vpc_connector_name = Some("projects/my-proj/locations/us-central1/connectors/vpc-conn".to_string()); + n + }]; + + let endpoints = extract_network_endpoints(&networks, "gcp", Some("env-1")); + assert_eq!(endpoints.len(), 1); + assert!(endpoints[0].connection_details.iter().any(|(k, v)| k + == "GCP_VPC_CONNECTOR" + && v == "projects/my-proj/locations/us-central1/connectors/vpc-conn")); + } + + #[test] + fn test_extract_network_endpoints_filters_non_ready() { + let networks = vec![ + { + let mut n = make_network("n1", "hetzner", "nbg1", "error", Some("env-1")); + n.vpc_id = Some("vpc-err".to_string()); + n + }, + { + let mut n = make_network("n2", "hetzner", "nbg1", "provisioning", Some("env-1")); + n.vpc_id = Some("vpc-prov".to_string()); + n + }, + ]; + + let endpoints = extract_network_endpoints(&networks, "hetzner", Some("env-1")); + assert!(endpoints.is_empty()); + } }