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/README.md b/README.md index 98847982..8596cf20 100644 --- a/README.md +++ b/README.md @@ -885,6 +885,10 @@ build all "tracked" content that has the status `NEEDS_BUILD`. > To re-run failed builds, use `rsconnect content build run --retry`. This will build all tracked content in any of the following states: `[NEEDS_BUILD, ABORTED, ERROR, RUNNING]`. +> +> If you encounter an error indicating that a build operation is already in progress, +you can use `rsconnect content build run --force` to bypass the check and proceed with building content marked as `NEEDS_BUILD`. +Ensure no other build operation is actively running before using the `--force` option. ```bash rsconnect content build run diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index ce49b324..562c09a7 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,14 @@ 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( + "A content build operation targeting '%s' is still running, or exited abnormally. " + "Use the '--force' option to override this check." % connect_server.url + ) # if we are re-building any already "tracked" content items, then re-add them to be safe if all: @@ -277,12 +271,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..28470478 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.", +) @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..64984dac 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -11,7 +11,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 +99,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 +221,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 +252,141 @@ 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, + "A content build operation targeting 'http://localhost:3939' is still running, or exited abnormally", + ) + self.assertRegex(result.output, "Use the '--force' option to override this check") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_build_force(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)))) + + # set rsconnect_build_running to true + # --force flag should ignore this and not fail. + self.build_store.set_build_running(True) + + 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, result.output) + + # 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_force_retry(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 + # --retry used with --force should successfully build content with these statuses. + 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) + + args = ["content", "build", "run", "--force", "--retry"] + 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) + + # 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)