From d2943c4bf6e407ae6a4d04586c41480b4e9a9d4a Mon Sep 17 00:00:00 2001 From: Burak Dede Date: Wed, 21 Jan 2026 21:59:50 +0100 Subject: [PATCH 1/6] Fix #166: persist app description on create and deploy --- crates/tower-cmd/src/api.rs | 1 + crates/tower-cmd/src/deploy.rs | 10 +-- crates/tower-cmd/src/error.rs | 14 +++- crates/tower-cmd/src/util/apps.rs | 72 ++++++++++++++----- .../features/cli_app_management.feature | 10 ++- tests/integration/features/steps/cli_steps.py | 32 +++++++++ tests/mock-api-server/main.py | 27 ++++++- 7 files changed, 139 insertions(+), 27 deletions(-) diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 772bfb21..0078a15f 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -73,6 +73,7 @@ pub async fn create_app( create_app_params: tower_api::models::CreateAppParams { schema: None, name: name.to_string(), + // API create expects short_description; CLI/Towerfile expose "description". short_description: Some(description.to_string()), slug: None, is_externally_accessible: None, diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index cf3edcb7..3eac7787 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -46,6 +46,9 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { crate::Error::ApiDescribeAppError { source } => { output::tower_error_and_die(source, "Fetching app details failed") } + crate::Error::ApiUpdateAppError { source } => { + output::tower_error_and_die(source, "Updating app description failed") + } crate::Error::PackageError { source } => { output::package_error(source); std::process::exit(1); @@ -72,16 +75,13 @@ pub async fn deploy_from_dir( let api_config = config.into(); // Add app existence check before proceeding - if let Err(err) = util::apps::ensure_app_exists( + util::apps::ensure_app_exists( &api_config, &towerfile.app.name, &towerfile.app.description, create_app, ) - .await - { - return Err(crate::Error::ApiDescribeAppError { source: err }); - } + .await?; let spec = PackageSpec::from_towerfile(&towerfile); let mut spinner = output::spinner("Building package..."); diff --git a/crates/tower-cmd/src/error.rs b/crates/tower-cmd/src/error.rs index ff5ab381..62b3be50 100644 --- a/crates/tower-cmd/src/error.rs +++ b/crates/tower-cmd/src/error.rs @@ -1,6 +1,6 @@ use snafu::prelude::*; use tower_api::apis::default_api::{ - DeployAppError, DescribeAppError, DescribeRunError, RunAppError, + DeployAppError, DescribeAppError, DescribeRunError, RunAppError, UpdateAppError, }; use tower_telemetry::debug; @@ -97,6 +97,12 @@ pub enum Error { source: tower_api::apis::Error, }, + // API update app error + #[snafu(display("API update app error: {}", source))] + ApiUpdateAppError { + source: tower_api::apis::Error, + }, + // Channel error #[snafu(display("Channel receive error"))] ChannelReceiveError, @@ -179,6 +185,12 @@ impl From> for Error { } } +impl From> for Error { + fn from(source: tower_api::apis::Error) -> Self { + Self::ApiUpdateAppError { source } + } +} + impl From for Error { fn from(_: tokio::sync::oneshot::error::RecvError) -> Self { Self::ChannelReceiveError diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 949785d1..4011e708 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -3,16 +3,18 @@ use http::StatusCode; use promptly::prompt_default; use tower_api::apis::{ configuration::Configuration, - default_api::{self, CreateAppParams, DescribeAppParams}, + default_api::{self, CreateAppParams, DescribeAppParams, DescribeAppSuccess, UpdateAppParams}, +}; +use tower_api::models::{ + CreateAppParams as CreateAppParamsModel, UpdateAppParams as UpdateAppParamsModel, }; -use tower_api::models::CreateAppParams as CreateAppParamsModel; pub async fn ensure_app_exists( api_config: &Configuration, app_name: &str, description: &str, create_app: bool, -) -> Result<(), tower_api::apis::Error> { +) -> Result<(), crate::Error> { // Try to describe the app first (with spinner) let mut spinner = output::spinner("Checking app..."); let describe_result = default_api::describe_app( @@ -28,8 +30,37 @@ pub async fn ensure_app_exists( .await; // If the app exists, return Ok - if describe_result.is_ok() { + if let Ok(response) = describe_result { spinner.success(); + if !description.is_empty() { + if let Some(DescribeAppSuccess::Status200(body)) = response.entity { + if body.app.short_description != description { + let mut update_spinner = output::spinner("Updating app description..."); + let update_result = default_api::update_app( + api_config, + UpdateAppParams { + name: app_name.to_string(), + update_app_params: UpdateAppParamsModel { + schema: None, + description: Some(Some(description.to_string())), + is_externally_accessible: None, + status: None, + subdomain: None, + }, + }, + ) + .await; + + match update_result { + Ok(_) => update_spinner.success(), + Err(err) => { + update_spinner.failure(); + return Err(crate::Error::ApiUpdateAppError { source: err }); + } + } + } + } + } return Ok(()); } @@ -49,7 +80,7 @@ pub async fn ensure_app_exists( // If it's not a 404 error, fail the spinner and return the error if !is_not_found { spinner.failure(); - return Err(err); + return Err(crate::Error::ApiDescribeAppError { source: err }); } // App not found - stop spinner before prompting user @@ -68,7 +99,7 @@ pub async fn ensure_app_exists( // If the user doesn't want to create the app, return the original error if !create_app { - return Err(err); + return Err(crate::Error::ApiDescribeAppError { source: err }); } // Try to create the app (with a new spinner) @@ -79,6 +110,7 @@ pub async fn ensure_app_exists( create_app_params: CreateAppParamsModel { schema: None, name: app_name.to_string(), + // API create expects short_description; CLI/Towerfile expose "description". short_description: Some(description.to_string()), slug: None, is_externally_accessible: None, @@ -97,20 +129,22 @@ pub async fn ensure_app_exists( Err(create_err) => { spinner.failure(); // Convert any creation error to a response error - Err(tower_api::apis::Error::ResponseError( - tower_api::apis::ResponseContent { - tower_trace_id: "".to_string(), - status: match &create_err { - tower_api::apis::Error::ResponseError(resp) => resp.status, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - content: match &create_err { - tower_api::apis::Error::ResponseError(resp) => resp.content.clone(), - _ => create_err.to_string(), + Err(crate::Error::ApiDescribeAppError { + source: tower_api::apis::Error::ResponseError( + tower_api::apis::ResponseContent { + tower_trace_id: "".to_string(), + status: match &create_err { + tower_api::apis::Error::ResponseError(resp) => resp.status, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + content: match &create_err { + tower_api::apis::Error::ResponseError(resp) => resp.content.clone(), + _ => create_err.to_string(), + }, + entity: None, }, - entity: None, - }, - )) + ), + }) } } } diff --git a/tests/integration/features/cli_app_management.feature b/tests/integration/features/cli_app_management.feature index 0add0a6d..c875ece1 100644 --- a/tests/integration/features/cli_app_management.feature +++ b/tests/integration/features/cli_app_management.feature @@ -26,4 +26,12 @@ Feature: CLI App Management When I run "tower apps create --json --name test-cli-app-123 --description 'Test app'" via CLI Then the output should be valid JSON And the JSON should contain the created app information - And the app name should be "test-cli-app-123" \ No newline at end of file + And the app name should be "test-cli-app-123" + And the app description should be "Test app" + + Scenario: CLI deploy updates app description from Towerfile + Given I have a valid Towerfile in the current directory + And I run "tower apps create --json --name {app_name}" via CLI using created app name + When I run "tower deploy --create" via CLI + And I run "tower apps show --json {app_name}" via CLI using created app name + Then the app description should be "A test app" diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index a491e491..c47048d8 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -333,3 +333,35 @@ def step_app_name_should_be(context, expected_name): assert ( actual_name == expected_name ), f"Expected app name '{expected_name}', got '{actual_name}'" + + +@step('the app description should be "{expected_description}"') +def step_app_description_should_be(context, expected_description): + """Verify app description matches expected value""" + data = json.loads(context.cli_output) + candidates = [] + + if "app" in data: + candidates.append(data["app"]) + if "data" in data and "app" in data["data"]: + candidates.append(data["data"]["app"]) + + if not candidates: + candidates.append(data) + + actual_description = None + for candidate in candidates: + if isinstance(candidate, dict): + if "short_description" in candidate: + actual_description = candidate["short_description"] + break + if "description" in candidate: + actual_description = candidate["description"] + break + + assert actual_description is not None, ( + f"Could not find app description in JSON response: {data}" + ) + assert ( + actual_description == expected_description + ), f"Expected description '{expected_description}', got '{actual_description}'" diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index e597d0b4..2fc1565a 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -132,6 +132,10 @@ async def create_app(app_data: Dict[str, Any]): if app_name in mock_apps_db: return {"app": mock_apps_db[app_name]} + description = app_data.get("description") + if description is None: + description = app_data.get("short_description", "") + new_app = { "created_at": datetime.datetime.now().isoformat(), "health_status": "healthy", @@ -148,7 +152,7 @@ async def create_app(app_data: Dict[str, Any]): "running": 0, }, "schedule": None, - "short_description": app_data.get("short_description", ""), + "short_description": description or "", "status": "active", "subdomain": "", "version": None, @@ -171,6 +175,27 @@ async def describe_app(name: str, response: Response): return {"app": app_info, "runs": []} # Simplistic, no runs yet +@app.put("/v1/apps/{name}") +async def update_app(name: str, app_data: Dict[str, Any], response: Response): + app_info = mock_apps_db.get(name) + if not app_info: + response.status_code = 404 + return { + "$schema": "https://api.tower.dev/v1/schemas/ErrorModel.json", + "title": "Not Found", + "status": 404, + "detail": f"App '{name}' not found", + } + + if "description" in app_data: + app_info["short_description"] = app_data.get("description") or "" + elif "short_description" in app_data: + app_info["short_description"] = app_data.get("short_description") or "" + + mock_apps_db[name] = app_info + return {"app": app_info} + + @app.delete("/v1/apps/{name}") async def delete_app(name: str): if name not in mock_apps_db: From 75d431018ee4b83daa7048a694384ebb94f78dcc Mon Sep 17 00:00:00 2001 From: Burak Dede Date: Wed, 21 Jan 2026 22:29:04 +0100 Subject: [PATCH 2/6] Improve #166 fix: description semantics and error handling improvements - Change towerfile desc. to Option to distinguish absence and explicit empty - Add ApiCreateError for more accurate error reporting on app createion - Add UnexpectedApiResponse error to prevent silent description synchronization skip - Simplify error conversion in `ensure_app_exists` --- crates/config/src/towerfile.rs | 9 ++-- crates/tower-cmd/src/deploy.rs | 5 +- crates/tower-cmd/src/error.rs | 18 ++++++- crates/tower-cmd/src/mcp.rs | 2 +- crates/tower-cmd/src/util/apps.rs | 82 +++++++++++++++---------------- 5 files changed, 67 insertions(+), 49 deletions(-) diff --git a/crates/config/src/towerfile.rs b/crates/config/src/towerfile.rs index 648bc00f..aa385e1a 100644 --- a/crates/config/src/towerfile.rs +++ b/crates/config/src/towerfile.rs @@ -28,8 +28,8 @@ pub struct App { #[serde(default)] pub schedule: String, - #[serde(default)] - pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde(default)] pub import_paths: Vec, @@ -58,7 +58,7 @@ impl Towerfile { script: String::from(""), source: vec![], schedule: String::from("0 0 * * *"), - description: String::from(""), + description: None, import_paths: vec![], }, } @@ -147,6 +147,7 @@ mod test { assert_eq!(towerfile.app.script, "./script.py"); assert_eq!(towerfile.app.source, vec!["*.py"]); assert_eq!(towerfile.app.schedule, "0 0 * * *"); + assert_eq!(towerfile.app.description, None); } #[test] @@ -163,6 +164,7 @@ mod test { assert_eq!(towerfile.app.script, "./script.py"); assert_eq!(towerfile.app.source, vec!["*.py"]); assert_eq!(towerfile.app.schedule, ""); + assert_eq!(towerfile.app.description, None); } #[test] @@ -316,6 +318,7 @@ default = "value2" assert_eq!(towerfile.app.name, reparsed.app.name); assert_eq!(towerfile.app.script, reparsed.app.script); assert_eq!(towerfile.app.source, reparsed.app.source); + assert_eq!(towerfile.app.description, reparsed.app.description); assert_eq!(towerfile.parameters.len(), reparsed.parameters.len()); assert_eq!(towerfile.parameters[0].name, reparsed.parameters[0].name); } diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index 3eac7787..65215650 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -43,6 +43,9 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { crate::Error::ApiDeployError { source } => { output::tower_error_and_die(source, "Deploying app failed") } + crate::Error::ApiCreateAppError { source } => { + output::tower_error_and_die(source, "Creating app failed") + } crate::Error::ApiDescribeAppError { source } => { output::tower_error_and_die(source, "Fetching app details failed") } @@ -78,7 +81,7 @@ pub async fn deploy_from_dir( util::apps::ensure_app_exists( &api_config, &towerfile.app.name, - &towerfile.app.description, + towerfile.app.description.as_deref(), create_app, ) .await?; diff --git a/crates/tower-cmd/src/error.rs b/crates/tower-cmd/src/error.rs index 62b3be50..df4f7d90 100644 --- a/crates/tower-cmd/src/error.rs +++ b/crates/tower-cmd/src/error.rs @@ -1,6 +1,7 @@ use snafu::prelude::*; use tower_api::apis::default_api::{ - DeployAppError, DescribeAppError, DescribeRunError, RunAppError, UpdateAppError, + CreateAppError, DeployAppError, DescribeAppError, DescribeRunError, RunAppError, + UpdateAppError, }; use tower_telemetry::debug; @@ -91,6 +92,12 @@ pub enum Error { source: tower_api::apis::Error, }, + // API create app error + #[snafu(display("API create app error: {}", source))] + ApiCreateAppError { + source: tower_api::apis::Error, + }, + // API describe app error #[snafu(display("API describe app error: {}", source))] ApiDescribeAppError { @@ -103,6 +110,9 @@ pub enum Error { source: tower_api::apis::Error, }, + #[snafu(display("Unexpected API response: {}", message))] + UnexpectedApiResponse { message: String }, + // Channel error #[snafu(display("Channel receive error"))] ChannelReceiveError, @@ -179,6 +189,12 @@ impl From> for Error { } } +impl From> for Error { + fn from(source: tower_api::apis::Error) -> Self { + Self::ApiCreateAppError { source } + } +} + impl From> for Error { fn from(source: tower_api::apis::Error) -> Self { Self::ApiDescribeAppError { source } diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index af0a5b27..95d9440d 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -734,7 +734,7 @@ impl TowerService { towerfile.app.script = script; } if let Some(description) = request.description { - towerfile.app.description = description; + towerfile.app.description = Some(description); } if let Some(source) = request.source { towerfile.app.source = source; diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 4011e708..383d613f 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -1,18 +1,20 @@ use crate::output; -use http::StatusCode; use promptly::prompt_default; use tower_api::apis::{ configuration::Configuration, - default_api::{self, CreateAppParams, DescribeAppParams, DescribeAppSuccess, UpdateAppParams}, + default_api::{ + self, CreateAppParams, DescribeAppParams, DescribeAppSuccess, UpdateAppParams, + }, }; use tower_api::models::{ CreateAppParams as CreateAppParamsModel, UpdateAppParams as UpdateAppParamsModel, }; +use tower_telemetry::debug; pub async fn ensure_app_exists( api_config: &Configuration, app_name: &str, - description: &str, + description: Option<&str>, create_app: bool, ) -> Result<(), crate::Error> { // Try to describe the app first (with spinner) @@ -32,31 +34,39 @@ pub async fn ensure_app_exists( // If the app exists, return Ok if let Ok(response) = describe_result { spinner.success(); - if !description.is_empty() { - if let Some(DescribeAppSuccess::Status200(body)) = response.entity { - if body.app.short_description != description { - let mut update_spinner = output::spinner("Updating app description..."); - let update_result = default_api::update_app( - api_config, - UpdateAppParams { - name: app_name.to_string(), - update_app_params: UpdateAppParamsModel { - schema: None, - description: Some(Some(description.to_string())), - is_externally_accessible: None, - status: None, - subdomain: None, - }, + if let Some(description) = description { + let body = match response.entity { + Some(DescribeAppSuccess::Status200(body)) => body, + other => { + debug!("unexpected describe app response entity: {:?}", other); + return Err(crate::Error::UnexpectedApiResponse { + message: "Describe app response missing payload".to_string(), + }); + } + }; + + if body.app.short_description != description { + let mut update_spinner = output::spinner("Updating app description..."); + let update_result = default_api::update_app( + api_config, + UpdateAppParams { + name: app_name.to_string(), + update_app_params: UpdateAppParamsModel { + schema: None, + description: Some(Some(description.to_string())), + is_externally_accessible: None, + status: None, + subdomain: None, }, - ) - .await; + }, + ) + .await; - match update_result { - Ok(_) => update_spinner.success(), - Err(err) => { - update_spinner.failure(); - return Err(crate::Error::ApiUpdateAppError { source: err }); - } + match update_result { + Ok(_) => update_spinner.success(), + Err(err) => { + update_spinner.failure(); + return Err(crate::Error::ApiUpdateAppError { source: err }); } } } @@ -111,7 +121,7 @@ pub async fn ensure_app_exists( schema: None, name: app_name.to_string(), // API create expects short_description; CLI/Towerfile expose "description". - short_description: Some(description.to_string()), + short_description: description.map(|desc| desc.to_string()), slug: None, is_externally_accessible: None, subdomain: None, @@ -128,22 +138,8 @@ pub async fn ensure_app_exists( } Err(create_err) => { spinner.failure(); - // Convert any creation error to a response error - Err(crate::Error::ApiDescribeAppError { - source: tower_api::apis::Error::ResponseError( - tower_api::apis::ResponseContent { - tower_trace_id: "".to_string(), - status: match &create_err { - tower_api::apis::Error::ResponseError(resp) => resp.status, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - content: match &create_err { - tower_api::apis::Error::ResponseError(resp) => resp.content.clone(), - _ => create_err.to_string(), - }, - entity: None, - }, - ), + Err(crate::Error::ApiCreateAppError { + source: create_err, }) } } From 29588f6a363a416c0284b4cf19ba41cf96f37662 Mon Sep 17 00:00:00 2001 From: Burak Dede Date: Thu, 22 Jan 2026 22:30:33 +0100 Subject: [PATCH 3/6] Simplify description update: always update on deploy, no diff check Change from diff-checking approach to "latest wins" semantics; - Always update description on deploy when present (no comparison) - Skip update for empty/None (preserves server state) - Remove spinner and entity validation (simpler, more resilient) --- crates/tower-cmd/src/error.rs | 3 -- crates/tower-cmd/src/util/apps.rs | 39 ++++++------------- .../features/cli_app_management.feature | 20 ++++++++++ tests/integration/features/steps/mcp_steps.py | 33 ++++++++++++++++ 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/crates/tower-cmd/src/error.rs b/crates/tower-cmd/src/error.rs index df4f7d90..f92be5f8 100644 --- a/crates/tower-cmd/src/error.rs +++ b/crates/tower-cmd/src/error.rs @@ -110,9 +110,6 @@ pub enum Error { source: tower_api::apis::Error, }, - #[snafu(display("Unexpected API response: {}", message))] - UnexpectedApiResponse { message: String }, - // Channel error #[snafu(display("Channel receive error"))] ChannelReceiveError, diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 383d613f..ebeef944 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -2,14 +2,11 @@ use crate::output; use promptly::prompt_default; use tower_api::apis::{ configuration::Configuration, - default_api::{ - self, CreateAppParams, DescribeAppParams, DescribeAppSuccess, UpdateAppParams, - }, + default_api::{self, CreateAppParams, DescribeAppParams, UpdateAppParams}, }; use tower_api::models::{ CreateAppParams as CreateAppParamsModel, UpdateAppParams as UpdateAppParamsModel, }; -use tower_telemetry::debug; pub async fn ensure_app_exists( api_config: &Configuration, @@ -31,46 +28,32 @@ pub async fn ensure_app_exists( ) .await; - // If the app exists, return Ok - if let Ok(response) = describe_result { + // If the app exists, update description if provided (no diff check, latest wins) + if describe_result.is_ok() { spinner.success(); - if let Some(description) = description { - let body = match response.entity { - Some(DescribeAppSuccess::Status200(body)) => body, - other => { - debug!("unexpected describe app response entity: {:?}", other); - return Err(crate::Error::UnexpectedApiResponse { - message: "Describe app response missing payload".to_string(), - }); - } - }; - if body.app.short_description != description { - let mut update_spinner = output::spinner("Updating app description..."); - let update_result = default_api::update_app( + if let Some(desc) = description { + if !desc.trim().is_empty() { + if let Err(err) = default_api::update_app( api_config, UpdateAppParams { name: app_name.to_string(), update_app_params: UpdateAppParamsModel { schema: None, - description: Some(Some(description.to_string())), + description: Some(Some(desc.to_string())), is_externally_accessible: None, status: None, subdomain: None, }, }, ) - .await; - - match update_result { - Ok(_) => update_spinner.success(), - Err(err) => { - update_spinner.failure(); - return Err(crate::Error::ApiUpdateAppError { source: err }); - } + .await + { + return Err(crate::Error::ApiUpdateAppError { source: err }); } } } + return Ok(()); } diff --git a/tests/integration/features/cli_app_management.feature b/tests/integration/features/cli_app_management.feature index c875ece1..520f6040 100644 --- a/tests/integration/features/cli_app_management.feature +++ b/tests/integration/features/cli_app_management.feature @@ -35,3 +35,23 @@ Feature: CLI App Management When I run "tower deploy --create" via CLI And I run "tower apps show --json {app_name}" via CLI using created app name Then the app description should be "A test app" + + Scenario: CLI deploy --create creates app with description from Towerfile + Given I have a valid Towerfile in the current directory + When I run "tower deploy --create" via CLI + And I run "tower apps show --json {app_name}" via CLI using created app name + Then the app description should be "A test app" + + Scenario: CLI deploy with empty description does not update app + Given I have a Towerfile with empty description in the current directory + And I run "tower apps create --json --name {app_name} --description 'Original description'" via CLI using created app name + When I run "tower deploy --create" via CLI + And I run "tower apps show --json {app_name}" via CLI using created app name + Then the app description should be "Original description" + + Scenario: CLI deploy with no description field does not update app + Given I have a Towerfile with no description in the current directory + And I run "tower apps create --json --name {app_name} --description 'Original description'" via CLI using created app name + When I run "tower deploy --create" via CLI + And I run "tower apps show --json {app_name}" via CLI using created app name + Then the app description should be "Original description" diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 1fa07c3d..1e39f123 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -212,6 +212,39 @@ def step_create_valid_towerfile(context): create_towerfile(context) +@given("I have a Towerfile with empty description in the current directory") +def step_create_towerfile_empty_description(context): + create_towerfile(context, description="") + + +@given("I have a Towerfile with no description in the current directory") +def step_create_towerfile_no_description(context): + """Create a Towerfile without a description field""" + from pathlib import Path + + app_name = unique_app_name(context, "hello-world", force_new=True) + context.app_name = app_name + + template_dir = Path(__file__).parents[2] / "templates" + + # Create Towerfile without description field + towerfile_content = f"""[app] +name = "{app_name}" +script = "./hello.py" +source = ["./hello.py"] + +[build] +python = "3.11" +""" + Path("Towerfile").write_text(towerfile_content) + + # Copy script file + script_template = template_dir / "hello.py" + if script_template.exists(): + import shutil + shutil.copy(script_template, "hello.py") + + @given("I have a simple hello world application") def step_create_hello_world_app(context): create_towerfile(context) From 94f1f842537789094cdc5fc11b9acf689d02d556 Mon Sep 17 00:00:00 2001 From: Burak Dede Date: Thu, 22 Jan 2026 23:00:21 +0100 Subject: [PATCH 4/6] simplify: don't block deploy during description update --- crates/tower-cmd/src/deploy.rs | 3 --- crates/tower-cmd/src/error.rs | 13 --------- crates/tower-cmd/src/util/apps.rs | 5 ++-- .../features/cli_app_management.feature | 7 +++++ tests/integration/features/steps/mcp_steps.py | 27 +++++++++++++++++++ tests/mock-api-server/main.py | 10 +++++++ 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index 65215650..15baadf7 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -49,9 +49,6 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { crate::Error::ApiDescribeAppError { source } => { output::tower_error_and_die(source, "Fetching app details failed") } - crate::Error::ApiUpdateAppError { source } => { - output::tower_error_and_die(source, "Updating app description failed") - } crate::Error::PackageError { source } => { output::package_error(source); std::process::exit(1); diff --git a/crates/tower-cmd/src/error.rs b/crates/tower-cmd/src/error.rs index f92be5f8..777deec5 100644 --- a/crates/tower-cmd/src/error.rs +++ b/crates/tower-cmd/src/error.rs @@ -1,7 +1,6 @@ use snafu::prelude::*; use tower_api::apis::default_api::{ CreateAppError, DeployAppError, DescribeAppError, DescribeRunError, RunAppError, - UpdateAppError, }; use tower_telemetry::debug; @@ -104,12 +103,6 @@ pub enum Error { source: tower_api::apis::Error, }, - // API update app error - #[snafu(display("API update app error: {}", source))] - ApiUpdateAppError { - source: tower_api::apis::Error, - }, - // Channel error #[snafu(display("Channel receive error"))] ChannelReceiveError, @@ -198,12 +191,6 @@ impl From> for Error { } } -impl From> for Error { - fn from(source: tower_api::apis::Error) -> Self { - Self::ApiUpdateAppError { source } - } -} - impl From for Error { fn from(_: tokio::sync::oneshot::error::RecvError) -> Self { Self::ChannelReceiveError diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index ebeef944..f6ac4b36 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -34,7 +34,8 @@ pub async fn ensure_app_exists( if let Some(desc) = description { if !desc.trim().is_empty() { - if let Err(err) = default_api::update_app( + // Description is metadata - warn on failure but don't block deploy + if let Err(_err) = default_api::update_app( api_config, UpdateAppParams { name: app_name.to_string(), @@ -49,7 +50,7 @@ pub async fn ensure_app_exists( ) .await { - return Err(crate::Error::ApiUpdateAppError { source: err }); + eprintln!("Warning: Failed to update app description"); } } } diff --git a/tests/integration/features/cli_app_management.feature b/tests/integration/features/cli_app_management.feature index 520f6040..09ac88d6 100644 --- a/tests/integration/features/cli_app_management.feature +++ b/tests/integration/features/cli_app_management.feature @@ -55,3 +55,10 @@ Feature: CLI App Management When I run "tower deploy --create" via CLI And I run "tower apps show --json {app_name}" via CLI using created app name Then the app description should be "Original description" + + Scenario: CLI deploy succeeds even when description update fails + Given I have a Towerfile for update-fail-test app in the current directory + And I run "tower apps create --json --name update-fail-test" via CLI + When I run "tower deploy --create" via CLI + Then the output should show "Version" + And the output should show "deployed to Tower" diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 1e39f123..3b35e71a 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -245,6 +245,33 @@ def step_create_towerfile_no_description(context): shutil.copy(script_template, "hello.py") +@given('I have a Towerfile for update-fail-test app in the current directory') +def step_create_towerfile_update_fail(context): + """Create a Towerfile for the update-fail-test app""" + from pathlib import Path + + context.app_name = "update-fail-test" + template_dir = Path(__file__).parents[2] / "templates" + + # Create Towerfile with description that will trigger update failure in mock + towerfile_content = """[app] +name = "update-fail-test" +script = "./hello.py" +description = "This will fail to update" +source = ["./hello.py"] + +[build] +python = "3.11" +""" + Path("Towerfile").write_text(towerfile_content) + + # Copy script file + script_template = template_dir / "hello.py" + if script_template.exists(): + import shutil + shutil.copy(script_template, "hello.py") + + @given("I have a simple hello world application") def step_create_hello_world_app(context): create_towerfile(context) diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 2fc1565a..3597949c 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -177,6 +177,16 @@ async def describe_app(name: str, response: Response): @app.put("/v1/apps/{name}") async def update_app(name: str, app_data: Dict[str, Any], response: Response): + # Simulate update failure for test app to verify deploy doesn't fail + if name == "update-fail-test": + response.status_code = 500 + return { + "$schema": "https://api.tower.dev/v1/schemas/ErrorModel.json", + "title": "Internal Server Error", + "status": 500, + "detail": "Simulated update failure for testing", + } + app_info = mock_apps_db.get(name) if not app_info: response.status_code = 404 From 674785731fdb2df92c6058fbc3674d41c2f1ade5 Mon Sep 17 00:00:00 2001 From: Burak Dede Date: Thu, 22 Jan 2026 23:23:49 +0100 Subject: [PATCH 5/6] scope description field to only app create flows and remove from deploy --- crates/tower-cmd/src/util/apps.rs | 32 +--------- .../features/cli_app_management.feature | 28 --------- tests/integration/features/steps/mcp_steps.py | 60 ------------------- tests/mock-api-server/main.py | 10 ---- 4 files changed, 3 insertions(+), 127 deletions(-) diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index f6ac4b36..38012bae 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -2,11 +2,9 @@ use crate::output; use promptly::prompt_default; use tower_api::apis::{ configuration::Configuration, - default_api::{self, CreateAppParams, DescribeAppParams, UpdateAppParams}, -}; -use tower_api::models::{ - CreateAppParams as CreateAppParamsModel, UpdateAppParams as UpdateAppParamsModel, + default_api::{self, CreateAppParams, DescribeAppParams}, }; +use tower_api::models::CreateAppParams as CreateAppParamsModel; pub async fn ensure_app_exists( api_config: &Configuration, @@ -28,33 +26,9 @@ pub async fn ensure_app_exists( ) .await; - // If the app exists, update description if provided (no diff check, latest wins) + // If the app exists, return Ok (description is create-only). if describe_result.is_ok() { spinner.success(); - - if let Some(desc) = description { - if !desc.trim().is_empty() { - // Description is metadata - warn on failure but don't block deploy - if let Err(_err) = default_api::update_app( - api_config, - UpdateAppParams { - name: app_name.to_string(), - update_app_params: UpdateAppParamsModel { - schema: None, - description: Some(Some(desc.to_string())), - is_externally_accessible: None, - status: None, - subdomain: None, - }, - }, - ) - .await - { - eprintln!("Warning: Failed to update app description"); - } - } - } - return Ok(()); } diff --git a/tests/integration/features/cli_app_management.feature b/tests/integration/features/cli_app_management.feature index 09ac88d6..fbadbe34 100644 --- a/tests/integration/features/cli_app_management.feature +++ b/tests/integration/features/cli_app_management.feature @@ -29,36 +29,8 @@ Feature: CLI App Management And the app name should be "test-cli-app-123" And the app description should be "Test app" - Scenario: CLI deploy updates app description from Towerfile - Given I have a valid Towerfile in the current directory - And I run "tower apps create --json --name {app_name}" via CLI using created app name - When I run "tower deploy --create" via CLI - And I run "tower apps show --json {app_name}" via CLI using created app name - Then the app description should be "A test app" - Scenario: CLI deploy --create creates app with description from Towerfile Given I have a valid Towerfile in the current directory When I run "tower deploy --create" via CLI And I run "tower apps show --json {app_name}" via CLI using created app name Then the app description should be "A test app" - - Scenario: CLI deploy with empty description does not update app - Given I have a Towerfile with empty description in the current directory - And I run "tower apps create --json --name {app_name} --description 'Original description'" via CLI using created app name - When I run "tower deploy --create" via CLI - And I run "tower apps show --json {app_name}" via CLI using created app name - Then the app description should be "Original description" - - Scenario: CLI deploy with no description field does not update app - Given I have a Towerfile with no description in the current directory - And I run "tower apps create --json --name {app_name} --description 'Original description'" via CLI using created app name - When I run "tower deploy --create" via CLI - And I run "tower apps show --json {app_name}" via CLI using created app name - Then the app description should be "Original description" - - Scenario: CLI deploy succeeds even when description update fails - Given I have a Towerfile for update-fail-test app in the current directory - And I run "tower apps create --json --name update-fail-test" via CLI - When I run "tower deploy --create" via CLI - Then the output should show "Version" - And the output should show "deployed to Tower" diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 3b35e71a..1fa07c3d 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -212,66 +212,6 @@ def step_create_valid_towerfile(context): create_towerfile(context) -@given("I have a Towerfile with empty description in the current directory") -def step_create_towerfile_empty_description(context): - create_towerfile(context, description="") - - -@given("I have a Towerfile with no description in the current directory") -def step_create_towerfile_no_description(context): - """Create a Towerfile without a description field""" - from pathlib import Path - - app_name = unique_app_name(context, "hello-world", force_new=True) - context.app_name = app_name - - template_dir = Path(__file__).parents[2] / "templates" - - # Create Towerfile without description field - towerfile_content = f"""[app] -name = "{app_name}" -script = "./hello.py" -source = ["./hello.py"] - -[build] -python = "3.11" -""" - Path("Towerfile").write_text(towerfile_content) - - # Copy script file - script_template = template_dir / "hello.py" - if script_template.exists(): - import shutil - shutil.copy(script_template, "hello.py") - - -@given('I have a Towerfile for update-fail-test app in the current directory') -def step_create_towerfile_update_fail(context): - """Create a Towerfile for the update-fail-test app""" - from pathlib import Path - - context.app_name = "update-fail-test" - template_dir = Path(__file__).parents[2] / "templates" - - # Create Towerfile with description that will trigger update failure in mock - towerfile_content = """[app] -name = "update-fail-test" -script = "./hello.py" -description = "This will fail to update" -source = ["./hello.py"] - -[build] -python = "3.11" -""" - Path("Towerfile").write_text(towerfile_content) - - # Copy script file - script_template = template_dir / "hello.py" - if script_template.exists(): - import shutil - shutil.copy(script_template, "hello.py") - - @given("I have a simple hello world application") def step_create_hello_world_app(context): create_towerfile(context) diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 3597949c..2fc1565a 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -177,16 +177,6 @@ async def describe_app(name: str, response: Response): @app.put("/v1/apps/{name}") async def update_app(name: str, app_data: Dict[str, Any], response: Response): - # Simulate update failure for test app to verify deploy doesn't fail - if name == "update-fail-test": - response.status_code = 500 - return { - "$schema": "https://api.tower.dev/v1/schemas/ErrorModel.json", - "title": "Internal Server Error", - "status": 500, - "detail": "Simulated update failure for testing", - } - app_info = mock_apps_db.get(name) if not app_info: response.status_code = 404 From 1214bab6a70f7ba835e1e4bf80fe320d59f1cad6 Mon Sep 17 00:00:00 2001 From: Burak Dede Date: Fri, 23 Jan 2026 15:22:40 +0100 Subject: [PATCH 6/6] fix linter/formatting errors --- tests/integration/features/steps/cli_steps.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index c47048d8..53274330 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -359,9 +359,9 @@ def step_app_description_should_be(context, expected_description): actual_description = candidate["description"] break - assert actual_description is not None, ( - f"Could not find app description in JSON response: {data}" - ) + assert ( + actual_description is not None + ), f"Could not find app description in JSON response: {data}" assert ( actual_description == expected_description ), f"Expected description '{expected_description}', got '{actual_description}'"