diff --git a/Cargo.lock b/Cargo.lock index 7cc44ce7..abe512d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,8 +480,9 @@ dependencies = [ [[package]] name = "config" -version = "0.3.38" +version = "0.3.39" dependencies = [ + "base64", "chrono", "clap", "dirs", @@ -586,7 +587,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto" -version = "0.3.38" +version = "0.3.39" dependencies = [ "aes-gcm", "base64", @@ -3214,7 +3215,7 @@ dependencies = [ [[package]] name = "testutils" -version = "0.3.38" +version = "0.3.39" dependencies = [ "pem", "rsa", @@ -3484,7 +3485,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.38" +version = "0.3.39" dependencies = [ "tokio", "tower-api", @@ -3509,7 +3510,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.38" +version = "0.3.39" dependencies = [ "reqwest", "serde", @@ -3521,7 +3522,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.38" +version = "0.3.39" dependencies = [ "axum", "bytes", @@ -3591,7 +3592,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.38" +version = "0.3.39" dependencies = [ "async-compression", "config", @@ -3610,7 +3611,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.38" +version = "0.3.39" dependencies = [ "chrono", "config", @@ -3631,7 +3632,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.38" +version = "0.3.39" dependencies = [ "tracing", "tracing-appender", @@ -3640,7 +3641,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.38" +version = "0.3.39" dependencies = [ "async-compression", "async_zip", @@ -3654,7 +3655,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.38" +version = "0.3.39" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 70fc3749..3d4dbb79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.38" +version = "0.3.39" description = "Tower is the best way to host Python data apps in production" rust-version = "1.81" authors = ["Brad Heller "] diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index a16d2f0e..39ec33a7 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -7,9 +7,10 @@ rust-version = { workspace = true } license = { workspace = true } [dependencies] +base64 = { workspace = true } chrono = { workspace = true } clap = { workspace = true } -dirs = { workspace = true } +dirs = { workspace = true } futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 5890e72a..a923147e 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -181,17 +181,15 @@ impl Config { configuration.base_path = base_path.to_string(); - if let Some(session) = &self.session { + // Always read from disk to pick up team switches + if let Ok(session) = Session::from_config_dir() { if let Some(active_team) = &session.active_team { - // Use the active team's JWT token configuration.bearer_access_token = Some(active_team.token.jwt.clone()); } else { - // Fall back to session token if no active team configuration.bearer_access_token = Some(session.token.jwt.clone()); } } - // Store the configuration in self configuration } } diff --git a/crates/config/src/session.rs b/crates/config/src/session.rs index 9148bb22..53830ccf 100644 --- a/crates/config/src/session.rs +++ b/crates/config/src/session.rs @@ -1,3 +1,4 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::{DateTime, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use std::fs; @@ -9,6 +10,20 @@ use crate::error::Error; use tower_api::apis::default_api::describe_session; use tower_telemetry::debug; +/// Extracts the account ID (aid) from a Tower JWT token. +/// Returns None if the JWT is malformed or doesn't contain an aid. +fn extract_aid_from_jwt(jwt: &str) -> Option { + let parts: Vec<&str> = jwt.split('.').collect(); + if parts.len() != 3 { + return None; + } + + let payload = parts[1]; + let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?; + let json: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + json.get("https://tower.dev/aid")?.as_str().map(String::from) +} + const DEFAULT_TOWER_URL: &str = "https://api.tower.dev"; pub fn default_tower_url() -> Url { @@ -163,6 +178,22 @@ impl Session { } } + /// Sets the active team based on an account ID (aid) extracted from a JWT. + /// Returns true if a matching team was found and set as active, false otherwise. + pub fn set_active_team_by_aid(&mut self, aid: &str) -> bool { + // Find the team whose JWT contains the matching aid + if let Some(team) = self + .teams + .iter() + .find(|team| extract_aid_from_jwt(&team.token.jwt).as_deref() == Some(aid)) + { + self.active_team = Some(team.clone()); + true + } else { + false + } + } + /// Updates the session with data from the API response pub fn update_from_api_response( &mut self, @@ -263,36 +294,30 @@ impl Session { } pub fn from_jwt(jwt: &str) -> Result { - // We need to instantiate our own configuration object here, instead of the typical thing - // that we do which is turn a Config into a Configuration. + let jwt_aid = extract_aid_from_jwt(jwt); + let mut config = tower_api::apis::configuration::Configuration::new(); config.bearer_access_token = Some(jwt.to_string()); - // We only pull TOWER_URL out of the environment here because we only ever use the JWT and - // all that in programmatic contexts (when TOWER_URL is set). - let tower_url = if let Ok(val) = std::env::var("TOWER_URL") { - val - } else { - DEFAULT_TOWER_URL.to_string() - }; - - // Setup the base path to point to the /v1 API endpoint as expected. + let tower_url = std::env::var("TOWER_URL").unwrap_or(DEFAULT_TOWER_URL.to_string()); let mut base_path = Url::parse(&tower_url).unwrap(); base_path.set_path("/v1"); - config.base_path = base_path.to_string(); - // This is a bit of a hairy thing: I didn't want to pull in too much from the Tower API - // client, so we're using the raw bindings here. match run_future_sync(describe_session(&config)) { Ok(resp) => { - // Now we need to extract the session from the response. let entity = resp.entity.unwrap(); match entity { tower_api::apis::default_api::DescribeSessionSuccess::Status200(resp) => { let mut session = Session::from_api_session(&resp.session); session.tower_url = base_path; + + if let Some(aid) = jwt_aid { + session.set_active_team_by_aid(&aid); + } + + session.save()?; Ok(session) } tower_api::apis::default_api::DescribeSessionSuccess::UnknownValue(val) => { diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index c08c12ca..668d5f79 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -133,36 +133,27 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) { output::table(headers, rows, Some(&app_response)); } - Err(err) => { - output::tower_error(err); - } + Err(err) => output::tower_error_and_die(err, "Fetching app details failed"), } } pub async fn do_list_apps(config: Config) { - let resp = api::list_apps(&config).await; - - match resp { - Ok(resp) => { - let items = resp - .apps - .iter() - .map(|app_summary| { - let app = &app_summary.app; - let desc = if app.short_description.is_empty() { - output::placeholder("No description") - } else { - app.short_description.to_string() - }; - format!("{}\n{}", output::title(&app.name), desc) - }) - .collect(); - output::list(items, Some(&resp.apps)); - } - Err(err) => { - output::tower_error(err); - } - } + let resp = output::with_spinner("Listing apps", api::list_apps(&config)).await; + + let items = resp + .apps + .iter() + .map(|app_summary| { + let app = &app_summary.app; + let desc = if app.short_description.is_empty() { + output::placeholder("No description") + } else { + app.short_description.to_string() + }; + format!("{}\n{}", output::title(&app.name), desc) + }) + .collect(); + output::list(items, Some(&resp.apps)); } pub async fn do_create(config: Config, args: &ArgMatches) { @@ -172,30 +163,16 @@ pub async fn do_create(config: Config, args: &ArgMatches) { let description = args.get_one::("description").unwrap(); - let mut spinner = output::spinner("Creating app"); + let app = + output::with_spinner("Creating app", api::create_app(&config, name, description)).await; - match api::create_app(&config, name, description).await { - Ok(app) => { - spinner.success(); - output::success_with_data(&format!("App '{}' created", name), Some(app)); - } - Err(err) => { - spinner.failure(); - output::tower_error(err); - } - } + output::success_with_data(&format!("App '{}' created", name), Some(app)); } pub async fn do_delete(config: Config, cmd: &ArgMatches) { let name = extract_app_name("delete", cmd.subcommand()); - let mut spinner = output::spinner("Deleting app"); - if let Err(err) = api::delete_app(&config, &name).await { - spinner.failure(); - output::tower_error(err); - } else { - spinner.success(); - } + output::with_spinner("Deleting app", api::delete_app(&config, &name)).await; } /// Extract app name and run number from command diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index b5c9b896..cf3edcb7 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -40,11 +40,21 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { let create_app = args.get_flag("create"); if let Err(err) = deploy_from_dir(config, dir, create_app).await { match err { - crate::Error::ApiDeployError { source } => output::tower_error(source), - crate::Error::ApiDescribeAppError { source } => output::tower_error(source), - crate::Error::PackageError { source } => output::package_error(source), - crate::Error::TowerfileLoadFailed { source, .. } => output::config_error(source), - _ => output::error(&err.to_string()), + crate::Error::ApiDeployError { source } => { + output::tower_error_and_die(source, "Deploying app failed") + } + crate::Error::ApiDescribeAppError { source } => { + output::tower_error_and_die(source, "Fetching app details failed") + } + crate::Error::PackageError { source } => { + output::package_error(source); + std::process::exit(1); + } + crate::Error::TowerfileLoadFailed { source, .. } => { + output::config_error(source); + std::process::exit(1); + } + _ => output::die(&err.to_string()), } } } @@ -62,13 +72,16 @@ pub async fn deploy_from_dir( let api_config = config.into(); // Add app existence check before proceeding - util::apps::ensure_app_exists( + if let Err(err) = util::apps::ensure_app_exists( &api_config, &towerfile.app.name, &towerfile.app.description, create_app, ) - .await?; + .await + { + return Err(crate::Error::ApiDescribeAppError { source: err }); + } let spec = PackageSpec::from_towerfile(&towerfile); let mut spinner = output::spinner("Building package..."); diff --git a/crates/tower-cmd/src/environments.rs b/crates/tower-cmd/src/environments.rs index 33b0cbe3..57609e5f 100644 --- a/crates/tower-cmd/src/environments.rs +++ b/crates/tower-cmd/src/environments.rs @@ -24,28 +24,21 @@ pub fn environments_cmd() -> Command { } pub async fn do_list(config: Config) { - let resp = api::list_environments(&config).await; + let resp = output::with_spinner("Listing environments", api::list_environments(&config)).await; - match resp { - Ok(resp) => { - let headers = vec!["Name"] - .into_iter() - .map(|h| h.yellow().to_string()) - .collect(); + let headers = vec!["Name"] + .into_iter() + .map(|h| h.yellow().to_string()) + .collect(); - let envs_data: Vec> = resp - .environments - .iter() - .map(|env| vec![env.name.clone()]) - .collect(); + let envs_data: Vec> = resp + .environments + .iter() + .map(|env| vec![env.name.clone()]) + .collect(); - // Display the table using the existing table function - output::table(headers, envs_data, Some(&resp.environments)); - } - Err(err) => { - output::tower_error(err); - } - } + // Display the table using the existing table function + output::table(headers, envs_data, Some(&resp.environments)); } pub async fn do_create(config: Config, args: &ArgMatches) { @@ -53,13 +46,11 @@ pub async fn do_create(config: Config, args: &ArgMatches) { output::die("Environment name (--name) is required"); }); - let mut spinner = output::spinner("Creating environment"); + output::with_spinner( + "Creating environment", + api::create_environment(&config, name), + ) + .await; - if let Err(err) = api::create_environment(&config, name).await { - spinner.failure(); - output::tower_error(err); - } else { - spinner.success(); - output::success(&format!("Environment '{}' created", name)); - } + output::success(&format!("Environment '{}' created", name)); } diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index afa88a41..a7a5974d 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -687,18 +687,15 @@ impl TowerService { } } Err(e) => { - let error_text = if output.trim().is_empty() { - let api_error = Self::extract_api_error_message(&e); - if Self::is_deployment_error(&api_error) { - format!( - "App '{}' not deployed. Try running tower_deploy first.", - app_name - ) - } else { - api_error - } + // Always extract the detailed API error message + let api_error = Self::extract_api_error_message(&e); + let error_text = if Self::is_deployment_error(&api_error) { + format!( + "App '{}' not deployed. Try running tower_deploy first.", + app_name + ) } else { - output + api_error }; Self::error_result("Remote run failed", error_text) } diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index df0cf0b4..fc3b7bb2 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -211,6 +211,7 @@ pub fn write(msg: &str) { send_to_current_sender(clean_msg); } else { io::stdout().write_all(msg.as_bytes()).unwrap(); + io::stdout().flush().ok(); } } @@ -319,6 +320,87 @@ pub fn tower_error(err: ApiError) { } } +/// Handles Tower API errors with context-specific authentication messages. +/// If the error is a 401 Unauthorized, provides a helpful message mentioning +/// the operation that failed and suggests running 'tower login'. +/// Always exits the process with error code 1. +pub fn tower_error_and_die(err: ApiError, operation: &str) -> ! { + // Check if this is an authentication error + if let ApiError::ResponseError(ref resp) = err { + if resp.status == StatusCode::UNAUTHORIZED { + die(&format!( + "{} because you are not logged into Tower. Please run 'tower login' first.", + operation + )); + } + } + + // Show the detailed error first + tower_error(err); + die(operation); +} + +/// Runs an async operation with a spinner and proper error handling. +/// +/// This helper provides consistent spinner behavior across all commands: +/// - Shows a spinner with "{operation}..." while the operation runs +/// - On success: stops the spinner with success indicator and returns the result +/// - On error: stops the spinner with failure indicator and shows auth-aware error message +/// +/// # Examples +/// +/// ```ignore +/// let envs = output::with_spinner( +/// "Listing environments", +/// api::list_environments(&config) +/// ).await; +/// ``` +pub async fn with_spinner(operation: &str, future: F) -> T +where + F: std::future::Future>>, +{ + let spinner_msg = format!("{}...", operation); + let mut spinner = self::spinner(&spinner_msg); + match future.await { + Ok(result) => { + spinner.success(); + result + } + Err(err) => { + spinner.failure(); + let error_msg = format!("{} failed", operation); + tower_error_and_die(err, &error_msg); + } + } +} + +/// Runs an async operation with a spinner, returning Result instead of exiting. +/// +/// This is the MCP-safe version of with_spinner that returns errors instead of exiting. +/// Use this for operations that may be called from MCP or other contexts where +/// process exit is not acceptable. Returns the error without displaying it, allowing +/// the caller to decide how to handle and display the error. +/// +/// Shows "{operation}..." during execution and stops the spinner on completion. +pub async fn try_with_spinner(operation: &str, future: F) -> Result> +where + F: std::future::Future>>, +{ + let spinner_msg = format!("{}...", operation); + let mut spinner = self::spinner(&spinner_msg); + match future.await { + Ok(result) => { + spinner.success(); + Ok(result) + } + Err(err) => { + spinner.failure(); + // Just return the error - let the caller decide how to handle it + Err(err) + } + } +} + pub fn table(headers: Vec, data: Vec>, json_data: Option<&T>) { if get_output_mode().is_json() { if let Some(data) = json_data { @@ -442,8 +524,13 @@ pub fn newline() { } pub fn die(msg: &str) -> ! { + io::stdout().flush().ok(); + io::stderr().flush().ok(); let line = format!("{} {}\n", "Error:".red(), msg); write(&line); + // Flush output before exit to ensure "Error:" message is displayed + io::stdout().flush().ok(); + io::stderr().flush().ok(); std::process::exit(1); } diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index ff9f5b62..dc7eb4e0 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -60,7 +60,14 @@ pub fn run_cmd() -> Command { pub async fn do_run(config: Config, args: &ArgMatches, cmd: Option<(&str, &ArgMatches)>) { if let Err(e) = do_run_inner(config, args, cmd).await { - output::die(&e.to_string()); + match e { + Error::ApiRunError { source } => { + output::tower_error_and_die(source, "Scheduling run failed"); + } + _ => { + output::die(&e.to_string()); + } + } } } @@ -114,11 +121,24 @@ where Fut: std::future::Future + Send + 'static, T: Send + 'static, { + // Load all the secrets and catalogs from the server let mut spinner = output::spinner("Setting up runtime environment..."); - // Load all the secrets and catalogs from the server - let secrets = get_secrets(&config, &env).await?; - let catalogs = get_catalogs(&config, &env).await?; + let secrets = match get_secrets(&config, &env).await { + Ok(s) => s, + Err(err) => { + spinner.failure(); + output::tower_error_and_die(err, "Fetching secrets failed"); + } + }; + + let catalogs = match get_catalogs(&config, &env).await { + Ok(c) => c, + Err(err) => { + spinner.failure(); + output::tower_error_and_die(err, "Fetching catalogs failed"); + } + }; spinner.success(); @@ -234,36 +254,29 @@ pub async fn do_run_remote( towerfile.app.name }; - let mut spinner = output::spinner("Scheduling run..."); + let res = output::try_with_spinner( + "Scheduling run", + api::run_app(&config, &app_slug, env, params), + ) + .await + .map_err(|source| Error::ApiRunError { source })?; - match api::run_app(&config, &app_slug, env, params).await { - Err(err) => { - spinner.failure(); - debug!("Failed to schedule run: {}", err); - output::tower_error(err); - Err(Error::RunFailed) - } - Ok(res) => { - spinner.success(); + let run = res.run; - let run = res.run; + if should_follow_run { + do_follow_run(config, &run).await?; + } else { + let line = format!( + "Run #{} for app `{}` has been scheduled", + run.number, app_slug + ); + output::success(&line); - if should_follow_run { - do_follow_run(config, &run).await?; - } else { - let line = format!( - "Run #{} for app `{}` has been scheduled", - run.number, app_slug - ); - output::success(&line); - - let link_line = format!(" See more: {}", run.dollar_link); - output::write(&link_line); - output::newline(); - } - Ok(()) - } + let link_line = format!(" See more: {}", run.dollar_link); + output::write(&link_line); + output::newline(); } + Ok(()) } async fn stream_logs_until_complete( @@ -459,39 +472,55 @@ fn get_app_name(cmd: Option<(&str, &ArgMatches)>) -> Option { } } -/// get_secrets manages the process of getting secrets from the Tower server in a way that can be -/// used by the local runtime during local app execution. -async fn get_secrets(config: &Config, env: &str) -> Result, Error> { +/// get_secrets_inner manages the process of getting secrets from the Tower server in a way that can be +/// used by the local runtime during local app execution. Returns API errors for spinner handling. +async fn get_secrets( + config: &Config, + env: &str, +) -> Result< + HashMap, + tower_api::apis::Error, +> { let (private_key, public_key) = crypto::generate_key_pair(); - let res = api::export_secrets(&config, env, false, public_key) - .await - .map_err(|err| { - debug!("API error fetching secrets: {:?}", err); - Error::FetchingSecretsFailed - })?; + let res = api::export_secrets(&config, env, false, public_key).await?; let mut secrets = HashMap::new(); for secret in res.secrets { // we will decrypt each property and inject it into the vals map. let decrypted_value = - crypto::decrypt(private_key.clone(), secret.encrypted_value.to_string())?; + crypto::decrypt(private_key.clone(), secret.encrypted_value.to_string()).map_err( + |_| { + tower_api::apis::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to decrypt secret", + )) + }, + )?; secrets.insert(secret.name, decrypted_value); } Ok(secrets) } -/// get_catalogs manages the process of exporting catalogs, decrypting their properties, and -/// preparting them for injection into the environment during app execution -async fn get_catalogs(config: &Config, env: &str) -> Result, Error> { +/// get_catalogs_inner manages the process of exporting catalogs, decrypting their properties, and +/// preparting them for injection into the environment during app execution. Returns plain Error for direct use. +async fn get_catalogs( + config: &Config, + env: &str, +) -> Result< + HashMap, + tower_api::apis::Error, +> { let (private_key, public_key) = crypto::generate_key_pair(); let res = api::export_catalogs(&config, env, false, public_key) .await - .map_err(|err| { - debug!("API error fetching catalogs: {:?}", err); - Error::FetchingCatalogsFailed + .map_err(|_| { + tower_api::apis::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to export catalogs", + )) })?; let mut vals = HashMap::new(); @@ -499,7 +528,13 @@ async fn get_catalogs(config: &Config, env: &str) -> Result("app").map(|s| s.as_str()); let environment = args.get_one::("environment").map(|s| s.as_str()); - match api::list_schedules(&config, app, environment).await { - Ok(response) => { - if response.schedules.is_empty() { - output::write("No schedules found.\n"); - return; - } - - let headers = vec![ - "ID".yellow().to_string(), - "App".yellow().to_string(), - "Environment".yellow().to_string(), - "Cron".yellow().to_string(), - "Status".yellow().to_string(), - ]; - - let rows: Vec> = response - .schedules - .iter() - .map(|schedule| { - let status = match schedule.status { - Status::Active => "active".green(), - Status::Disabled => "disabled".red(), - }; - - vec![ - schedule.id.clone(), - schedule.app_name.clone(), - schedule.environment.clone(), - schedule.cron.clone(), - status.to_string(), - ] - }) - .collect(); - - output::table(headers, rows, Some(&response.schedules)); - } - Err(err) => { - output::tower_error(err); - } + let response = output::with_spinner( + "Listing schedules", + api::list_schedules(&config, app, environment), + ) + .await; + + if response.schedules.is_empty() { + output::write("No schedules found.\n"); + return; } + + let headers = vec![ + "ID".yellow().to_string(), + "App".yellow().to_string(), + "Environment".yellow().to_string(), + "Cron".yellow().to_string(), + "Status".yellow().to_string(), + ]; + + let rows: Vec> = response + .schedules + .iter() + .map(|schedule| { + let status = match schedule.status { + Status::Active => "active".green(), + Status::Disabled => "disabled".red(), + }; + + vec![ + schedule.id.clone(), + schedule.app_name.clone(), + schedule.environment.clone(), + schedule.cron.clone(), + status.to_string(), + ] + }) + .collect(); + + output::table(headers, rows, Some(&response.schedules)); } pub async fn do_create(config: Config, args: &ArgMatches) { @@ -148,55 +147,42 @@ pub async fn do_create(config: Config, args: &ArgMatches) { let cron = args.get_one::("cron").unwrap(); let parameters = parse_parameters(args); - let mut spinner = output::spinner("Creating schedule"); + let response = output::with_spinner( + "Creating schedule", + api::create_schedule(&config, app_name, environment, cron, parameters), + ) + .await; - match api::create_schedule(&config, app_name, environment, cron, parameters).await { - Ok(response) => { - spinner.success(); - output::success(&format!( - "Schedule created with ID: {}", - response.schedule.id - )); - } - Err(err) => { - spinner.failure(); - output::tower_error(err); - } - } + output::success(&format!( + "Schedule created with ID: {}", + response.schedule.id + )); } pub async fn do_update(config: Config, args: &ArgMatches) { let schedule_id = extract_schedule_id("update", args.subcommand()); let cron = args.get_one::("cron"); let parameters = parse_parameters(args); - let mut spinner = output::spinner("Updating schedule"); - match api::update_schedule(&config, &schedule_id, cron, parameters).await { - Ok(_) => { - spinner.success(); - output::success(&format!("Schedule {} updated", schedule_id)); - } - Err(err) => { - spinner.failure(); - output::tower_error(err); - } - } + output::with_spinner( + "Updating schedule", + api::update_schedule(&config, &schedule_id, cron, parameters), + ) + .await; + + output::success(&format!("Schedule {} updated", schedule_id)); } pub async fn do_delete(config: Config, args: &ArgMatches) { let schedule_id = extract_schedule_id("delete", args.subcommand()); - let mut spinner = output::spinner("Deleting schedule"); - match api::delete_schedule(&config, &schedule_id).await { - Ok(_) => { - spinner.success(); - output::success(&format!("Schedule {} deleted", schedule_id)); - } - Err(err) => { - spinner.failure(); - output::tower_error(err); - } - } + output::with_spinner( + "Deleting schedule", + api::delete_schedule(&config, &schedule_id), + ) + .await; + + output::success(&format!("Schedule {} deleted", schedule_id)); } fn extract_schedule_id(subcmd: &str, cmd: Option<(&str, &ArgMatches)>) -> String { diff --git a/crates/tower-cmd/src/secrets.rs b/crates/tower-cmd/src/secrets.rs index 4f7ceb6b..5e878c13 100644 --- a/crates/tower-cmd/src/secrets.rs +++ b/crates/tower-cmd/src/secrets.rs @@ -4,10 +4,7 @@ use config::Config; use crypto::encrypt; use rsa::pkcs1::DecodeRsaPublicKey; -use tower_api::{ - apis::{default_api::CreateSecretError, Error}, - models::CreateSecretResponse, -}; +use tower_api::models::CreateSecretResponse; use tower_telemetry::debug; use crate::{api, output, util::cmd}; @@ -88,56 +85,54 @@ pub async fn do_list(config: Config, args: &ArgMatches) { if show { let (private_key, public_key) = crypto::generate_key_pair(); - match api::export_secrets(&config, &env, all, public_key).await { - Ok(list_response) => { - let headers = vec![ - "Secret".bold().yellow().to_string(), - "Environment".bold().yellow().to_string(), - "Value".bold().yellow().to_string(), - ]; - let data = list_response - .secrets - .iter() - .map(|secret| { - // now we decrypt the value and show it. - let decrypted_value = - crypto::decrypt(private_key.clone(), secret.encrypted_value.clone()) - .unwrap(); - - vec![ - secret.name.clone(), - secret.environment.clone(), - decrypted_value, - ] - }) - .collect(); - output::table(headers, data, Some(&list_response.secrets)); - } - Err(err) => output::tower_error(err), - } + let list_response = output::with_spinner( + "Listing secrets", + api::export_secrets(&config, &env, all, public_key), + ) + .await; + + let headers = vec![ + "Secret".bold().yellow().to_string(), + "Environment".bold().yellow().to_string(), + "Value".bold().yellow().to_string(), + ]; + let data = list_response + .secrets + .iter() + .map(|secret| { + // now we decrypt the value and show it. + let decrypted_value = + crypto::decrypt(private_key.clone(), secret.encrypted_value.clone()).unwrap(); + + vec![ + secret.name.clone(), + secret.environment.clone(), + decrypted_value, + ] + }) + .collect(); + output::table(headers, data, Some(&list_response.secrets)); } else { - match api::list_secrets(&config, &env, all).await { - Ok(list_response) => { - let headers = vec![ - "Secret".bold().yellow().to_string(), - "Environment".bold().yellow().to_string(), - "Preview".bold().yellow().to_string(), - ]; - let data = list_response - .secrets - .iter() - .map(|secret| { - vec![ - secret.name.clone(), - secret.environment.clone(), - secret.preview.dimmed().to_string(), - ] - }) - .collect(); - output::table(headers, data, Some(&list_response.secrets)); - } - Err(err) => output::tower_error(err), - } + let list_response = + output::with_spinner("Listing secrets", api::list_secrets(&config, &env, all)).await; + + let headers = vec![ + "Secret".bold().yellow().to_string(), + "Environment".bold().yellow().to_string(), + "Preview".bold().yellow().to_string(), + ]; + let data = list_response + .secrets + .iter() + .map(|secret| { + vec![ + secret.name.clone(), + secret.environment.clone(), + secret.preview.dimmed().to_string(), + ] + }) + .collect(); + output::table(headers, data, Some(&list_response.secrets)); } } @@ -157,8 +152,15 @@ pub async fn do_create(config: Config, args: &ArgMatches) { output::success(&line); } Err(err) => { - debug!("Failed to create secrets: {}", err); spinner.failure(); + match err { + SecretCreationError::FetchKeyFailed(e) => { + output::tower_error_and_die(e, "Fetching secrets key failed"); + } + SecretCreationError::CreateFailed(e) => { + output::tower_error_and_die(e, "Creating secret failed"); + } + } } } } @@ -167,14 +169,11 @@ pub async fn do_delete(config: Config, args: &ArgMatches) { let (environment, name) = extract_secret_environment_and_name("delete", args.subcommand()); debug!("deleting secret, environment={} name={}", environment, name); - let mut spinner = output::spinner("Deleting secret..."); - - if let Ok(_) = api::delete_secret(&config, &name, &environment).await { - spinner.success(); - } else { - spinner.failure(); - output::die("There was a problem with the Tower API! Please try again later."); - } + output::with_spinner( + "Deleting secret", + api::delete_secret(&config, &name, &environment), + ) + .await; } fn create_preview(value: &str) -> String { @@ -190,29 +189,31 @@ fn create_preview(value: &str) -> String { } } +enum SecretCreationError { + FetchKeyFailed(tower_api::apis::Error), + CreateFailed(tower_api::apis::Error), +} + async fn encrypt_and_create_secret( config: &Config, name: &str, value: &str, environment: &str, -) -> Result> { - match api::describe_secrets_key(config).await { - Ok(res) => { - let public_key = - rsa::RsaPublicKey::from_pkcs1_pem(&res.public_key).unwrap_or_else(|_| { - output::die("Failed to parse public key"); - }); - - let encrypted_value = encrypt(public_key, value.to_string()).unwrap(); - let preview = create_preview(value); - - api::create_secret(&config, name, environment, &encrypted_value, &preview).await - } - Err(err) => { - debug!("failed to talk to tower api: {}", err); - output::die("There was a problem with the Tower API! Please try again later."); - } - } +) -> Result { + let res = api::describe_secrets_key(config) + .await + .map_err(SecretCreationError::FetchKeyFailed)?; + + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&res.public_key).unwrap_or_else(|_| { + output::die("Failed to parse public key"); + }); + + let encrypted_value = encrypt(public_key, value.to_string()).unwrap(); + let preview = create_preview(value); + + api::create_secret(&config, name, environment, &encrypted_value, &preview) + .await + .map_err(SecretCreationError::CreateFailed) } fn extract_secret_environment_and_name( diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index cc7abfe4..d631e532 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -1,7 +1,6 @@ use clap::{ArgMatches, Command}; use colored::*; use config::Config; -use tower_telemetry::debug; use crate::{api, output}; @@ -28,30 +27,18 @@ async fn refresh_session(config: &Config) -> config::Session { } }; - let mut spinner = output::spinner("Refreshing session..."); + let resp = output::with_spinner("Refreshing session", api::refresh_session(&config)).await; - match api::refresh_session(&config).await { - Ok(resp) => { - spinner.success(); + // Create a mutable copy of the session to update + let mut session = current_session; - // Create a mutable copy of the session to update - let mut session = current_session; - - // Update it with the API response - if let Err(e) = session.update_from_api_response(&resp) { - output::config_error(e); - std::process::exit(1); - } - - session - } - Err(err) => { - debug!("Failed to refresh session: {}", err); - - spinner.failure(); - output::die("There was a problem talking to the Tower API. Try again later!"); - } + // Update it with the API response + if let Err(e) = session.update_from_api_response(&resp) { + output::config_error(e); + std::process::exit(1); } + + session } pub async fn do_list(config: Config) { diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 69b57aed..949785d1 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -13,7 +13,8 @@ pub async fn ensure_app_exists( description: &str, create_app: bool, ) -> Result<(), tower_api::apis::Error> { - // Try to describe the app first + // Try to describe the app first (with spinner) + let mut spinner = output::spinner("Checking app..."); let describe_result = default_api::describe_app( api_config, DescribeAppParams { @@ -28,6 +29,7 @@ pub async fn ensure_app_exists( // If the app exists, return Ok if describe_result.is_ok() { + spinner.success(); return Ok(()); } @@ -44,11 +46,15 @@ pub async fn ensure_app_exists( _ => false, }; - // If it's not a 404 error, return the original error + // If it's not a 404 error, fail the spinner and return the error if !is_not_found { + spinner.failure(); return Err(err); } + // App not found - stop spinner before prompting user + drop(spinner); + // Decide whether to create the app let create_app = create_app || prompt_default( @@ -65,7 +71,8 @@ pub async fn ensure_app_exists( return Err(err); } - // Try to create the app + // Try to create the app (with a new spinner) + let mut spinner = output::spinner("Creating app..."); let create_result = default_api::create_app( api_config, CreateAppParams { @@ -83,10 +90,12 @@ pub async fn ensure_app_exists( match create_result { Ok(_) => { + spinner.success(); output::success(&format!("Created app '{}'", app_name)); Ok(()) } Err(create_err) => { + spinner.failure(); // Convert any creation error to a response error Err(tower_api::apis::Error::ResponseError( tower_api::apis::ResponseContent { diff --git a/crates/tower-cmd/src/util/deploy.rs b/crates/tower-cmd/src/util/deploy.rs index 4488c39d..ad995887 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -33,7 +33,16 @@ pub async fn upload_file_with_progress( let metadata = file.metadata().await?; let file_size = metadata.len(); - // Create a stream with progress tracking + // Check if bundle size exceeds the maximum allowed size + if file_size > tower_package::MAX_BUNDLE_SIZE { + let size_mb = file_size as f64 / (1024.0 * 1024.0); + let max_mb = tower_package::MAX_BUNDLE_SIZE as f64 / (1024.0 * 1024.0); + output::die(&format!( + "Your App is too big! ({:.2} MB) exceeds maximum allowed size ({:.0} MB). Please consider reducing app size by removing unnecessary files or import_paths in the Towerfile.", + size_mb, max_mb + )); + } + let reader_stream = ReaderStream::new(file); let progress_stream = util::progress::ProgressStream::new(reader_stream, file_size, progress_cb).await?; diff --git a/crates/tower-package/src/lib.rs b/crates/tower-package/src/lib.rs index b2a8f6a1..c32ae959 100644 --- a/crates/tower-package/src/lib.rs +++ b/crates/tower-package/src/lib.rs @@ -29,6 +29,10 @@ pub use error::Error; // 3 - Change checksum algorithm to be cross-platform const CURRENT_PACKAGE_VERSION: i32 = 3; +// Maximum allowed size for a bundle package in bytes (50MB) +// This limit ensures bundles remain manageable for deployment and storage. +pub const MAX_BUNDLE_SIZE: u64 = 50 * 1024 * 1024; + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Parameter { pub name: String, diff --git a/crates/tower-runtime/tests/local_test.rs b/crates/tower-runtime/tests/local_test.rs index e803ff2f..d8ee4a6d 100644 --- a/crates/tower-runtime/tests/local_test.rs +++ b/crates/tower-runtime/tests/local_test.rs @@ -56,7 +56,7 @@ async fn test_running_hello_world_json_logs() { secrets: HashMap::new(), parameters: HashMap::new(), env_vars: HashMap::new(), - cache_dir: Some(config::default_cache_dir()) + cache_dir: Some(config::default_cache_dir()), }; // Start the app using the LocalApp runtime diff --git a/crates/tower-telemetry/src/logging.rs b/crates/tower-telemetry/src/logging.rs index c80b4464..7c16fd48 100644 --- a/crates/tower-telemetry/src/logging.rs +++ b/crates/tower-telemetry/src/logging.rs @@ -183,22 +183,26 @@ fn create_fmt_layer(format: LogFormat, destination: LogDestination) -> BoxedFmtL match destination { LogDestination::Stdout => match format { - LogFormat::Plain => Box::new(fmt::layer().event_format( - fmt::format() - .pretty() - .with_target(false) - .with_file(false) - .with_line_number(false) - .with_ansi(use_color) - )), - LogFormat::Json => Box::new(fmt::layer().event_format( - fmt::format() - .json() - .with_target(false) - .with_file(false) - .with_line_number(false) - .with_ansi(use_color) - )), + LogFormat::Plain => Box::new( + fmt::layer().event_format( + fmt::format() + .pretty() + .with_target(false) + .with_file(false) + .with_line_number(false) + .with_ansi(use_color), + ), + ), + LogFormat::Json => Box::new( + fmt::layer().event_format( + fmt::format() + .json() + .with_target(false) + .with_file(false) + .with_line_number(false) + .with_ansi(use_color), + ), + ), }, LogDestination::File(path) => { let file_appender = tracing_appender::rolling::daily(".", path); @@ -211,7 +215,7 @@ fn create_fmt_layer(format: LogFormat, destination: LogDestination) -> BoxedFmtL .with_target(false) .with_file(false) .with_line_number(false) - .with_ansi(use_color) + .with_ansi(use_color), ) .with_writer(file_appender), ), @@ -223,7 +227,7 @@ fn create_fmt_layer(format: LogFormat, destination: LogDestination) -> BoxedFmtL .with_target(false) .with_file(false) .with_line_number(false) - .with_ansi(use_color) + .with_ansi(use_color), ) .with_writer(file_appender), ), diff --git a/pyproject.toml b/pyproject.toml index d03595e0..705d2a3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.38" +version = "0.3.39" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }] readme = "README.md" diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index eee67012..da8ef117 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -6,6 +6,7 @@ import shutil import json import shlex +from datetime import datetime from pathlib import Path from behave import given, when, then from dirty_equals import IsStr, IsPartialDict @@ -84,10 +85,11 @@ def step_log_lines_should_be_separate(context): assert len(lines) > 3, f"Expected multiple lines of output, got: {len(lines)} lines" # Lines with timestamps should not be concatenated + current_year = str(datetime.now().year) timestamp_lines = [ line for line in lines - if "2025-" in line and ("Hello" in line or "Creating" in line) + if current_year in line and ("Hello" in line or "Creating" in line) ] assert ( len(timestamp_lines) > 1 diff --git a/uv.lock b/uv.lock index 8fdfa3aa..f6377fb7 100644 --- a/uv.lock +++ b/uv.lock @@ -2744,7 +2744,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.38" +version = "0.3.39" source = { editable = "." } dependencies = [ { name = "attrs" },