From 346061f33d3099e81737306ebd26e8adc9209523 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Fri, 14 Aug 2026 09:54:17 -0400 Subject: [PATCH] feat: add `content migrate-to-connect-cloud` Point a directory's local deployment record at an existing Posit Connect Cloud content item, so the next deploy from that directory updates that item instead of creating a second one. Connect Cloud cannot look content up by name, so the local record is the only way back to a content item; without one, deploying content that already exists there duplicates it. Nothing is copied and no bundle is uploaded. The record that was migrated from is removed, leaving the directory with one deployment target rather than two; `--from-server` chooses which record to migrate when the store covers several servers, and `-o/--overwrite` replaces an existing Connect Cloud record. Records are keyed by account, and a deploy refuses content owned by another account, so a record written under the wrong account would be silently useless. Migration therefore requires the content's owning account to be the one being targeted and names it when it is not. With no local record at all the record is reconstructed from the content, with app mode `unknown`, which `validate_app_mode` already tolerates. Fixes #826 --- docs/CHANGELOG.md | 13 ++ rsconnect/api.py | 162 ++++++++++++++++++- rsconnect/main.py | 96 ++++++++++++ rsconnect/metadata.py | 15 +- tests/test_connect_cloud.py | 300 ++++++++++++++++++++++++++++++++++++ 5 files changed, 579 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4d9bf052..588605a7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- New `rsconnect content migrate-to-connect-cloud` points a directory's local + deployment record at an existing Posit Connect Cloud content item, so the next + deploy from that directory updates that item instead of creating a second one. + Use it after migrating shinyapps.io content to Connect Cloud: + `rsconnect content migrate-to-connect-cloud ./my-app -n cloud + --content-id `. Nothing is copied and no bundle is uploaded — the content + must already exist in Connect Cloud, and only local files change. The record + it was migrated from is removed, so the directory is left with one deployment + target rather than two; pass `--from-server` to choose which record to migrate + when there are several. + +## Unreleased + - Posit Connect Cloud is now a supported deployment target, alongside Posit Connect and shinyapps.io, mirroring the R rsconnect package's support. Register an account with `rsconnect add -n --connect-cloud diff --git a/rsconnect/api.py b/rsconnect/api.py index 6d4d7ba0..172fb73f 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -68,7 +68,15 @@ create_multipart_form_data, ) from .log import cls_logged, connect_logger, console_logger, logger -from .metadata import SHINYAPPS_API_URL, SHINYAPPS_SERVER_NAME, AppStore, ServerData, ServerStore +from .metadata import ( + SHINYAPPS_API_URL, + SHINYAPPS_SERVER_NAME, + AppMetadata, + AppStore, + ServerData, + ServerStore, + resolve_server_alias, +) from .models import ( AppMode, AppModes, @@ -1302,6 +1310,14 @@ class ServerDetails(TypedDict): python: ServerDetailsPython +def _record_server_list(records: list[AppMetadata]) -> str: + """The servers a set of deployment records covers, for an error message.""" + servers = sorted(record.get("server_url", "") for record in records) + if not servers: + return "" + return " Records exist for: %s." % ", ".join(servers) + + class RSConnectExecutor: def __init__( self, @@ -2256,6 +2272,134 @@ def write_deployed_info(self): self.app_mode, ) + def migration_source_record(self, from_server: Optional[str] = None) -> Optional[AppMetadata]: + """The deployment record being migrated away from, if there is one. + + Only records for other servers are candidates: a Connect Cloud record is + what this migration produces, so treating one as a source would delete the + result. With no `from_server` a lone record is taken, and several are + reported rather than picked between. + """ + candidates = [ + record + for record in self.app_store.get_all() + if not connect_cloud.is_connect_cloud_url(record.get("server_url", "").split("#")[0]) + ] + + if from_server: + target = resolve_server_alias(from_server) + matches = [record for record in candidates if record.get("server_url") == target] + if not matches: + raise RSConnectException( + 'No deployment record for server "%s" in %s.%s' + % (from_server, self.app_store.get_path(), _record_server_list(candidates)) + ) + return matches[0] + + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + raise RSConnectException( + "Several deployment records exist in %s. Use --from-server to choose which one to " + "migrate.%s" % (self.app_store.get_path(), _record_server_list(candidates)) + ) + + def migrate_to_connect_cloud( + self, + content_id: str, + from_server: Optional[str] = None, + overwrite: bool = False, + ) -> AppMetadata: + """Point this content's local deployment record at existing Posit Connect Cloud content. + + Nothing is copied and no bundle is uploaded: the content must already exist in + Connect Cloud. What changes is the local record, which is the only way back to a + content item — Connect Cloud cannot look content up by name, so deploying + without a record creates a duplicate instead of updating the existing item. + + :param content_id: the id of the Connect Cloud content to point at. + :param from_server: the URL of the deployment record to migrate, needed only + when the local records cover several servers. + :param overwrite: replace an existing Connect Cloud record for this account. + :return: the record that was written. + """ + if not isinstance(self.remote_server, ConnectCloudServer) or not isinstance(self.client, ConnectCloudClient): + raise RSConnectException("Migrating a deployment record requires a Posit Connect Cloud account.") + + source = self.migration_source_record(from_server) + + # Checked before any request, so a run that cannot write anything makes no + # network calls and leaves the existing record untouched. The name-keyed + # location counts as existing too: a deploy reads it when the id-keyed one is + # missing, so a record there is this account's current target, and writing the + # id-keyed record would silently retarget the deploy and strand a duplicate. + target_key = self.record_server_key() + fallback_key = self.record_server_key_fallback() + existing = self.app_store.get(target_key) + if existing is None and fallback_key: + existing = self.app_store.get(fallback_key) + if existing and not overwrite: + raise RSConnectException( + 'A Posit Connect Cloud deployment record for account "%s" already exists in %s, for content %s. ' + "Use --overwrite to replace it." + % (self.remote_server.account_name, self.app_store.get_path(), existing.get("app_id")) + ) + + service = ConnectCloudService(self.client, self.remote_server) + with self.client: + content = self.client.get_content(content_id) + account_id = content.get("account_id") + account_name = service.account_name_for_id(account_id) if account_id else None + if not account_name: + raise RSConnectException( + "Unable to determine which Posit Connect Cloud account owns content %s. " + "You may not have a role on that account." % content_id + ) + # The record is keyed by account, and a deploy only reads the record for the + # account it is publishing to -- and would refuse content owned by another + # account anyway. A record written under the wrong account would therefore + # be silently ignored, so name the account that makes it usable instead. + # Compared by id when one is known, since that is what the key uses and what + # survives a rename; by name otherwise, which is then what the key uses too. + if self.remote_server.account_id: + same_account = account_id == self.remote_server.account_id + else: + same_account = account_name == self.remote_server.account_name + if not same_account: + raise RSConnectException( + 'Content %s belongs to the Posit Connect Cloud account "%s", not "%s". ' + "Re-run with -A %s." % (content_id, account_name, self.remote_server.account_name, account_name) + ) + + title = content.get("title") or (source or {}).get("title") or self.title + self.app_store.set( + target_key, + abspath(self.path), + self.remote_server.urls().content_url(account_name, content_id), + content_id, + None, # Connect Cloud content has no GUID separate from its id. + title, + # Connect Cloud derives the app mode from the content type and primary file, + # so there is nothing to read back from it; the source record's mode is kept + # when there is one. "unknown" does not block a later deploy of any mode. + (source or {}).get("app_mode") or AppModes.UNKNOWN.name(), + ) + + # Removed only after the new record is safely written: leaving both is + # recoverable, losing both is not. + if source: + self.app_store.remove(source["server_url"]) + # The name-keyed record is the same account's, now superseded by the id-keyed + # one -- the same migration a deploy's write performs. + if fallback_key: + self.app_store.remove(fallback_key) + + record = self.app_store.get(target_key) + if record is None: # pragma: no cover - just written above + raise RSConnectException("The deployment record could not be saved.") + return record + @property def supports_verify_before_activate(self) -> bool: """Whether the target server supports deploying a bundle as a draft and @@ -3449,6 +3593,17 @@ def account_id(self) -> str: account = self._client.get_account_by_name(self._server.account_name) return account["id"] + def account_name_for_id(self, account_id: str) -> Optional[str]: + """The name of the account with this id, among those the caller has a role on. + + None means the account is not one of them, which for content the caller is + working with means they likely cannot publish to it either. + """ + for account in self._client.get_accounts(): + if account.get("id") == account_id: + return account["name"] + return None + def content_url(self, content_id: str, account_id: Optional[str]) -> str: """Build the browsable URL for a content item. @@ -3461,10 +3616,7 @@ def content_url(self, content_id: str, account_id: Optional[str]) -> str: # be stale after a rename, and account ids are what survive one. account_name = self._server.account_name if account_id: - for account in self._client.get_accounts(): - if account.get("id") == account_id: - account_name = account["name"] - break + account_name = self.account_name_for_id(account_id) or account_name return self._server.urls().content_url(account_name, content_id) except RSConnectException as exc: # A URL we cannot build must not mask an otherwise successful deploy. diff --git a/rsconnect/main.py b/rsconnect/main.py index c9faa975..14ccab08 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -84,6 +84,7 @@ ) from .models import EnvironmentInstallation, EnvironmentVolumeMount from .api import ( + ConnectCloudServer, RSConnectClient, RSConnectExecutor, RSConnectServer, @@ -4852,6 +4853,101 @@ def _guid_for_current_server(server_url: str) -> Optional[str]: logger.info("Environment ready. Activate with: source %s/bin/activate" % env_path) +@content.command( + name="migrate-to-connect-cloud", + short_help="Point a local deployment record at existing Posit Connect Cloud content.", + help=( + "Rewrite the local deployment record for FILE_OR_DIRECTORY so the next deploy updates an " + "existing Posit Connect Cloud content item instead of creating a new one. Use this after " + "migrating shinyapps.io content in Connect Cloud." + "\n\n" + "Nothing is copied and no bundle is uploaded: the content must already exist in Connect " + "Cloud, and only local files are changed. The record it was migrated from is removed, so " + "afterwards the directory has one deployment target rather than two." + "\n\n" + "FILE_OR_DIRECTORY is the same path you pass to `rsconnect deploy`. It defaults to the " + "current directory." + ), + no_args_is_help=True, +) +@click.option("--name", "-n", help="The nickname of the saved Posit Connect Cloud account.") +@click.option( + "--server", + "-s", + envvar="CONNECT_SERVER", + help="The Posit Connect Cloud server, `connect.posit.cloud`. \ +(Also settable via CONNECT_SERVER environment variable.)", +) +@connect_cloud_args +@connect_cloud_account_arg +@click.option( + "--content-id", + required=True, + type=StrippedStringParamType(), + metavar="TEXT", + help="The id of the Posit Connect Cloud content to point at. It is the last part of the \ +content URL: https://connect.posit.cloud/{account}/content/{content-id}.", +) +@click.option( + "--from-server", + help="The URL of the deployment record to migrate, such as `shinyapps.io`. Only needed when \ +the local deployment records cover more than one server.", +) +@click.option( + "--overwrite", + "-o", + is_flag=True, + help="Replace an existing Posit Connect Cloud deployment record for this account.", +) +@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") +@click.argument( + "file_or_directory", + type=click.Path(exists=True, dir_okay=True, file_okay=True), + default=os.curdir, +) +@cli_exception_handler +@click.pass_context +def content_migrate_to_connect_cloud( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + account: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, + content_id: str, + from_server: Optional[str], + overwrite: bool, + file_or_directory: str, + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + account=account, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, + path=file_or_directory, + ).validate_server() + if not isinstance(ce.remote_server, ConnectCloudServer): + raise RSConnectException( + "`rsconnect content migrate-to-connect-cloud` requires a Posit Connect Cloud account. " + "Pass --connect-cloud with -A/--account, or -n with a saved Connect Cloud nickname." + ) + + with cli_feedback("Migrating the deployment record"): + record = ce.migrate_to_connect_cloud(content_id, from_server=from_server, overwrite=overwrite) + + click.echo('Found "%s" in Posit Connect Cloud.' % record["title"]) + click.echo("The deployment record for %s now points at %s" % (file_or_directory, record["app_url"])) + click.echo("The next deploy will update that content item rather than create a new one.") + + @content.group(no_args_is_help=True, help="Manage git repository configuration for content items.") def repository(): pass diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index c54e1869..7e727c08 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -697,7 +697,8 @@ class AppMetadata(TypedDict): filename: str app_url: str app_id: str - app_guid: str + # Posit Connect Cloud and shinyapps.io identify content by id alone. + app_guid: Optional[str] title: str app_mode: str app_store_version: int @@ -757,7 +758,7 @@ def set( filename: str, app_url: str, app_id: str, - app_guid: str, + app_guid: Optional[str], title: str, app_mode: AppMode | str, ): @@ -786,6 +787,16 @@ def set( }, ) + def remove(self, server_url: str) -> bool: + """ + Forget the metadata for the app deployed to the given server. + + :param server_url: the key the record is stored under, as reported by + `get_all()`. + :return: whether a record was removed. + """ + return self._remove_by_key(server_url) + def resolve(self, server: str, app_id: Optional[str], app_mode: Optional[AppMode]): metadata = self.get(server) if metadata is None: diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index b2f459ea..aaf38992 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -18,8 +18,10 @@ ConnectCloudClient, ConnectCloudServer, ConnectCloudService, + RSConnectClient, RSConnectExecutor, ) +from rsconnect.environment import fake_module_file_from_directory from rsconnect.exception import DeploymentFailedException, RSConnectException from rsconnect.log import VERBOSE from rsconnect.main import cli @@ -2178,6 +2180,304 @@ def test_a_name_keyed_record_is_still_found_when_an_id_arrives(self): self.assertEqual(executor.app_id, "c1") +SHINYAPPS = "https://api.shinyapps.io" +MIGRATED_KEY = "%s#acct-1" % API + + +class TestConnectCloudMigrate(unittest.TestCase): + """Migration rewrites the local deployment record; no content is copied.""" + + def setUp(self): + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + self.app_dir = tempdir.name + self.store_file = fake_module_file_from_directory(self.app_dir) + + def _store(self) -> AppStore: + return AppStore(self.store_file) + + def _record(self, server_url: str, app_id: str = "42", app_mode: Any = AppModes.PYTHON_SHINY) -> None: + store = self._store() + store.set(server_url, self.app_dir, "https://acme.shinyapps.io/my-app", app_id, None, "My App", app_mode) + + def _executor(self, account_name: str = "acme", account_id: Optional[str] = "acct-1") -> RSConnectExecutor: + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.remote_server = ConnectCloudServer(account_name, access_token="at", account_id=account_id) + executor.client = mock.MagicMock(spec=ConnectCloudClient) + executor.client.get_content.return_value = {"id": "c1", "title": "My Cloud App", "account_id": "acct-1"} + executor.client.get_accounts.return_value = [{"id": "acct-1", "name": "acme"}] + executor.app_store = self._store() + executor.path = self.app_dir + executor.title = "app" + executor.logger = None + return executor + + def test_rewrites_the_record_and_removes_the_source(self): + self._record(SHINYAPPS) + + record = self._executor().migrate_to_connect_cloud("c1") + + self.assertEqual(record["app_id"], "c1") + self.assertEqual(record["title"], "My Cloud App") + self.assertEqual(record["app_url"], "https://connect.posit.cloud/acme/content/c1") + # The mode is not knowable from Connect Cloud, so the source record's is kept. + self.assertEqual(record["app_mode"], "python-shiny") + + saved = self._store() + self.assertIsNotNone(saved.get(MIGRATED_KEY)) + self.assertIsNone(saved.get(SHINYAPPS), "the migrated-from record should be gone") + + def test_the_next_deploy_finds_the_migrated_record(self): + # The whole point: a deploy of the same path must pick the content id up, + # or it would create a second content item in Connect Cloud. + self._record(SHINYAPPS) + self._executor().migrate_to_connect_cloud("c1") + + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.logger = None + executor.remote_server = ConnectCloudServer("acme", account_id="acct-1") + executor.app_store = self._store() + executor.app_store_version = None + executor.path = self.app_dir + executor.new = False + executor.app_id = None + executor.app_mode = None + executor.validate_app_mode(app_mode=AppModes.PYTHON_SHINY) + + self.assertEqual(executor.app_id, "c1") + + def test_content_owned_by_another_account_is_refused(self): + # A record under this account would never be read for content the deploy + # would refuse anyway, so the account that makes it usable is named. + self._record(SHINYAPPS) + executor = self._executor() + executor.client.get_content.return_value = {"id": "c1", "title": "T", "account_id": "acct-2"} + executor.client.get_accounts.return_value = [ + {"id": "acct-1", "name": "acme"}, + {"id": "acct-2", "name": "team"}, + ] + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn('"team"', str(context.exception)) + self.assertIn("-A team", str(context.exception)) + saved = self._store() + self.assertIsNotNone(saved.get(SHINYAPPS), "the source record should be untouched") + self.assertIsNone(saved.get("%s#acct-2" % API)) + + def test_without_an_account_id_the_account_is_matched_by_name(self): + # A server built from an account name alone keys its records by that name, + # so the ownership check has to compare the same thing the key does. + self._record(SHINYAPPS) + executor = self._executor(account_id=None) + + record = executor.migrate_to_connect_cloud("c1") + + self.assertEqual(record["server_url"], "%s#acme" % API) + + def test_an_unresolvable_account_is_refused(self): + self._record(SHINYAPPS) + executor = self._executor() + executor.client.get_content.return_value = {"id": "c1", "title": "T", "account_id": "acct-unknown"} + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn("Unable to determine which Posit Connect Cloud account", str(context.exception)) + self.assertIsNotNone(self._store().get(SHINYAPPS)) + + def test_missing_content_leaves_the_records_alone(self): + self._record(SHINYAPPS) + executor = self._executor() + executor.client.get_content.side_effect = RSConnectException("content c1 has been deleted", status=404) + + with self.assertRaises(RSConnectException): + executor.migrate_to_connect_cloud("c1") + + saved = self._store() + self.assertIsNotNone(saved.get(SHINYAPPS)) + self.assertIsNone(saved.get(MIGRATED_KEY)) + + def test_an_existing_cloud_record_needs_overwrite(self): + self._record(SHINYAPPS) + self._record(MIGRATED_KEY, app_id="other") + executor = self._executor() + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn("--overwrite", str(context.exception)) + self.assertIn("other", str(context.exception)) + # The check precedes every request, so nothing was asked of the server. + executor.client.get_content.assert_not_called() + self.assertEqual(self._store().get(MIGRATED_KEY)["app_id"], "other") + + def test_a_name_keyed_cloud_record_also_needs_overwrite(self): + # A record written before account ids were stored is keyed by name. A deploy + # reads it when the id-keyed record is missing, so it is this account's + # current target and must not be replaced silently. + self._record("%s#acme" % API, app_id="old") + executor = self._executor() + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn("--overwrite", str(context.exception)) + self.assertIn("old", str(context.exception)) + executor.client.get_content.assert_not_called() + + def test_overwrite_replaces_a_name_keyed_cloud_record(self): + # Both keys naming the same account must not be left behind as duplicates. + self._record("%s#acme" % API, app_id="old") + + record = self._executor().migrate_to_connect_cloud("c1", overwrite=True) + + self.assertEqual(record["server_url"], MIGRATED_KEY) + saved = self._store() + self.assertEqual(saved.get(MIGRATED_KEY)["app_id"], "c1") + self.assertIsNone(saved.get("%s#acme" % API)) + + def test_overwrite_replaces_the_existing_cloud_record(self): + self._record(MIGRATED_KEY, app_id="other") + + record = self._executor().migrate_to_connect_cloud("c1", overwrite=True) + + self.assertEqual(record["app_id"], "c1") + self.assertEqual(self._store().get(MIGRATED_KEY)["app_id"], "c1") + + def test_a_cloud_record_is_never_treated_as_the_source(self): + # Another account's record is not what this one is migrating away from; + # deleting it would throw away a working deployment target. + other_account = "%s#acct-9" % API + self._record(other_account) + + self._executor().migrate_to_connect_cloud("c1") + + saved = self._store() + self.assertIsNotNone(saved.get(other_account)) + self.assertIsNotNone(saved.get(MIGRATED_KEY)) + + def test_no_source_record_reconstructs_from_the_content(self): + record = self._executor().migrate_to_connect_cloud("c1") + + self.assertEqual(record["app_id"], "c1") + self.assertEqual(record["title"], "My Cloud App") + # Nothing local says what kind of content this is, and "unknown" does not + # block a later deploy of any mode. + self.assertEqual(record["app_mode"], "unknown") + + def test_several_records_require_from_server(self): + self._record(SHINYAPPS) + self._record("https://connect.example.com") + executor = self._executor() + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn("--from-server", str(context.exception)) + self.assertIn(SHINYAPPS, str(context.exception)) + self.assertIn("https://connect.example.com", str(context.exception)) + executor.client.get_content.assert_not_called() + + def test_from_server_selects_one_and_leaves_the_other(self): + self._record(SHINYAPPS) + self._record("https://connect.example.com") + + # The pseudo-server name resolves to the URL records are stored under. + self._executor().migrate_to_connect_cloud("c1", from_server="shinyapps.io") + + saved = self._store() + self.assertIsNone(saved.get(SHINYAPPS)) + self.assertIsNotNone(saved.get("https://connect.example.com")) + self.assertIsNotNone(saved.get(MIGRATED_KEY)) + + def test_from_server_that_matches_no_record_is_reported(self): + self._record(SHINYAPPS) + executor = self._executor() + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1", from_server="https://connect.example.com") + + self.assertIn("No deployment record", str(context.exception)) + self.assertIn(SHINYAPPS, str(context.exception)) + self.assertIsNotNone(self._store().get(SHINYAPPS)) + + def test_a_non_cloud_target_is_rejected(self): + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.remote_server = api.RSConnectServer("https://connect.example.com", "key") + executor.client = mock.MagicMock(spec=RSConnectClient) + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn("Posit Connect Cloud account", str(context.exception)) + + +class TestConnectCloudMigrateCli(CliTestCase): + def setUp(self): + super().setUp() + # The executor opens its own store; point it at the same temporary one. + api_store_patch = mock.patch("rsconnect.api.ServerStore", return_value=self.store) + api_store_patch.start() + self.addCleanup(api_store_patch.stop) + + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + self.app_dir = tempdir.name + self.app_store = AppStore(fake_module_file_from_directory(self.app_dir)) + self.app_store.set( + SHINYAPPS, self.app_dir, "https://acme.shinyapps.io/my-app", "42", None, "My App", AppModes.PYTHON_SHINY + ) + + def _migrate(self, *args: str): + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(ConnectCloudClient, "get_current_user", return_value={"id": "u1"})) + stack.enter_context( + mock.patch.object( + ConnectCloudClient, + "get_content", + return_value={"id": "c1", "title": "My Cloud App", "account_id": "acct-1"}, + ) + ) + stack.enter_context( + mock.patch.object(ConnectCloudClient, "get_accounts", return_value=[{"id": "acct-1", "name": "acme"}]) + ) + return self.runner.invoke( + cli, + ["content", "migrate-to-connect-cloud", self.app_dir, "--content-id", "c1", *args], + ) + + def test_migrates_with_a_saved_nickname(self): + self.store.set( + "cloud", + API, + connect_cloud_account_name="acme", + connect_cloud_account_id="acct-1", + connect_cloud_access_token="at", + ) + + result = self._migrate("-n", "cloud") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("My Cloud App", result.output) + self.assertIn("https://connect.posit.cloud/acme/content/c1", result.output) + + saved = AppStore(fake_module_file_from_directory(self.app_dir)) + self.assertEqual(saved.get(MIGRATED_KEY)["app_id"], "c1") + self.assertIsNone(saved.get(SHINYAPPS)) + + def test_a_connect_server_is_rejected(self): + self.store.set("prod", "https://connect.example.com", api_key="key") + + with mock.patch.object(api.RSConnectExecutor, "validate_connect_server"): + result = self._migrate("-n", "prod") + + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("requires a Posit Connect Cloud account", result.output) + self.assertIsNotNone(AppStore(fake_module_file_from_directory(self.app_dir)).get(SHINYAPPS)) + + class TestPresignedUrlErrorRedaction(unittest.TestCase): def test_upload_error_does_not_leak_the_signed_url(self): # handle_bad_response quotes the URI in its message; for a presigned