Skip to content

Commit 9478983

Browse files
authored
fix: add content build run --force flag (#630)
Signed-off-by: Lucas Rodriguez <[email protected]>
1 parent 3324af0 commit 9478983

File tree

6 files changed

+165
-22
lines changed

6 files changed

+165
-22
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Added
1010

1111
- Added validation for required flags for the `rsconnect system caches delete` command.
12+
- Added `--force` flag to `rsconnect content build run` command. This allows users
13+
to force builds when a build is already marked as running. (#630)
1214

1315
## [1.25.0] - 2024-12-18
1416

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,10 @@ build all "tracked" content that has the status `NEEDS_BUILD`.
885885
886886
> To re-run failed builds, use `rsconnect content build run --retry`. This will build
887887
all tracked content in any of the following states: `[NEEDS_BUILD, ABORTED, ERROR, RUNNING]`.
888+
>
889+
> If you encounter an error indicating that a build operation is already in progress,
890+
you can use `rsconnect content build run --force` to bypass the check and proceed with building content marked as `NEEDS_BUILD`.
891+
Ensure no other build operation is actively running before using the `--force` option.
888892
889893
```bash
890894
rsconnect content build run

rsconnect/actions_content.py

+9-15
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ def build_add_content(
4949
:param content_guids_with_bundle: Union[tuple[models.ContentGuidWithBundle], list[models.ContentGuidWithBundle]]
5050
"""
5151
build_store = ensure_content_build_store(connect_server)
52-
if build_store.get_build_running():
53-
raise RSConnectException(
54-
"There is already a build running on this server, "
55-
+ "please wait for it to finish before adding new content."
56-
)
57-
5852
with RSConnectClient(connect_server) as client:
5953
if len(content_guids_with_bundle) == 1:
6054
all_content = [client.content_get(content_guids_with_bundle[0].guid)]
@@ -104,10 +98,6 @@ def build_remove_content(
10498
_validate_build_rm_args(guid, all, purge)
10599

106100
build_store = ensure_content_build_store(connect_server)
107-
if build_store.get_build_running():
108-
raise RSConnectException(
109-
"There is a build running on this server, " + "please wait for it to finish before removing content."
110-
)
111101
guids: list[str]
112102
if all:
113103
guids = [c["guid"] for c in build_store.get_content_items()]
@@ -141,10 +131,14 @@ def build_start(
141131
all: bool = False,
142132
poll_wait: int = 1,
143133
debug: bool = False,
134+
force: bool = False,
144135
):
145136
build_store = ensure_content_build_store(connect_server)
146-
if build_store.get_build_running():
147-
raise RSConnectException("There is already a build running on this server: %s" % connect_server.url)
137+
if build_store.get_build_running() and not force:
138+
raise RSConnectException(
139+
"A content build operation targeting '%s' is still running, or exited abnormally. "
140+
"Use the '--force' option to override this check." % connect_server.url
141+
)
148142

149143
# if we are re-building any already "tracked" content items, then re-add them to be safe
150144
if all:
@@ -277,12 +271,12 @@ def _monitor_build(connect_server: RSConnectServer, content_items: list[ContentI
277271
)
278272

279273
if build_store.aborted():
280-
logger.warn("Build interrupted!")
274+
logger.warning("Build interrupted!")
281275
aborted_builds = [i["guid"] for i in content_items if i["rsconnect_build_status"] == BuildStatus.RUNNING]
282276
if len(aborted_builds) > 0:
283-
logger.warn("Marking %d builds as ABORTED..." % len(aborted_builds))
277+
logger.warning("Marking %d builds as ABORTED..." % len(aborted_builds))
284278
for guid in aborted_builds:
285-
logger.warn("Build aborted: %s" % guid)
279+
logger.warning("Build aborted: %s" % guid)
286280
build_store.set_content_item_build_status(guid, BuildStatus.ABORTED)
287281
return False
288282

rsconnect/main.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -2755,6 +2755,11 @@ def get_build_logs(
27552755
is_flag=True,
27562756
help="Log stacktraces from exceptions during background operations.",
27572757
)
2758+
@click.option(
2759+
"--force",
2760+
is_flag=True,
2761+
help="Always build content even if a build is already marked as running.",
2762+
)
27582763
@click.pass_context
27592764
def start_content_build(
27602765
ctx: click.Context,
@@ -2772,6 +2777,7 @@ def start_content_build(
27722777
poll_wait: int,
27732778
format: LogOutputFormat.All,
27742779
debug: bool,
2780+
force: bool,
27752781
verbose: int,
27762782
):
27772783
set_verbosity(verbose)
@@ -2781,7 +2787,7 @@ def start_content_build(
27812787
ce = RSConnectExecutor(ctx, name, server, api_key, insecure, cacert, logger=None).validate_server()
27822788
if not isinstance(ce.remote_server, RSConnectServer):
27832789
raise RSConnectException("rsconnect content build run` requires a Posit Connect server.")
2784-
build_start(ce.remote_server, parallelism, aborted, error, running, retry, all, poll_wait, debug)
2790+
build_start(ce.remote_server, parallelism, aborted, error, running, retry, all, poll_wait, debug, force)
27852791

27862792

27872793
@cli.group(no_args_is_help=True, help="Interact with Posit Connect's system API.")

rsconnect/utils_package.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def fix_starlette_requirements(
246246
if compare_semvers(starlette_req.specs[0].version, "0.35.0") >= 0:
247247
# starlette is in requirements.txt, but with a version spec that is
248248
# not compatible with this version of Connect.
249-
logger.warn(
249+
logger.warning(
250250
"Starlette version is 0.35.0 or greater, but this version of Connect "
251251
"requires starlette<0.35.0. Setting to <0.35.0."
252252
)

tests/test_main_content.py

+142-5
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from rsconnect import VERSION
1212
from rsconnect.api import RSConnectServer
1313
from rsconnect.models import BuildStatus
14-
from rsconnect.metadata import ContentBuildStore, _normalize_server_url
14+
from rsconnect.actions_content import ensure_content_build_store
15+
from rsconnect.metadata import _normalize_server_url
1516

1617
from .utils import apply_common_args
1718

@@ -98,6 +99,8 @@ def tearDownClass(cls):
9899
def setUp(self):
99100
self.connect_server = "http://localhost:3939"
100101
self.api_key = "testapikey123"
102+
self.build_store = ensure_content_build_store(RSConnectServer(self.connect_server, self.api_key))
103+
self.build_store.set_build_running(False)
101104

102105
def test_version(self):
103106
runner = CliRunner()
@@ -218,10 +221,9 @@ def test_build_retry(self):
218221
self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server))))
219222

