Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,8 @@ 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 stating that a build operation is already running, you can use `rsconnect content build run --force`. This will override this check and build any content marked as `NEEDS_BUILD`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this include something like, "make sure any previously launched content build operation is no longer active"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh yeah that's a good suggestion. Added a note for that and made an attempt to improve the flow of the wording as well


```bash
rsconnect content build run
Expand Down
24 changes: 9 additions & 15 deletions rsconnect/actions_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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()]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -2772,6 +2777,7 @@ def start_content_build(
poll_wait: int,
format: LogOutputFormat.All,
debug: bool,
force: bool,
verbose: int,
):
set_verbosity(verbose)
Expand All @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion rsconnect/utils_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
147 changes: 142 additions & 5 deletions tests/test_main_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down
Loading