From ad7de8b92efc64ba2daadfeedd3e2376683816c3 Mon Sep 17 00:00:00 2001
From: Lucas Rodriguez <lucas.rodriguez@posit.co>
Date: Thu, 19 Dec 2024 22:40:41 -0600
Subject: [PATCH] fix: add content build run --force flag

Signed-off-by: Lucas Rodriguez <lucas.rodriguez@posit.co>
---
 CHANGELOG.md                 |   2 +
 rsconnect/actions_content.py |  36 ++++++-----
 rsconnect/main.py            |   8 ++-
 rsconnect/utils_package.py   |   2 +-
 tests/test_main_content.py   | 120 +++++++++++++++++++++++++++++++++--
 5 files changed, 145 insertions(+), 23 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92cc1fae..f3a15897 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.
 
 ## [1.25.0] - 2024-12-18
 
diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py
index ce49b324..918c5327 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("Are you sure you want to proceed? Type 'yes' to confirm: ").strip().lower()
+        if user_input != "yes":
+            logger.warning("Build aborted.")
+            return
+        logger.info("Proceeding with the build operation...")
 
     # 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..5cac9d63 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 operation...", 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)