Skip to content

Commit

Permalink
fix: add content build run --force flag
Browse files Browse the repository at this point in the history
Signed-off-by: Lucas Rodriguez <lucas.rodriguez@posit.co>
  • Loading branch information
lucasrod16 committed Dec 20, 2024
1 parent 801d8f0 commit 8e5071e
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 23 deletions.
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
36 changes: 20 additions & 16 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,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:
Expand 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
Expand Down Expand Up @@ -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

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. Builds the same content as --retry.",
)
@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
120 changes: 115 additions & 5 deletions tests/test_main_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import shutil
import tarfile
import unittest
from unittest.mock import patch

import httpretty
from click.testing import CliRunner
Expand All @@ -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

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

0 comments on commit 8e5071e

Please sign in to comment.