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