diff --git a/src/adapters/backend/mod.rs b/src/adapters/backend/mod.rs index cafc548..1b870e0 100644 --- a/src/adapters/backend/mod.rs +++ b/src/adapters/backend/mod.rs @@ -105,13 +105,13 @@ impl BackendClient { pub(crate) async fn list_applications( &self, - id: WorkspaceId, + workspace_id: WorkspaceId, ) -> Result> { self.is_authenicated()?; - let applications = self + let applications: Vec<_> = self .client .applications_api() - .list_applications(id) + .list_applications(workspace_id) .await .into_diagnostic()? .applications; diff --git a/src/adapters/cloudflare/mod.rs b/src/adapters/cloudflare/mod.rs index 25a8000..d4ce229 100644 --- a/src/adapters/cloudflare/mod.rs +++ b/src/adapters/cloudflare/mod.rs @@ -4,6 +4,7 @@ use miette::{IntoDiagnostic, Result, miette}; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; use reqwest::multipart::Part; use reqwest::{Client, multipart}; +use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use tokio::fs::read; use tracing::{debug, error}; @@ -16,6 +17,15 @@ use responses::CloudflareResponse; use crate::artifacts::CloudflareManifest; +#[derive(Debug, Clone, Serialize, Deserialize, Getters)] +pub struct WorkerScript { + id: String, + created_on: String, + modified_on: String, + usage_model: Option, + etag: String, +} + static URL: OnceLock = OnceLock::new(); fn init_url() -> Url { @@ -28,12 +38,10 @@ pub struct CloudflareClient { client: Client, /// This is the Cloudflare account id account_id: String, - /// The name of the Cloudflare worker - worker_name: String, } impl CloudflareClient { - pub fn new(account_id: String, worker_name: String, token: &str) -> Self { + pub fn new(account_id: String, token: &str) -> Self { // TODO: Add a timeout. let mut default_headers = HeaderMap::new(); let auth = format!("Bearer {token}"); @@ -46,11 +54,7 @@ impl CloudflareClient { .build() .expect("Must be able to construct client"); - Self { - client, - account_id, - worker_name, - } + Self { client, account_id } } // Commented out until we verify if we need an upload session. @@ -164,12 +168,12 @@ impl CloudflareClient { // https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/versions/methods/create/ pub async fn upload_version( &self, + worker_name: &String, manifest: &CloudflareManifest, main_module: &String, ) -> Result { debug!("Uploading Worker version"); let account_id = &self.account_id; - let worker_name = &self.worker_name; let path = format!("accounts/{account_id}/workers/scripts/{worker_name}/versions"); let url = Self::url_with_path(&path); @@ -224,10 +228,9 @@ impl CloudflareClient { // Corresponds to: // https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/deployments/methods/get/ - pub async fn get_current_version(&self) -> Result { + pub async fn get_current_version(&self, worker_name: &String) -> Result { debug!("Getting current Worker version"); let account_id = &self.account_id; - let worker_name = &self.worker_name; let path = format!("accounts/{account_id}/workers/scripts/{worker_name}/deployments"); let url = Self::url_with_path(&path); @@ -263,10 +266,13 @@ impl CloudflareClient { // Corresponds to: // https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/deployments/methods/create/ - pub async fn create_deployment(&self, request: CreateDeploymentRequest) -> Result<()> { + pub async fn create_deployment( + &self, + worker_name: &String, + request: CreateDeploymentRequest, + ) -> Result<()> { debug!("Deploying updated version(s)"); let account_id = &self.account_id; - let worker_name = &self.worker_name; let path = format!("accounts/{account_id}/workers/scripts/{worker_name}/deployments"); let url = Self::url_with_path(&path); @@ -291,14 +297,14 @@ impl CloudflareClient { // For the monitor to grab metrics within a time range. pub async fn collect_metrics( &self, - worker_version_id: String, + worker_name: &String, + worker_version_id: &String, status_code_range_start: u16, status_code_range_end: u16, from_time: DateTime, to_time: DateTime, ) -> Result { let account_id = &self.account_id; - let worker_name = &self.worker_name; let path = format!("accounts/{account_id}/workers/observability/telemetry/query"); let url = Self::url_with_path(&path); @@ -390,6 +396,35 @@ impl CloudflareClient { Ok(count) } + // List all workers for the account + // Corresponds to: https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/list/ + pub async fn list_workers(&self) -> Result> { + debug!("Listing Cloudflare Workers"); + let account_id = &self.account_id; + let path = format!("accounts/{account_id}/workers/scripts"); + let url = Self::url_with_path(&path); + + // Make request to Cloudflare API + let response = self.client.get(url).send().await.into_diagnostic()?; + + // Check if we get a non-2xx response + if !response.status().is_success() { + return Err(miette!( + "Failed to list Workers. Error: {:?}", + response.json::().await + )); + } + + // Serialize the response into our struct + let workers_response = response + .json::>>() + .await + .into_diagnostic()?; + + debug!("Workers listed successfully"); + Ok(workers_response.result) + } + fn base_url() -> &'static Url { URL.get_or_init(init_url) } diff --git a/src/adapters/ingresses/cloudflare.rs b/src/adapters/ingresses/cloudflare.rs index 30550bb..b2374c0 100644 --- a/src/adapters/ingresses/cloudflare.rs +++ b/src/adapters/ingresses/cloudflare.rs @@ -17,6 +17,8 @@ use tracing::{debug, info}; #[derive(Getters)] pub struct CloudflareWorkerIngress { client: Client, + // The name of the worker being monitored + worker_name: String, // The version id of the baseline version control_version_id: Option, // The version id of the canary version @@ -24,9 +26,10 @@ pub struct CloudflareWorkerIngress { } impl CloudflareWorkerIngress { - pub fn new(client: Client) -> Self { + pub fn new(client: Client, worker_name: String) -> Self { Self { client, + worker_name, control_version_id: None, canary_version_id: None, } @@ -38,7 +41,7 @@ impl Ingress for CloudflareWorkerIngress { fn get_config(&self) -> IngressConfig { IngressConfig::CloudflareWorker { account_id: self.client.account_id().clone(), - worker_name: self.client.worker_name().clone(), + worker_name: self.worker_name().clone(), } } @@ -67,7 +70,9 @@ impl Ingress for CloudflareWorkerIngress { .versions(vec![control_version, canary_version]) .build(); - self.client.create_deployment(deployment_request).await + self.client + .create_deployment(self.worker_name(), deployment_request) + .await } async fn set_canary_traffic(&mut self, percent: WholePercent) -> Result<()> { @@ -85,7 +90,9 @@ impl Ingress for CloudflareWorkerIngress { .versions(vec![control_version, canary_version]) .build(); - self.client.create_deployment(deployment_request).await + self.client + .create_deployment(self.worker_name(), deployment_request) + .await } async fn rollback_canary(&mut self) -> Result<()> { @@ -103,7 +110,9 @@ impl Ingress for CloudflareWorkerIngress { // no canary version and so we don't try to roll it back (again) during shutdown. self.canary_version_id = None; - self.client.create_deployment(deployment_request).await + self.client + .create_deployment(self.worker_name(), deployment_request) + .await } async fn promote_canary(&mut self) -> Result<()> { @@ -121,7 +130,9 @@ impl Ingress for CloudflareWorkerIngress { // the control version and so we don't try to roll it back during shutdown. self.canary_version_id = None; - self.client.create_deployment(deployment_request).await + self.client + .create_deployment(self.worker_name(), deployment_request) + .await } } diff --git a/src/adapters/monitors/cloudflare.rs b/src/adapters/monitors/cloudflare.rs index 1318a95..84e85f3 100644 --- a/src/adapters/monitors/cloudflare.rs +++ b/src/adapters/monitors/cloudflare.rs @@ -17,6 +17,8 @@ use super::Monitor; #[derive(Getters)] pub struct CloudflareMonitor { client: Client, + // The name of the worker being monitored + worker_name: String, // The version id of the baseline version control_version_id: Option, // The version id of the canary version @@ -28,9 +30,10 @@ pub struct CloudflareMonitor { } impl CloudflareMonitor { - pub fn new(client: Client) -> Self { + pub fn new(client: Client, worker_name: String) -> Self { Self { client, + worker_name, control_version_id: None, canary_version_id: None, start_time: Utc::now(), @@ -48,7 +51,7 @@ impl Monitor for CloudflareMonitor { fn get_config(&self) -> MonitorConfig { MonitorConfig::CloudflareWorkersObservability { account_id: self.client.account_id().clone(), - worker_name: self.client.worker_name().clone(), + worker_name: self.worker_name().clone(), } } @@ -68,7 +71,8 @@ impl Monitor for CloudflareMonitor { // Query all control metrics, but only if we've already received a control version id if let Some(control_version_id) = &self.control_version_id { let control_2xx_future = self.client.collect_metrics( - control_version_id.clone(), + self.worker_name(), + control_version_id, 200, 299, start_query_time, @@ -76,7 +80,8 @@ impl Monitor for CloudflareMonitor { ); let control_4xx_future = self.client.collect_metrics( - control_version_id.clone(), + self.worker_name(), + control_version_id, 400, 499, start_query_time, @@ -84,7 +89,8 @@ impl Monitor for CloudflareMonitor { ); let control_5xx_future = self.client.collect_metrics( - control_version_id.clone(), + self.worker_name(), + control_version_id, 500, 599, start_query_time, @@ -111,7 +117,8 @@ impl Monitor for CloudflareMonitor { // Query all canary metrics, but only if we've already received a control version id if let Some(canary_version_id) = &self.canary_version_id { let canary_2xx_future = self.client.collect_metrics( - canary_version_id.clone(), + self.worker_name(), + canary_version_id, 200, 299, start_query_time, @@ -119,7 +126,8 @@ impl Monitor for CloudflareMonitor { ); let canary_4xx_future = self.client.collect_metrics( - canary_version_id.clone(), + self.worker_name(), + canary_version_id, 400, 499, start_query_time, @@ -127,7 +135,8 @@ impl Monitor for CloudflareMonitor { ); let canary_5xx_future = self.client.collect_metrics( - canary_version_id.clone(), + self.worker_name(), + canary_version_id, 500, 599, start_query_time, diff --git a/src/adapters/platforms/cloudflare.rs b/src/adapters/platforms/cloudflare.rs index 3697a0e..32cd2a8 100644 --- a/src/adapters/platforms/cloudflare.rs +++ b/src/adapters/platforms/cloudflare.rs @@ -16,14 +16,21 @@ use tracing::info; #[derive(Getters)] pub struct CloudflareWorkerPlatform { client: Client, + worker_name: String, artifact_path: PathBuf, main_module: String, } impl CloudflareWorkerPlatform { - pub fn new(client: Client, artifact_path: PathBuf, main_module: String) -> Self { + pub fn new( + client: Client, + worker_name: String, + artifact_path: PathBuf, + main_module: String, + ) -> Self { Self { client, + worker_name, artifact_path, main_module, } @@ -35,13 +42,13 @@ impl Platform for CloudflareWorkerPlatform { fn get_config(&self) -> PlatformConfig { PlatformConfig::CloudflareWorker { account_id: self.client.account_id().clone(), - worker_name: self.client.worker_name().clone(), + worker_name: self.worker_name().clone(), } } async fn deploy(&mut self) -> Result<(String, String)> { info!("Deploying Worker!"); - let baseline_version_id = self.client.get_current_version().await?; + let baseline_version_id = self.client.get_current_version(self.worker_name()).await?; // 1. First, we create a manifest of the files to upload let manifest = CloudflareManifest::new(&self.artifact_path).await?; @@ -72,7 +79,7 @@ impl Platform for CloudflareWorkerPlatform { // 2. Finally, upload the files let upload_version_request = self .client - .upload_version(&manifest, &self.main_module) + .upload_version(self.worker_name(), &manifest, &self.main_module) .await?; Ok((baseline_version_id, upload_version_request.id)) diff --git a/src/cmd/init.rs b/src/cmd/init.rs index b272f89..0f5332f 100644 --- a/src/cmd/init.rs +++ b/src/cmd/init.rs @@ -7,20 +7,25 @@ //! until you reach a leaf node. Each leaf node corresponds to a //! field in the manifest. +use std::path::Path; use std::sync::Arc; use async_trait::async_trait; use bon::Builder; use miette::{IntoDiagnostic, Result}; use tokio::{runtime::Runtime, sync::Mutex}; -use tracing::info; +use tracing::{info, warn}; use crate::{ MULTITOOL_ORIGIN, Terminal, - adapters::BackendClient, + adapters::{BackendClient, CloudflareClient, backend::WorkspaceId}, config::InitSubcommand, fs::{FileSystem, Session, SessionFile}, - manifest::{InitTomlManifest, Manifest}, + manifest::{ + AwsApiGatewayConfig, AwsCloudwatch, AwsLambdaConfig, CloudflareConfig, IngressConfig, + InitTomlManifest, Manifest, MonitorConfig, PlatformConfig, + }, + utils::load_default_aws_config, }; pub struct Init { @@ -122,7 +127,7 @@ struct CheckLogin { impl CheckLogin { async fn prompt_login(&self) -> Result { - let email = self.terminal.prompt_email(); + let email = self.terminal.prompt_text("Email"); let password = self.terminal.prompt_password(); let backend = BackendClient::new(Some(&self.origin), None)?; let creds = backend.exchange_creds(&email, &password).await?; @@ -202,7 +207,7 @@ impl InitStateMachine for PromptWorkspace { Ok(workspaces) => workspaces, Err(err) => return State::Err(err), }; - let mut options: Vec<_> = workspaces + let workspace_names: Vec<_> = workspaces .iter() .map(|workspace| workspace.display_name.clone()) .collect(); @@ -210,26 +215,29 @@ impl InitStateMachine for PromptWorkspace { // from the list, or to create a new one. // To give them that option, we have to add a new element // to the list. + let mut options = workspace_names.clone(); options.push("+ Create new workspace".to_owned()); // Now, we can prompt the user to select an option. info!("Which workspace would you like to use?"); - let selection = self.terminal.prompt_workspace_selection(options.as_slice()); + let selection = self + .terminal + .prompt_single_selection("Workspace", options.as_slice()); if selection < workspaces.len() { // The user has selected an existing workspace. - // Set this field and continue. - let selected_workspace = workspaces[selection].clone(); + let selected_workspace = &workspaces[selection]; let workspace_name = selected_workspace.display_name.clone(); let workspace_id = selected_workspace.id; + let mut manifest_guard = self.manifest.lock().await; manifest_guard.set_workspace(workspace_name); drop(manifest_guard); - // Awesome, now we can move on to the application id. + // Awesome, now we can move on to the application. let next = PromptApplication::builder() + .workspace_id(workspace_id) .manifest(self.manifest.clone()) .fs(self.fs.clone()) .terminal(self.terminal.clone()) .backend(self.backend.clone()) - .workspace_id(workspace_id) .build(); State::Next(Box::new(next)) } else { @@ -266,10 +274,10 @@ impl InitStateMachine for PromptWorkspace { #[derive(Builder)] struct PromptApplication { + workspace_id: WorkspaceId, manifest: Arc>, terminal: Arc, fs: FileSystem, - workspace_id: u32, backend: BackendClient, } @@ -286,55 +294,468 @@ impl InitStateMachine for PromptApplication { Ok(applications) => applications, Err(err) => return State::Err(err), }; - let mut options: Vec<_> = applications - .iter() - .map(|application| application.display_name.clone()) + let application_names: Vec<_> = applications + .into_iter() + .map(|application| application.display_name) .collect(); // We're going to prompt the user to pick out their application // from the list, or to create a new one. // To give them that option, we have to add a new element // to the list. + let mut options = application_names.clone(); options.push("+ Create new application".to_owned()); // Now, we can prompt the user to select an option. info!("Which application would you like to use?"); - let selection = self.terminal.prompt_workspace_selection(options.as_slice()); - if selection < applications.len() { + let selection = self + .terminal + .prompt_single_selection("Application", options.as_slice()); + if selection < application_names.len() { // The user has selected an existing application. - // Set this field and continue. - let selected_application = applications[selection].clone(); - let application_name = selected_application.display_name.clone(); + let application = application_names[selection].clone(); let mut manifest_guard = self.manifest.lock().await; - manifest_guard.set_application(application_name); + manifest_guard.set_application(application); drop(manifest_guard); - // Return the completed manifest - State::Done(self.manifest.clone()) + // Move on to cloud provider selection + let next = PromptCloudProvider::builder() + .manifest(self.manifest.clone()) + .fs(self.fs.clone()) + .terminal(self.terminal.clone()) + .backend(self.backend.clone()) + .build(); + State::Next(Box::new(next)) } else { // The user has decided to create a new application. - // Prompt for the application name, create a new application, - // and then set the field and continue. - info!("Let's create a new application"); - let application_name = self.terminal.prompt_application_name(); - - // Create the application - // Since we have a todo! in the create_application method, - // this code will not actually run until that's implemented - let application = match self - .backend - .create_application(self.workspace_id, application_name.clone()) - .await - { - Ok(application) => application, - Err(err) => return State::Err(err), - }; + // Prompt for the application name and create a new application. + todo!(); + } + } +} + +#[derive(Builder)] +struct PromptCloudProvider { + manifest: Arc>, + terminal: Arc, + fs: FileSystem, + backend: BackendClient, +} + +#[async_trait] +impl InitStateMachine for PromptCloudProvider { + type Output = Arc>; + + async fn run(&mut self) -> State { + debug_assert!(self.backend.is_authenicated().is_ok()); + + // Prompt the user to select their cloud provider + let cloud_providers = vec!["AWS".to_owned(), "Cloudflare".to_owned()]; + info!("Which cloud provider are you using?"); + let selection = self + .terminal + .prompt_single_selection("Provider", cloud_providers.as_slice()); + + let selected_provider = &cloud_providers[selection]; + + match selected_provider.as_str() { + "AWS" => { + // Move on to AWS setup + let next = PromptAWSSetup::builder() + .manifest(self.manifest.clone()) + .fs(self.fs.clone()) + .terminal(self.terminal.clone()) + .backend(self.backend.clone()) + .build(); + State::Next(Box::new(next)) + } + "Cloudflare" => { + // Move on to Cloudflare setup + let next = PromptCloudflareSetup::builder() + .manifest(self.manifest.clone()) + .fs(self.fs.clone()) + .terminal(self.terminal.clone()) + .backend(self.backend.clone()) + .build(); + State::Next(Box::new(next)) + } + _ => return State::Err(miette::miette!("Invalid cloud provider selected")), + } + } +} + +#[derive(Builder)] +struct PromptCloudflareSetup { + manifest: Arc>, + terminal: Arc, + fs: FileSystem, + backend: BackendClient, +} + +impl PromptCloudflareSetup { + async fn get_worker_name(&self, client: &CloudflareClient) -> Result { + // Get list of workers from Cloudflare + let workers = match client.list_workers().await { + Ok(workers) => workers, + Err(err) => { + return Err(miette::miette!( + "Failed to fetch workers from Cloudflare: {}", + err + )); + } + }; + + // Prompt user to select which worker they want to use + let worker_names: Vec = workers.iter().map(|w| w.id().clone()).collect(); + if worker_names.is_empty() { + return Err(miette::miette!( + "No workers found in your Cloudflare account" + )); + } + + info!("Which Cloudflare Worker would you like to use?"); + let selection = self + .terminal + .prompt_single_selection("Worker", &worker_names); + Ok(worker_names[selection].clone()) + } + + fn get_artifact_path(&self) -> String { + loop { + info!("Please enter the path to your worker's artifacts (e.g., src/):"); + let path_str = self.terminal.prompt_text("Artifact Path"); + let path = Path::new(&path_str); + + // Verify that the path is valid + if path.exists() && path.is_dir() { + return path_str; + } else { + info!( + "The path '{}' is not a valid directory. Please try again.", + path_str + ); + } + } + } +} + +#[async_trait] +impl InitStateMachine for PromptCloudflareSetup { + type Output = Arc>; - // Set the application name in the manifest + async fn run(&mut self) -> State { + debug_assert!(self.backend.is_authenicated().is_ok()); + + // Get account ID from user + info!("Please enter your Cloudflare account ID:"); + let account_id = self.terminal.prompt_text("Account ID"); + + // First, prompt user for API token + info!("Please enter your Cloudflare API token:"); + let api_token = self.terminal.prompt_text("API Token"); + + let client = CloudflareClient::new(account_id.clone(), &api_token); + + let selected_worker_name = match self.get_worker_name(&client).await { + Ok(name) => name, + Err(err) => return State::Err(err), + }; + + let artifact_path = self.get_artifact_path(); + + // Prompt user for the main module of their worker + info!("Please enter the main module of your worker (e.g., index.js):"); + let main_module = self.terminal.prompt_text("Main Module"); + + // Store all the values in the manifest + { let mut manifest_guard = self.manifest.lock().await; - manifest_guard.set_application(application_name); - drop(manifest_guard); - // Return the completed manifest - State::Done(self.manifest.clone()) + let cloudflare_config = CloudflareConfig::new( + false, // wrangler + main_module, + account_id, + selected_worker_name, + artifact_path, + ); + + manifest_guard.set_cloudflare_config(cloudflare_config); } + + State::Done(self.manifest.clone()) + } +} + +#[derive(Builder)] +struct PromptAWSSetup { + manifest: Arc>, + terminal: Arc, + fs: FileSystem, + backend: BackendClient, +} +impl PromptAWSSetup { + async fn get_lambda_name( + &self, + lambda_client: &aws_sdk_lambda::Client, + region: &str, + ) -> Result { + let lambda_functions = match lambda_client.list_functions().send().await { + Ok(response) => response.functions().to_vec(), + Err(err) => { + return Err(miette::miette!( + "Failed to list Lambda functions: {:?}", + err + )); + } + }; + + if lambda_functions.is_empty() { + return Err(miette::miette!( + "No Lambda functions found in region {}. Please create a Lambda function first.", + region + )); + } + + let lambda_names: Vec = lambda_functions + .iter() + .filter_map(|f| f.function_name().map(|name| name.to_string())) + .collect(); + + info!("Which Lambda function would you like to use?"); + let lambda_selection = self + .terminal + .prompt_single_selection("Lambda", &lambda_names); + Ok(lambda_names[lambda_selection].clone()) + } + + fn prompt_artifact_path(&self) -> String { + loop { + info!( + "Please enter the path to your Lambda deployment artifact (must be a .zip file):" + ); + let path_str = self.terminal.prompt_text("Artifact Path"); + let path = Path::new(&path_str); + + if !path.exists() { + warn!("The path '{}' does not exist. Please try again.", path_str); + continue; + } + + if !path.is_file() { + warn!("The path '{}' is not a file. Please try again.", path_str); + continue; + } + + if !path_str.to_lowercase().ends_with(".zip") { + warn!( + "The file '{}' is not a .zip file. Please provide a .zip file.", + path_str + ); + continue; + } + + return path_str; + } + } + + async fn get_api_gateway( + &self, + apig_client: &aws_sdk_apigateway::Client, + ) -> Result<(String, String)> { + info!("Fetching API Gateways..."); + let api_gateways = match apig_client.get_rest_apis().send().await { + Ok(response) => response.items().to_vec(), + Err(err) => { + return Err(miette::miette!("Failed to list API Gateways: {:?}", err)); + } + }; + + if api_gateways.is_empty() { + return Err(miette::miette!( + "No API Gateways found. Please create an API Gateway first." + )); + } + + let gateway_names: Vec = api_gateways + .iter() + .filter_map(|gw| gw.name().map(|name| name.to_string())) + .collect(); + + info!("Which API Gateway would you like to use?"); + let gateway_selection = self + .terminal + .prompt_single_selection("API Gateway", &gateway_names); + let selected_gateway_name = gateway_names[gateway_selection].clone(); + let selected_gateway = &api_gateways[gateway_selection]; + let gateway_id = selected_gateway.id().unwrap().to_string(); + + Ok((selected_gateway_name, gateway_id)) + } + + async fn get_stage( + &self, + apig_client: &aws_sdk_apigateway::Client, + gateway_id: &str, + gateway_name: &str, + ) -> Result { + info!("Fetching API Gateway stages..."); + let stages = match apig_client + .get_stages() + .rest_api_id(gateway_id) + .send() + .await + { + Ok(response) => response.item().to_vec(), + Err(err) => { + return Err(miette::miette!( + "Failed to list API Gateway stages: {:?}", + err + )); + } + }; + + if stages.is_empty() { + return Err(miette::miette!( + "No stages found for API Gateway '{}'. Please create a stage first.", + gateway_name + )); + } + + let stage_names: Vec = stages + .iter() + .filter_map(|stage| stage.stage_name().map(|name| name.to_string())) + .collect(); + + info!("Which stage would you like to use?"); + let stage_selection = self.terminal.prompt_single_selection("Stage", &stage_names); + Ok(stage_names[stage_selection].clone()) + } + + async fn get_resource_method_and_path( + &self, + apig_client: &aws_sdk_apigateway::Client, + gateway_id: &str, + gateway_name: &str, + ) -> Result<(String, String)> { + info!("Fetching API Gateway resources..."); + let resources = match apig_client + .get_resources() + .rest_api_id(gateway_id) + .send() + .await + { + Ok(response) => response.items().to_vec(), + Err(err) => { + return Err(miette::miette!( + "Failed to list API Gateway resources: {:?}", + err + )); + } + }; + + if resources.is_empty() { + return Err(miette::miette!( + "No resources found for API Gateway '{}'. Please create resources first.", + gateway_name + )); + } + + // Build a list of resource path + method combinations + let mut resource_options = Vec::new(); + for resource in &resources { + if let Some(path) = resource.path() { + if let Some(methods) = resource.resource_methods() { + for method in methods.keys() { + resource_options.push(format!("{} /{}", method, path)); + } + } + } + } + + if resource_options.is_empty() { + return Err(miette::miette!( + "No resource methods found for API Gateway '{}'. Please configure resource methods first.", + gateway_name + )); + } + + info!("Which resource method and path would you like to use?"); + let resource_selection = self + .terminal + .prompt_single_selection("Resource Method", &resource_options); + let selected_resource_option = &resource_options[resource_selection]; + + // Parse the selected option to extract method and path + let parts: Vec<&str> = selected_resource_option.splitn(2, ' ').collect(); + let selected_method = parts[0].to_string(); + let selected_path = parts[1].to_string(); + + Ok((selected_method, selected_path)) + } +} + +#[async_trait] +impl InitStateMachine for PromptAWSSetup { + type Output = Arc>; + + async fn run(&mut self) -> State { + let aws_config = load_default_aws_config().await; + let apig_client = aws_sdk_apigateway::Client::new(&aws_config); + let lambda_client = aws_sdk_lambda::Client::new(aws_config); + + info!("Please enter the AWS region you would like to use (e.g., us-east-2):"); + let region = self.terminal.prompt_text("AWS Region"); + + let lambda_name = match self.get_lambda_name(&lambda_client, ®ion).await { + Ok(name) => name, + Err(err) => return State::Err(err), + }; + + let artifact_path = self.prompt_artifact_path(); + + let (gateway_name, gateway_id) = match self.get_api_gateway(&apig_client).await { + Ok(result) => result, + Err(err) => return State::Err(err), + }; + + let stage_name = match self + .get_stage(&apig_client, &gateway_id, &gateway_name) + .await + { + Ok(name) => name, + Err(err) => return State::Err(err), + }; + + let (resource_method, resource_path) = match self + .get_resource_method_and_path(&apig_client, &gateway_id, &gateway_name) + .await + { + Ok(result) => result, + Err(err) => return State::Err(err), + }; + + let mut manifest_guard = self.manifest.lock().await; + + // Create and set Lambda config + let platform_config = PlatformConfig::AwsLambda(AwsLambdaConfig::new( + lambda_name, + region.clone(), + artifact_path, + )); + manifest_guard.set_platform_config(platform_config); + + // Create and set APIG config + let ingress_config = IngressConfig::AwsApiGateway(AwsApiGatewayConfig::new( + stage_name, + gateway_name, + resource_path, + resource_method, + region.clone(), + )); + manifest_guard.set_ingress_config(ingress_config); + + // Create and set CloudWatch config + let monitor_config = MonitorConfig::AwsCloudwatch(AwsCloudwatch::default()); + manifest_guard.set_monitor_config(monitor_config); + + State::Done(self.manifest.clone()) } } diff --git a/src/cmd/login.rs b/src/cmd/login.rs index ebae0f4..51c44ec 100644 --- a/src/cmd/login.rs +++ b/src/cmd/login.rs @@ -35,7 +35,7 @@ impl Login { .email() .as_deref() .map(ToString::to_string) - .unwrap_or_else(|| self.terminal.prompt_email()); + .unwrap_or_else(|| self.terminal.prompt_text("Email")); // If no password was provided, prompt for their password. let password = self diff --git a/src/fs/manifest/mod.rs b/src/fs/manifest/mod.rs index aa3824c..08a9f13 100644 --- a/src/fs/manifest/mod.rs +++ b/src/fs/manifest/mod.rs @@ -1,6 +1,9 @@ use serde::{Deserialize, Serialize}; -pub use schema::{CloudflareConfig, Manifest, application_manifest}; +pub use schema::{ + AwsApiGatewayConfig, AwsCloudwatch, AwsLambdaConfig, CloudflareConfig, IngressConfig, Manifest, + MonitorConfig, PlatformConfig, application_manifest, +}; use super::{DirectoryType, file::StaticFile}; diff --git a/src/fs/manifest/schema.rs b/src/fs/manifest/schema.rs index d309f94..770f2fd 100644 --- a/src/fs/manifest/schema.rs +++ b/src/fs/manifest/schema.rs @@ -86,6 +86,22 @@ impl Manifest { self.application.as_deref() } + pub fn set_cloudflare_config(&mut self, config: CloudflareConfig) { + self.config.cloudflare = Some(config); + } + + pub fn set_platform_config(&mut self, config: PlatformConfig) { + self.config.platform = Some(config); + } + + pub fn set_ingress_config(&mut self, config: IngressConfig) { + self.config.ingress = Some(config); + } + + pub fn set_monitor_config(&mut self, config: MonitorConfig) { + self.config.monitor = Some(config); + } + pub(crate) async fn load_platform(&self, args: &RunSubcommand) -> Result { self.config.load_platform(args).await } @@ -267,6 +283,22 @@ pub struct AwsApiGatewayConfig { } impl AwsApiGatewayConfig { + pub fn new( + stage_name: String, + gateway_name: String, + resource_path: String, + resource_method: String, + region: String, + ) -> Self { + Self { + stage_name, + gateway_name, + resource_path, + resource_method, + region, + } + } + async fn load_ingress(&self, _: &RunSubcommand) -> Result { let ingress = AwsApiGateway::builder() .gateway_name(self.gateway_name.clone()) @@ -305,6 +337,14 @@ pub struct AwsLambdaConfig { } impl AwsLambdaConfig { + pub fn new(name: String, region: String, artifact_path: String) -> Self { + Self { + name, + region, + artifact_path, + } + } + async fn load_platform(&self, args: &RunSubcommand) -> Result { let region: String = args .aws_region() @@ -343,6 +383,23 @@ pub struct CloudflareConfig { } impl CloudflareConfig { + pub fn new( + wrangler: bool, + main_module: String, + account_id: String, + worker_name: String, + artifact_path: String, + ) -> Self { + Self { + wrangler: Some(wrangler), + main_module: Some(main_module), + account_id: Some(account_id), + worker_name: Some(worker_name), + artifact_path: Some(artifact_path), + api_token: None, // This is set via CLI/env, not stored in manifest + } + } + pub fn load_wrangler(&self, fs: &FileSystem) -> Result { fs.load_file(WranglerFile) } @@ -427,9 +484,9 @@ impl CloudflareConfig { let api_token = self.load_api_token(args)?; let account_id = self.load_account_id(&fs, args)?; let worker_name = self.load_worker_name(&fs, args)?; - let client = CloudflareClient::new(account_id, worker_name, &api_token); + let client = CloudflareClient::new(account_id, &api_token); - Ok(Box::new(CloudflareWorkerIngress::new(client))) + Ok(Box::new(CloudflareWorkerIngress::new(client, worker_name))) } fn load_monitor(&self, args: &RunSubcommand) -> Result { @@ -438,9 +495,9 @@ impl CloudflareConfig { let api_token = self.load_api_token(args)?; let account_id = self.load_account_id(&fs, args)?; let worker_name = self.load_worker_name(&fs, args)?; - let client = CloudflareClient::new(account_id, worker_name, &api_token); + let client = CloudflareClient::new(account_id, &api_token); - Ok(Box::new(CloudflareMonitor::new(client))) + Ok(Box::new(CloudflareMonitor::new(client, worker_name))) } fn load_platform(&self, args: &RunSubcommand) -> Result { @@ -451,10 +508,11 @@ impl CloudflareConfig { let worker_name = self.load_worker_name(&fs, args)?; let main_module = self.load_main_module(&fs, args)?; let artifact_path = self.load_artifact_path(&fs, args)?; - let client = CloudflareClient::new(account_id, worker_name, &api_token); + let client = CloudflareClient::new(account_id, &api_token); Ok(Box::new(CloudflareWorkerPlatform::new( client, + worker_name, artifact_path, main_module, ))) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index c6540ca..120de6d 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -91,17 +91,17 @@ impl Terminal { .into_diagnostic() } - pub fn prompt_email(&self) -> String { + pub fn prompt_text(&self, prompt: &str) -> String { Input::with_theme(self.stdout.theme()) - .with_prompt("Email") + .with_prompt(prompt) .interact() .unwrap() } - pub fn prompt_workspace_selection(&self, items: &[String]) -> usize { + pub fn prompt_single_selection(&self, prompt: &str, items: &[String]) -> usize { Select::with_theme(self.stdout.theme()) .items(items) - .with_prompt("Workspace") + .with_prompt(prompt) .interact() .unwrap() }