220223
# change the content build status so it looks like it was interrupted/failed
221-
store = ContentBuildStore(RSConnectServer(self.connect_server, self.api_key))
222-
store.set_content_item_build_status("7d59c5c7-c4a7-4950-acc3-3943b7192bc4", BuildStatus.RUNNING)
223-
store.set_content_item_build_status("ab497e4b-b706-4ae7-be49-228979a95eb4", BuildStatus.ABORTED)
224-
store.set_content_item_build_status("cdfed1f7-0e09-40eb-996d-0ef77ea2d797", BuildStatus.ERROR)
224+
self.build_store.set_content_item_build_status("7d59c5c7-c4a7-4950-acc3-3943b7192bc4", BuildStatus.RUNNING)
225+
self.build_store.set_content_item_build_status("ab497e4b-b706-4ae7-be49-228979a95eb4", BuildStatus.ABORTED)
226+
self.build_store.set_content_item_build_status("cdfed1f7-0e09-40eb-996d-0ef77ea2d797", BuildStatus.ERROR)
225227

226228
# run the build
227229
args = ["content", "build", "run", "--retry"]
@@ -250,6 +252,141 @@ def test_build_retry(self):
250252
self.assertEqual(listing[1]["rsconnect_build_status"], BuildStatus.COMPLETE)
251253
self.assertEqual(listing[2]["rsconnect_build_status"], BuildStatus.COMPLETE)
252254

