diff --git a/Cargo.lock b/Cargo.lock index 69082ec6..0c1bd888 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "config" -version = "0.3.51" +version = "0.3.52" dependencies = [ "base64", "chrono", @@ -598,7 +598,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto" -version = "0.3.51" +version = "0.3.52" dependencies = [ "aes-gcm", "base64", @@ -3316,7 +3316,7 @@ dependencies = [ [[package]] name = "testutils" -version = "0.3.51" +version = "0.3.52" dependencies = [ "pem", "rsa", @@ -3586,7 +3586,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.51" +version = "0.3.52" dependencies = [ "config", "pyo3", @@ -3614,7 +3614,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.51" +version = "0.3.52" dependencies = [ "reqwest", "serde", @@ -3626,7 +3626,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.51" +version = "0.3.52" dependencies = [ "axum", "bytes", @@ -3696,7 +3696,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.51" +version = "0.3.52" dependencies = [ "async-compression", "config", @@ -3714,7 +3714,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.51" +version = "0.3.52" dependencies = [ "async-trait", "chrono", @@ -3737,7 +3737,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.51" +version = "0.3.52" dependencies = [ "tracing", "tracing-appender", @@ -3746,7 +3746,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.51" +version = "0.3.52" dependencies = [ "async-compression", "async_zip", @@ -3764,7 +3764,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.51" +version = "0.3.52" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index efc53368..9ed835ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.51" +version = "0.3.52" description = "Tower is the best way to host Python data apps in production" rust-version = "1.81" authors = ["Brad Heller "] diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 9a3ffaf9..f4b0a3c0 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -980,3 +980,35 @@ impl ResponseEntity for tower_api::apis::default_api::DeleteScheduleSuccess { } } } + +pub async fn cancel_run( + config: &Config, + name: &str, + seq: i64, +) -> Result< + tower_api::models::CancelRunResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::CancelRunParams { + name: name.to_string(), + seq, + }; + + unwrap_api_response(tower_api::apis::default_api::cancel_run( + api_config, params, + )) + .await +} + +impl ResponseEntity for tower_api::apis::default_api::CancelRunSuccess { + type Data = tower_api::models::CancelRunResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index 319ded8a..1fd816f0 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -76,6 +76,24 @@ pub fn apps_cmd() -> Command { ) .about("Delete an app in Tower"), ) + .subcommand( + Command::new("cancel") + .arg( + Arg::new("app_name") + .value_parser(value_parser!(String)) + .index(1) + .required(true) + .help("Name of the app"), + ) + .arg( + Arg::new("run_number") + .value_parser(value_parser!(i64)) + .index(2) + .required(true) + .help("Run number to cancel"), + ) + .about("Cancel a running app run"), + ) } pub async fn do_logs(config: Config, cmd: &ArgMatches) { @@ -223,6 +241,26 @@ pub async fn do_delete(config: Config, cmd: &ArgMatches) { output::with_spinner("Deleting app", api::delete_app(&config, name)).await; } +pub async fn do_cancel(config: Config, cmd: &ArgMatches) { + let name = cmd + .get_one::("app_name") + .expect("app_name should be required"); + let seq = cmd + .get_one::("run_number") + .copied() + .expect("run_number should be required"); + + let response = + output::with_spinner("Cancelling run", api::cancel_run(&config, name, seq)).await; + + let run = &response.run; + let status = format!("{:?}", run.status); + output::success_with_data( + &format!("Run #{} for '{}' cancelled (status: {})", seq, name, status), + Some(response), + ); +} + async fn latest_run_number(config: &Config, name: &str) -> i64 { match api::describe_app(config, name).await { Ok(resp) => resp.runs @@ -715,4 +753,30 @@ mod tests { assert!(should_emit_line(&mut last_line_num, 4)); assert_eq!(last_line_num, Some(4)); } + + #[test] + fn test_cancel_args_parsing() { + let matches = apps_cmd() + .try_get_matches_from(["apps", "cancel", "my-app", "42"]) + .unwrap(); + let (cmd, sub_matches) = matches.subcommand().unwrap(); + + assert_eq!(cmd, "cancel"); + assert_eq!( + sub_matches + .get_one::("app_name") + .map(|s| s.as_str()), + Some("my-app") + ); + assert_eq!(sub_matches.get_one::("run_number"), Some(&42)); + } + + #[test] + fn test_cancel_requires_both_args() { + let result = apps_cmd().try_get_matches_from(["apps", "cancel", "my-app"]); + assert!(result.is_err()); + + let result = apps_cmd().try_get_matches_from(["apps", "cancel"]); + assert!(result.is_err()); + } } diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index a1086e28..43de9c31 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -124,6 +124,7 @@ impl App { Some(("show", args)) => apps::do_show(sessionized_config, args).await, Some(("logs", args)) => apps::do_logs(sessionized_config, args).await, Some(("delete", args)) => apps::do_delete(sessionized_config, args).await, + Some(("cancel", args)) => apps::do_cancel(sessionized_config, args).await, _ => { apps::apps_cmd().print_help().unwrap(); std::process::exit(2); diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index ad6043ae..c2c18e47 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -98,6 +98,12 @@ struct GenerateTowerfileRequest { script_path: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +struct CancelRunRequest { + name: String, + run_number: String, +} + #[derive(Debug, Deserialize, JsonSchema)] struct ScheduleRequest { app_name: String, @@ -481,6 +487,28 @@ impl TowerService { } } + #[tool(description = "Cancel a running Tower app run")] + async fn tower_apps_cancel( + &self, + Parameters(request): Parameters, + ) -> Result { + let seq: i64 = request + .run_number + .parse() + .map_err(|_| McpError::invalid_params("run_number must be a number", None))?; + + match api::cancel_run(&self.config, &request.name, seq).await { + Ok(response) => { + let status = format!("{:?}", response.run.status); + Self::text_success(format!( + "Cancelled run #{} for '{}' (status: {})", + seq, request.name, status + )) + } + Err(e) => Self::error_result("Failed to cancel run", e), + } + } + #[tool(description = "List secrets in your Tower account (shows only previews for security)")] async fn tower_secrets_list( &self, diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index b4b6eb46..2dfcd709 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -298,7 +298,6 @@ fn output_response_content_error(err: ResponseContent) { match err.status { StatusCode::CONFLICT => { - error("There was a conflict while trying to do that!"); output_full_error_details(&error_model); } StatusCode::UNPROCESSABLE_ENTITY => { @@ -316,7 +315,10 @@ fn output_response_content_error(err: ResponseContent) { ); } _ => { - error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit."); + if error_model.detail.is_none() && error_model.errors.is_none() { + error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit."); + } + output_full_error_details(&error_model); } } } diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index b0a79ea7..03a10e5c 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -184,6 +184,7 @@ where let session = config.session.as_ref().ok_or(Error::NoSession)?; env_vars.insert("TOWER_JWT".to_string(), session.token.jwt.to_string()); + env_vars.insert("TOWER__RUNTIME__IS_LOCAL".to_string(), "true".to_string()); // Load the Towerfile let towerfile_path = path.join("Towerfile"); diff --git a/pyproject.toml b/pyproject.toml index 9becce92..fdf30a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.51" +version = "0.3.52" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }] readme = "README.md" diff --git a/src/tower/info/__init__.py b/src/tower/info/__init__.py index 8aede614..ce5ee9d2 100644 --- a/src/tower/info/__init__.py +++ b/src/tower/info/__init__.py @@ -83,7 +83,18 @@ def is_cloud_run() -> bool: Returns: bool: True if running in Tower cloud, otherwise False. """ - val = _get_runtime_env_variable("IS_TOWER_MANAGED", "") + val = _get_runtime_env_variable("IS_TOWER_MANAGED", "false") + return _strtobool(val) + + +def is_local() -> bool: + """ + Check if the current run is executing locally via the Tower CLI. + + Returns: + bool: True if running locally, otherwise False. + """ + val = _get_runtime_env_variable("IS_LOCAL", "false") return _strtobool(val) diff --git a/tests/integration/features/cli_runs.feature b/tests/integration/features/cli_runs.feature index 27f272b9..b2e4c384 100644 --- a/tests/integration/features/cli_runs.feature +++ b/tests/integration/features/cli_runs.feature @@ -45,6 +45,13 @@ Feature: CLI Run Commands And I run "tower apps logs --follow {app_name}#{run_number}" via CLI using created app name and run number Then the output should show "Hello, World!" + Scenario: CLI apps cancel should cancel a running run + Given I have a valid Towerfile in the current directory + When I run "tower deploy --create" via CLI + And I run "tower run --detached" via CLI and capture run number + And I run "tower apps cancel {app_name} {run_number}" via CLI using created app name and run number + Then the output should show "cancelled" + Scenario: CLI apps logs follow should display warnings Given I have a simple hello world application named "app-logs-warning" When I run "tower deploy --create" via CLI diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 11f2e2ba..b8ea7762 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -338,6 +338,24 @@ async def describe_run(name: str, seq: int): ) +@app.post("/v1/apps/{name}/runs/{seq}") +async def cancel_run(name: str, seq: int): + """Mock endpoint for cancelling a run.""" + if name not in mock_apps_db: + raise HTTPException(status_code=404, detail=f"App '{name}' not found") + + for run_id, run_data in mock_runs_db.items(): + if run_data["app_name"] == name and run_data["number"] == seq: + run_data["status"] = "cancelled" + run_data["status_group"] = "successful" + run_data["cancelled_at"] = now_iso() + return {"run": run_data} + + raise HTTPException( + status_code=404, detail=f"Run sequence {seq} not found for app '{name}'" + ) + + # Placeholder for /secrets endpoints @app.get("/v1/secrets") async def list_secrets(): diff --git a/tests/tower/test_info.py b/tests/tower/test_info.py new file mode 100644 index 00000000..ccb863fd --- /dev/null +++ b/tests/tower/test_info.py @@ -0,0 +1,214 @@ +import pytest +from tower import info + + +class TestIsCloudRun: + def test_returns_true_when_set_to_true(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "true") + assert info.is_cloud_run() is True + + def test_returns_true_when_set_to_1(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "1") + assert info.is_cloud_run() is True + + def test_returns_true_when_set_to_yes(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "yes") + assert info.is_cloud_run() is True + + def test_returns_true_case_insensitive(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "True") + assert info.is_cloud_run() is True + + def test_returns_false_when_set_to_false(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "false") + assert info.is_cloud_run() is False + + def test_returns_false_when_set_to_0(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "0") + assert info.is_cloud_run() is False + + def test_returns_false_when_set_to_no(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "no") + assert info.is_cloud_run() is False + + def test_returns_false_when_env_var_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__IS_TOWER_MANAGED", raising=False) + assert info.is_cloud_run() is False + + def test_raises_on_invalid_value(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_TOWER_MANAGED", "invalid") + with pytest.raises(ValueError, match="invalid truth value"): + info.is_cloud_run() + + +class TestIsLocal: + def test_returns_true_when_set_to_true(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "true") + assert info.is_local() is True + + def test_returns_true_when_set_to_1(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "1") + assert info.is_local() is True + + def test_returns_true_when_set_to_yes(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "yes") + assert info.is_local() is True + + def test_returns_true_case_insensitive(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "True") + assert info.is_local() is True + + def test_returns_false_when_set_to_false(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "false") + assert info.is_local() is False + + def test_returns_false_when_set_to_0(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "0") + assert info.is_local() is False + + def test_returns_false_when_set_to_no(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "no") + assert info.is_local() is False + + def test_returns_false_when_env_var_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__IS_LOCAL", raising=False) + assert info.is_local() is False + + def test_raises_on_invalid_value(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_LOCAL", "invalid") + with pytest.raises(ValueError, match="invalid truth value"): + info.is_local() + + +class TestIsManualRun: + def test_returns_true_when_set_to_true(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_MANUAL_RUN", "true") + assert info.is_manual_run() is True + + def test_returns_false_when_set_to_false(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__IS_MANUAL_RUN", "false") + assert info.is_manual_run() is False + + def test_returns_false_when_env_var_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__IS_MANUAL_RUN", raising=False) + assert info.is_manual_run() is False + + +class TestIsScheduledRun: + def test_returns_true_when_schedule_name_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__SCHEDULE_NAME", "daily-job") + assert info.is_scheduled_run() is True + + def test_returns_false_when_schedule_name_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__SCHEDULE_NAME", raising=False) + assert info.is_scheduled_run() is False + + +class TestScheduleName: + def test_returns_name_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__SCHEDULE_NAME", "daily-job") + assert info.schedule_name() == "daily-job" + + def test_returns_none_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__SCHEDULE_NAME", raising=False) + assert info.schedule_name() is None + + +class TestScheduleId: + def test_returns_id_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__SCHEDULE_ID", "sched-123") + assert info.schedule_id() == "sched-123" + + def test_returns_none_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__SCHEDULE_ID", raising=False) + assert info.schedule_id() is None + + +class TestRunId: + def test_returns_id_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__RUN_ID", "run-456") + assert info.run_id() == "run-456" + + def test_returns_none_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__RUN_ID", raising=False) + assert info.run_id() is None + + +class TestRunNumber: + def test_returns_number_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__RUN_NUMBER", "42") + assert info.run_number() == 42 + + def test_returns_none_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__RUN_NUMBER", raising=False) + assert info.run_number() is None + + +class TestHostname: + def test_returns_hostname_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__HOST", "my-host.example.com") + assert info.hostname() == "my-host.example.com" + + def test_returns_none_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__HOST", raising=False) + assert info.hostname() is None + + +class TestPort: + def test_returns_port_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__PORT", "8080") + assert info.port() == 8080 + + def test_returns_none_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__PORT", raising=False) + assert info.port() is None + + +class TestRunnerName: + def test_returns_name_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__RUNNER_NAME", "my-runner") + assert info.runner_name() == "my-runner" + + def test_returns_empty_string_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__RUNNER_NAME", raising=False) + assert info.runner_name() == "" + + +class TestRunnerId: + def test_returns_id_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__RUNNER_ID", "runner-789") + assert info.runner_id() == "runner-789" + + def test_returns_empty_string_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__RUNNER_ID", raising=False) + assert info.runner_id() == "" + + +class TestAppName: + def test_returns_name_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__APP_NAME", "my-app") + assert info.app_name() == "my-app" + + def test_returns_empty_string_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__APP_NAME", raising=False) + assert info.app_name() == "" + + +class TestTeamName: + def test_returns_name_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__TEAM_NAME", "my-team") + assert info.team_name() == "my-team" + + def test_returns_empty_string_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__TEAM_NAME", raising=False) + assert info.team_name() == "" + + +class TestEnvironment: + def test_returns_env_when_set(self, monkeypatch): + monkeypatch.setenv("TOWER__RUNTIME__ENVIRONMENT_NAME", "production") + assert info.environment() == "production" + + def test_returns_empty_string_when_missing(self, monkeypatch): + monkeypatch.delenv("TOWER__RUNTIME__ENVIRONMENT_NAME", raising=False) + assert info.environment() == "" diff --git a/uv.lock b/uv.lock index 475a42ac..7ded35b2 100644 --- a/uv.lock +++ b/uv.lock @@ -2488,7 +2488,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.51" +version = "0.3.52" source = { editable = "." } dependencies = [ { name = "attrs" },