diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cc1fae..d96c7127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added validation for required flags for the `rsconnect system caches delete` command. +- Added `--force` flag to `rsconnect content build run` command. This allows users + to force builds when a build is already marked as running. (#630) ## [1.25.0] - 2024-12-18 diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index ce49b324..996b160e 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -49,12 +49,6 @@ def build_add_content( :param content_guids_with_bundle: Union[tuple[models.ContentGuidWithBundle], list[models.ContentGuidWithBundle]] """ build_store = ensure_content_build_store(connect_server) - if build_store.get_build_running(): - raise RSConnectException( - "There is already a build running on this server, " - + "please wait for it to finish before adding new content." - ) - with RSConnectClient(connect_server) as client: if len(content_guids_with_bundle) == 1: all_content = [client.content_get(content_guids_with_bundle[0].guid)] @@ -104,10 +98,6 @@ def build_remove_content( _validate_build_rm_args(guid, all, purge) build_store = ensure_content_build_store(connect_server) - if build_store.get_build_running(): - raise RSConnectException( - "There is a build running on this server, " + "please wait for it to finish before removing content." - ) guids: list[str] if all: guids = [c["guid"] for c in build_store.get_content_items()] @@ -141,10 +131,23 @@ def build_start( all: bool = False, poll_wait: int = 1, debug: bool = False, + force: bool = False, ): build_store = ensure_content_build_store(connect_server) - if build_store.get_build_running(): - raise RSConnectException("There is already a build running on this server: %s" % connect_server.url) + if build_store.get_build_running() and not force: + raise RSConnectException( + "There is already a build running on this server: %s. " + "Use the '--force' flag to override this check." % connect_server.url + ) + + # prompt the user to confirm that they want to --force a build. + if force: + logger.warning("Please ensure a build is not already running in another terminal before proceeding.") + user_input = input("Proceed with the build? Type 'yes' to confirm, any other key to cancel: ").strip().lower() + if user_input != "yes": + logger.warning("Build aborted.") + return + logger.info("Proceeding with the build...") # if we are re-building any already "tracked" content items, then re-add them to be safe if all: @@ -154,7 +157,8 @@ def build_start( build_add_content(connect_server, all_content) else: # --retry is shorthand for --aborted --error --running - if retry: + # --force has the same behavior as --retry and also ignores when rsconnect_build_running=true + if retry or force: aborted = True error = True running = True @@ -277,12 +281,12 @@ def _monitor_build(connect_server: RSConnectServer, content_items: list[ContentI ) if build_store.aborted(): - logger.warn("Build interrupted!") + logger.warning("Build interrupted!") aborted_builds = [i["guid"] for i in content_items if i["rsconnect_build_status"] == BuildStatus.RUNNING] if len(aborted_builds) > 0: - logger.warn("Marking %d builds as ABORTED..." % len(aborted_builds)) + logger.warning("Marking %d builds as ABORTED..." % len(aborted_builds)) for guid in aborted_builds: - logger.warn("Build aborted: %s" % guid) + logger.warning("Build aborted: %s" % guid) build_store.set_content_item_build_status(guid, BuildStatus.ABORTED) return False diff --git a/rsconnect/main.py b/rsconnect/main.py index 0902bb5c..d5f1a988 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2755,6 +2755,11 @@ def get_build_logs( is_flag=True, help="Log stacktraces from exceptions during background operations.", ) +@click.option( + "--force", + is_flag=True, + help="Always build content even if a build is already marked as running. Builds the same content as --retry.", +) @click.pass_context def start_content_build( ctx: click.Context, @@ -2772,6 +2777,7 @@ def start_content_build( poll_wait: int, format: LogOutputFormat.All, debug: bool, + force: bool, verbose: int, ): set_verbosity(verbose) @@ -2781,7 +2787,7 @@ def start_content_build( ce = RSConnectExecutor(ctx, name, server, api_key, insecure, cacert, logger=None).validate_server() if not isinstance(ce.remote_server, RSConnectServer): raise RSConnectException("rsconnect content build run` requires a Posit Connect server.") - build_start(ce.remote_server, parallelism, aborted, error, running, retry, all, poll_wait, debug) + build_start(ce.remote_server, parallelism, aborted, error, running, retry, all, poll_wait, debug, force) @cli.group(no_args_is_help=True, help="Interact with Posit Connect's system API.") diff --git a/rsconnect/utils_package.py b/rsconnect/utils_package.py index 94ded49f..613b49a2 100644 --- a/rsconnect/utils_package.py +++ b/rsconnect/utils_package.py @@ -246,7 +246,7 @@ def fix_starlette_requirements( if compare_semvers(starlette_req.specs[0].version, "0.35.0") >= 0: # starlette is in requirements.txt, but with a version spec that is # not compatible with this version of Connect. - logger.warn( + logger.warning( "Starlette version is 0.35.0 or greater, but this version of Connect " "requires starlette<0.35.0. Setting to <0.35.0." ) diff --git a/tests/test_main_content.py b/tests/test_main_content.py index c13eed4b..d1c6b8aa 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -3,6 +3,7 @@ import shutil import tarfile import unittest +from unittest.mock import patch import httpretty from click.testing import CliRunner @@ -11,7 +12,8 @@ from rsconnect import VERSION from rsconnect.api import RSConnectServer from rsconnect.models import BuildStatus -from rsconnect.metadata import ContentBuildStore, _normalize_server_url +from rsconnect.actions_content import ensure_content_build_store +from rsconnect.metadata import _normalize_server_url from .utils import apply_common_args @@ -98,6 +100,8 @@ def tearDownClass(cls): def setUp(self): self.connect_server = "http://localhost:3939" self.api_key = "testapikey123" + self.build_store = ensure_content_build_store(RSConnectServer(self.connect_server, self.api_key)) + self.build_store.set_build_running(False) def test_version(self): runner = CliRunner() @@ -218,10 +222,9 @@ def test_build_retry(self): self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server)))) # change the content build status so it looks like it was interrupted/failed - store = ContentBuildStore(RSConnectServer(self.connect_server, self.api_key)) - store.set_content_item_build_status("7d59c5c7-c4a7-4950-acc3-3943b7192bc4", BuildStatus.RUNNING) - store.set_content_item_build_status("ab497e4b-b706-4ae7-be49-228979a95eb4", BuildStatus.ABORTED) - store.set_content_item_build_status("cdfed1f7-0e09-40eb-996d-0ef77ea2d797", BuildStatus.ERROR) + self.build_store.set_content_item_build_status("7d59c5c7-c4a7-4950-acc3-3943b7192bc4", BuildStatus.RUNNING) + self.build_store.set_content_item_build_status("ab497e4b-b706-4ae7-be49-228979a95eb4", BuildStatus.ABORTED) + self.build_store.set_content_item_build_status("cdfed1f7-0e09-40eb-996d-0ef77ea2d797", BuildStatus.ERROR) # run the build args = ["content", "build", "run", "--retry"] @@ -250,6 +253,113 @@ def test_build_retry(self): self.assertEqual(listing[1]["rsconnect_build_status"], BuildStatus.COMPLETE) self.assertEqual(listing[2]["rsconnect_build_status"], BuildStatus.COMPLETE) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_build_already_running_error(self): + register_uris(self.connect_server) + runner = CliRunner() + + args = ["content", "build", "add", "-g", "7d59c5c7-c4a7-4950-acc3-3943b7192bc4"] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server)))) + + # set rsconnect_build_running to true to trigger "already a build running" error + self.build_store.set_build_running(True) + + # build without --force flag should fail + args = ["content", "build", "run"] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 1) + self.assertRegex(result.output, "There is already a build running on this server") + self.assertRegex(result.output, "Use the '--force' flag to override this check") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_build_force_abort(self): + register_uris(self.connect_server) + runner = CliRunner() + + args = ["content", "build", "add", "-g", "7d59c5c7-c4a7-4950-acc3-3943b7192bc4"] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server)))) + + # set rsconnect_build_running to true + # --force flag should ignore this and not fail. + self.build_store.set_build_running(True) + + # mock "no" input to simulate user response to prompt + with patch("builtins.input", return_value="no"), self.assertLogs("rsconnect") as log: + args = ["content", "build", "run", "--force"] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0) + self.assertIn("Please ensure a build is not already running in another terminal", log.output[0]) + self.assertIn("Build aborted", log.output[1]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_build_force_success(self): + register_uris(self.connect_server) + runner = CliRunner() + + # add 3 content items + args = [ + "content", + "build", + "add", + "-g", + "7d59c5c7-c4a7-4950-acc3-3943b7192bc4", + "-g", + "ab497e4b-b706-4ae7-be49-228979a95eb4", + "-g", + "cdfed1f7-0e09-40eb-996d-0ef77ea2d797", + ] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server)))) + + # change the content build status so it looks like it was interrupted/failed + self.build_store.set_content_item_build_status("7d59c5c7-c4a7-4950-acc3-3943b7192bc4", BuildStatus.RUNNING) + self.build_store.set_content_item_build_status("ab497e4b-b706-4ae7-be49-228979a95eb4", BuildStatus.ABORTED) + self.build_store.set_content_item_build_status("cdfed1f7-0e09-40eb-996d-0ef77ea2d797", BuildStatus.ERROR) + + # set rsconnect_build_running to true + # --force flag should ignore this and not fail. + self.build_store.set_build_running(True) + + # mock "yes" input to simulate user response to prompt + with patch("builtins.input", return_value="yes"), self.assertLogs("rsconnect") as log: + args = ["content", "build", "run", "--force"] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0) + self.assertIn("Please ensure a build is not already running in another terminal", log.output[0]) + self.assertIn("Proceeding with the build...", log.output[1]) + + # check that the build succeeded + args = [ + "content", + "build", + "ls", + "-g", + "7d59c5c7-c4a7-4950-acc3-3943b7192bc4", + "-g", + "ab497e4b-b706-4ae7-be49-228979a95eb4", + "-g", + "cdfed1f7-0e09-40eb-996d-0ef77ea2d797", + ] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + listing = json.loads(result.output) + self.assertTrue(len(listing) == 3) + self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.COMPLETE) + self.assertEqual(listing[1]["rsconnect_build_status"], BuildStatus.COMPLETE) + self.assertEqual(listing[2]["rsconnect_build_status"], BuildStatus.COMPLETE) + @httpretty.activate(verbose=True, allow_net_connect=False) def test_build_rm(self): register_uris(self.connect_server)