255+
@httpretty.activate(verbose=True, allow_net_connect=False)
256+
def test_build_already_running_error(self):
257+
register_uris(self.connect_server)
258+
runner = CliRunner()
259+
260+
args = ["content", "build", "add", "-g", "7d59c5c7-c4a7-4950-acc3-3943b7192bc4"]
261+
apply_common_args(args, server=self.connect_server, key=self.api_key)
262+
result = runner.invoke(cli, args)
263+
self.assertEqual(result.exit_code, 0, result.output)
264+
self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server))))
265+
266+
# set rsconnect_build_running to true to trigger "already a build running" error
267+
self.build_store.set_build_running(True)
268+
269+
# build without --force flag should fail
270+
args = ["content", "build", "run"]
271+
apply_common_args(args, server=self.connect_server, key=self.api_key)
272+
result = runner.invoke(cli, args)
273+
self.assertEqual(result.exit_code, 1)
274+
self.assertRegex(
275+
result.output,
276+
"A content build operation targeting 'http://localhost:3939' is still running, or exited abnormally",
277+
)
278+
self.assertRegex(result.output, "Use the '--force' option to override this check")
279+
280+
@httpretty.activate(verbose=True, allow_net_connect=False)
281+
def test_build_force(self):
282+
register_uris(self.connect_server)
283+
runner = CliRunner()
284+
285+
# add 3 content items
286+
args = [
287+
"content",
288+
"build",
289+
"add",
290+
"-g",
291+
"7d59c5c7-c4a7-4950-acc3-3943b7192bc4",
292+
"-g",
293+
"ab497e4b-b706-4ae7-be49-228979a95eb4",
294+
"-g",
295+
"cdfed1f7-0e09-40eb-996d-0ef77ea2d797",
296+
]
297+
apply_common_args(args, server=self.connect_server, key=self.api_key)
298+
result = runner.invoke(cli, args)
299+
self.assertEqual(result.exit_code, 0, result.output)
300+
self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server))))
301+
302+
# set rsconnect_build_running to true
303+
# --force flag should ignore this and not fail.
304+
self.build_store.set_build_running(True)
305+
306+
args = ["content", "build", "run", "--force"]
307+
apply_common_args(args, server=self.connect_server, key=self.api_key)
308+
result = runner.invoke(cli, args)
309+
self.assertEqual(result.exit_code, 0, result.output)
310+
311+
# check that the build succeeded
312+
args = [
313+
"content",
314+
"build",
315+
"ls",
316+
"-g",
317+
"7d59c5c7-c4a7-4950-acc3-3943b7192bc4",
318+
"-g",
319+
"ab497e4b-b706-4ae7-be49-228979a95eb4",
320+
"-g",
321+
"cdfed1f7-0e09-40eb-996d-0ef77ea2d797",
322+
]
323+
apply_common_args(args, server=self.connect_server, key=self.api_key)
324+
result = runner.invoke(cli, args)
325+
self.assertEqual(result.exit_code, 0, result.output)
326+
listing = json.loads(result.output)
327+
self.assertTrue(len(listing) == 3)
328+
self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.COMPLETE)
329+
self.assertEqual(listing[1]["rsconnect_build_status"], BuildStatus.COMPLETE)
330+
self.assertEqual(listing[2]["rsconnect_build_status"], BuildStatus.COMPLETE)
331+
332+
@httpretty.activate(verbose=True, allow_net_connect=False)
333+
def test_build_force_retry(self):
334+
register_uris(self.connect_server)
335+
runner = CliRunner()
336+
337+
# add 3 content items
338+
args = [
339+
"content",
340+
"build",
341+
"add",
342+
"-g",
343+
"7d59c5c7-c4a7-4950-acc3-3943b7192bc4",
344+
"-g",
345+
"ab497e4b-b706-4ae7-be49-228979a95eb4",
346+
"-g",
347+
"cdfed1f7-0e09-40eb-996d-0ef77ea2d797",
348+
]
349+
apply_common_args(args, server=self.connect_server, key=self.api_key)
350+
result = runner.invoke(cli, args)
351+
self.assertEqual(result.exit_code, 0, result.output)
352+
self.assertTrue(os.path.exists("%s/%s.json" % (TEMP_DIR, _normalize_server_url(self.connect_server))))
353+
354+
# change the content build status so it looks like it was interrupted/failed
355+
# --retry used with --force should successfully build content with these statuses.
356+
self.build_store.set_content_item_build_status("7d59c5c7-c4a7-4950-acc3-3943b7192bc4", BuildStatus.RUNNING)
357+
self.build_store.set_content_item_build_status("ab497e4b-b706-4ae7-be49-228979a95eb4", BuildStatus.ABORTED)
358+
self.build_store.set_content_item_build_status("cdfed1f7-0e09-40eb-996d-0ef77ea2d797", BuildStatus.ERROR)
359+
360+
# set rsconnect_build_running to true
361+
# --force flag should ignore this and not fail.
362+
self.build_store.set_build_running(True)
363+
364+
args = ["content", "build", "run", "--force", "--retry"]
365+
apply_common_args(args, server=self.connect_server, key=self.api_key)
366+
result = runner.invoke(cli, args)
367+
self.assertEqual(result.exit_code, 0, result.output)
368+
369+
# check that the build succeeded
370+
args = [
371+
"content",
372+
"build",
373+
"ls",
374+
"-g",
375+
"7d59c5c7-c4a7-4950-acc3-3943b7192bc4",
376+
"-g",
377+
"ab497e4b-b706-4ae7-be49-228979a95eb4",
378+
"-g",
379+
"cdfed1f7-0e09-40eb-996d-0ef77ea2d797",
380+
]
381+
apply_common_args(args, server=self.connect_server, key=self.api_key)
382+
result = runner.invoke(cli, args)
383+
self.assertEqual(result.exit_code, 0, result.output)
384+
listing = json.loads(result.output)
385+
self.assertTrue(len(listing) == 3)
386+
self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.COMPLETE)
387+
self.assertEqual(listing[1]["rsconnect_build_status"], BuildStatus.COMPLETE)
388+
self.assertEqual(listing[2]["rsconnect_build_status"], BuildStatus.COMPLETE)
389+
253390
@httpretty.activate(verbose=True, allow_net_connect=False)
254391
def test_build_rm(self):
255392
register_uris(self.connect_server)

0 commit comments

Comments
 (0)