From 78c86217e68e5bf1f8ac8da44e22c19c0d6b576c Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 26 Apr 2023 13:16:06 -0500 Subject: [PATCH 01/13] start setting record version on metadata records --- rsconnect/metadata.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 450e8d95..c2fc70cb 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -18,6 +18,7 @@ from .log import logger from .models import AppMode, AppModes +METADATA_RECORD_VERSION = 1 def config_dirname(platform=sys.platform, env=os.environ): """Get the user's configuration directory path for this platform.""" @@ -446,6 +447,7 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode): app_guid=app_guid, title=title, app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode, + record_version=METADATA_RECORD_VERSION, ), ) From 35633b2f604745d1ffc9d9c1545b64596b3120e3 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Fri, 28 Apr 2023 15:32:26 -0500 Subject: [PATCH 02/13] rename metadata version to appstore version --- rsconnect/metadata.py | 4 ++-- tests/test_metadata.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index c2fc70cb..e69fb1ac 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -18,7 +18,7 @@ from .log import logger from .models import AppMode, AppModes -METADATA_RECORD_VERSION = 1 +APPSTORE_VERSION = 1 def config_dirname(platform=sys.platform, env=os.environ): """Get the user's configuration directory path for this platform.""" @@ -447,7 +447,7 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode): app_guid=app_guid, title=title, app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode, - record_version=METADATA_RECORD_VERSION, + appstore_version=APPSTORE_VERSION, ), ) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 651d20cf..935d4bb2 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -199,6 +199,7 @@ def test_get(self): title="Important Title", app_mode="static", filename="/path/to/file", + appstore_version=1, ), ) @@ -212,6 +213,7 @@ def test_get(self): title="Untitled", app_mode="jupyter-static", filename="/path/to/file", + appstore_version=1, ), ) From f75817cbb2bebe21ee7d98043e725bdb529dc7b0 Mon Sep 17 00:00:00 2001 From: Bincheng Wu Date: Mon, 1 May 2023 16:41:53 +0000 Subject: [PATCH 03/13] Change appstore_version to a local variable --- rsconnect/metadata.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index e69fb1ac..e9d867ad 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -18,7 +18,6 @@ from .log import logger from .models import AppMode, AppModes -APPSTORE_VERSION = 1 def config_dirname(platform=sys.platform, env=os.environ): """Get the user's configuration directory path for this platform.""" @@ -404,12 +403,13 @@ class AppStore(DataStore): hash is derived from the entry point file name. """ - def __init__(self, app_file): + def __init__(self, app_file, appstore_version=1): base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json" super(AppStore, self).__init__( join(dirname(app_file), "rsconnect-python", base_name), join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"), ) + self.appstore_version = appstore_version def get(self, server_url): """ @@ -447,7 +447,7 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode): app_guid=app_guid, title=title, app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode, - appstore_version=APPSTORE_VERSION, + appstore_version=self.appstore_version, ), ) From 4e9428ae8ca5b457c96bf04b38a3a4938e1a5bdd Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 3 May 2023 12:47:50 -0500 Subject: [PATCH 04/13] implement publishing static outputs for cloud --- rsconnect/actions.py | 3 +- rsconnect/api.py | 214 +++++++++++++++--------- rsconnect/main.py | 18 +- tests/test_main.py | 189 +++++++++++++++++++-- tests/testdata/py3/static/index.html | 9 + tests/testdata/py3/static/manifest.json | 16 ++ 6 files changed, 344 insertions(+), 105 deletions(-) create mode 100644 tests/testdata/py3/static/index.html create mode 100644 tests/testdata/py3/static/manifest.json diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 46ca1161..57d4c1c0 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -224,8 +224,7 @@ def test_server(connect_server): def test_rstudio_server(server: api.PositServer): with api.PositClient(server) as client: try: - result = client.get_current_user() - server.handle_bad_response(result) + client.get_current_user() except RSConnectException as exc: raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc)) diff --git a/rsconnect/api.py b/rsconnect/api.py index 21427efc..701e6963 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -24,7 +24,7 @@ from .certificates import read_certificate_file from .http_support import HTTPResponse, HTTPServer, append_to_path, CookieJar from .log import logger, connect_logger, cls_logged, console_logger -from .models import AppModes +from .models import AppMode, AppModes from .metadata import ServerStore, AppStore from .exception import RSConnectException from .bundle import _default_title, fake_module_file_from_directory @@ -700,12 +700,13 @@ def upload_rstudio_bundle(self, prepare_deploy_result, bundle_size: int, content @cls_logged("Deploying bundle ...") def deploy_bundle( self, - app_id: int = None, + app_id: typing.Union[str, int] = None, deployment_name: str = None, title: str = None, title_is_default: bool = False, bundle: IO = None, env_vars=None, + app_mode=None, ): app_id = app_id or self.get("app_id") deployment_name = deployment_name or self.get("deployment_name") @@ -713,6 +714,7 @@ def deploy_bundle( title_is_default = title_is_default or self.get("title_is_default") bundle = bundle or self.get("bundle") env_vars = env_vars or self.get("env_vars") + app_mode = app_mode or self.get("app_mode") if isinstance(self.remote_server, RSConnectServer): result = self.client.deploy( @@ -734,7 +736,7 @@ def deploy_bundle( if isinstance(self.remote_server, ShinyappsServer): shinyapps_service = ShinyappsService(self.client, self.remote_server) prepare_deploy_result = shinyapps_service.prepare_deploy( - app_id, + typing.cast(int, app_id), deployment_name, bundle_size, bundle_hash, @@ -748,9 +750,10 @@ def deploy_bundle( deployment_name, bundle_size, bundle_hash, + app_mode, ) self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) - cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) + cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.application_id) print("Application successfully deployed to {}".format(prepare_deploy_result.app_url)) webbrowser.open_new(prepare_deploy_result.app_url) @@ -868,7 +871,7 @@ def validate_app_mode(self, *args, **kwargs): elif isinstance(self.remote_server, PositServer): try: app = get_rstudio_app_info(self.remote_server, app_id) - existing_app_mode = AppModes.get_by_cloud_name(app.json_data["mode"]) + existing_app_mode = AppModes.get_by_cloud_name(app["mode"]) except RSConnectException as e: raise RSConnectException( f"{e} Try setting the --new flag to overwrite the previous deployment." @@ -1026,7 +1029,7 @@ def upload(self, path, presigned_checksum, bundle_size, contents): class PrepareDeployResult: - def __init__(self, app_id: int, app_url: str, bundle_id: int, presigned_url: str, presigned_checksum: str): + def __init__(self, app_id: str, app_url: str, bundle_id: int, presigned_url: str, presigned_checksum: str): self.app_id = app_id self.app_url = app_url self.bundle_id = bundle_id @@ -1036,7 +1039,14 @@ def __init__(self, app_id: int, app_url: str, bundle_id: int, presigned_url: str class PrepareDeployOutputResult(PrepareDeployResult): def __init__( - self, app_id: int, app_url: str, bundle_id: int, presigned_url: str, presigned_checksum: str, output_id: int + self, + app_id: str, + app_url: str, + bundle_id: int, + presigned_url: str, + presigned_checksum: str, + output_id: int, + application_id: int, ): super().__init__( app_id=app_id, @@ -1046,6 +1056,7 @@ def __init__( presigned_checksum=presigned_checksum, ) self.output_id = output_id + self.application_id = application_id class PositClient(HTTPServer): @@ -1071,6 +1082,15 @@ def _get_canonical_request_signature(self, request): result = hmac.new(self._key, request.encode(), hashlib.sha256).hexdigest() return base64.b64encode(result.encode()).decode() + def _tweak_response(self, response): + return ( + response.json_data + if ( + response.status and response.status >= 200 and response.status <= 299 and response.json_data is not None + ) + else response + ) + def get_extra_headers(self, url, method, body): canonical_request_method = method.upper() canonical_request_path = parse.urlparse(url).path @@ -1097,10 +1117,14 @@ def get_extra_headers(self, url, method, body): } def get_application(self, application_id): - return self.get("/v1/applications/{}".format(application_id)) + response = self.get("/v1/applications/{}".format(application_id)) + self._server.handle_bad_response(response) + return response def get_content(self, content_id): - return self.get("/v1/content/{}".format(content_id)) + response = self.get("/v1/content/{}".format(content_id)) + self._server.handle_bad_response(response) + return response def create_application(self, account_id, application_name): application_data = { @@ -1108,23 +1132,32 @@ def create_application(self, account_id, application_name): "name": application_name, "template": "shiny", } - return self.post("/v1/applications/", body=application_data) + response = self.post("/v1/applications/", body=application_data) + self._server.handle_bad_response(response) + return response - def create_output(self, name, project_id=None, space_id=None): - data = { - "name": name, - "space": space_id, - "project": project_id, - } - return self.post("/v1/outputs/", body=data) + def create_output(self, name: str, application_type: str, project_id=None, space_id=None): + data = {"name": name, "space": space_id, "project": project_id, "application_type": application_type} + response = self.post("/v1/outputs/", body=data) + self._server.handle_bad_response(response) + return response + + def create_revision(self, content_id): + response = self.post("/v1/outputs/{}/revisions".format(content_id), body={}) + self._server.handle_bad_response(response) + return response def get_accounts(self): - return self.get("/v1/accounts/") + response = self.get("/v1/accounts/") + self._server.handle_bad_response(response) + return response def _get_applications_like_name_page(self, name: str, offset: int): - return self.get( + response = self.get( "/v1/applications?filter=name:like:{}&offset={}&count=100&use_advanced_filters=true".format(name, offset) ) + self._server.handle_bad_response(response) + return response def create_bundle(self, application_id: int, content_type: str, content_length: int, checksum: str): bundle_data = { @@ -1133,20 +1166,29 @@ def create_bundle(self, application_id: int, content_type: str, content_length: "content_length": content_length, "checksum": checksum, } - result = self.post("/v1/bundles", body=bundle_data) - return result + response = self.post("/v1/bundles", body=bundle_data) + self._server.handle_bad_response(response) + return response def set_bundle_status(self, bundle_id, bundle_status): - return self.post("/v1/bundles/{}/status".format(bundle_id), body={"status": bundle_status}) + response = self.post("/v1/bundles/{}/status".format(bundle_id), body={"status": bundle_status}) + self._server.handle_bad_response(response) + return response def deploy_application(self, bundle_id, app_id): - return self.post("/v1/applications/{}/deploy".format(app_id), body={"bundle": bundle_id, "rebuild": False}) + response = self.post("/v1/applications/{}/deploy".format(app_id), body={"bundle": bundle_id, "rebuild": False}) + self._server.handle_bad_response(response) + return response def get_task(self, task_id): - return self.get("/v1/tasks/{}".format(task_id), query_params={"legacy": "true"}) + response = self.get("/v1/tasks/{}".format(task_id), query_params={"legacy": "true"}) + self._server.handle_bad_response(response) + return response def get_current_user(self): - return self.get("/v1/users/me") + response = self.get("/v1/users/me") + self._server.handle_bad_response(response) + return response def wait_until_task_is_successful(self, task_id, timeout=get_timeout()): print() @@ -1154,11 +1196,10 @@ def wait_until_task_is_successful(self, task_id, timeout=get_timeout()): start_time = time.time() while time.time() - start_time < timeout: task = self.get_task(task_id) - self._server.handle_bad_response(task) - finished = task.json_data["finished"] - status = task.json_data["status"] - description = task.json_data["description"] - error = task.json_data["error"] + finished = task["finished"] + status = task["status"] + description = task["description"] + error = task["error"] if finished: break @@ -1181,12 +1222,12 @@ def get_applications_like_name(self, name): self._server.handle_bad_response(results) offset = 0 - while len(applications) < int(results.json_data["total"]): + while len(applications) < int(results["total"]): results = self._get_applications_like_name_page(name, offset) self._server.handle_bad_response(results) - applications = results.json_data["applications"] + applications = results["applications"] applications.extend(applications) - offset += int(results.json_data["count"]) + offset += int(results["count"]) return [app["name"] for app in applications] @@ -1203,9 +1244,7 @@ def __init__(self, rstudio_client: PositClient, server: ShinyappsServer): def prepare_deploy(self, app_id: typing.Optional[int], app_name: str, bundle_size: int, bundle_hash: str): accounts = self._rstudio_client.get_accounts() self._server.handle_bad_response(accounts) - account = next( - filter(lambda acct: acct["name"] == self._server.account_name, accounts.json_data["accounts"]), None - ) + account = next(filter(lambda acct: acct["name"] == self._server.account_name, accounts["accounts"]), None) # TODO: also check this during `add` command if account is None: raise RSConnectException( @@ -1217,8 +1256,8 @@ def prepare_deploy(self, app_id: typing.Optional[int], app_name: str, bundle_siz else: application = self._rstudio_client.get_application(app_id) self._server.handle_bad_response(application) - app_id_int = application.json_data["id"] - app_url = application.json_data["url"] + app_id_int = application["id"] + app_url = application["url"] bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) self._server.handle_bad_response(bundle) @@ -1226,18 +1265,25 @@ def prepare_deploy(self, app_id: typing.Optional[int], app_name: str, bundle_siz return PrepareDeployResult( app_id_int, app_url, - int(bundle.json_data["id"]), - bundle.json_data["presigned_url"], - bundle.json_data["presigned_checksum"], + int(bundle["id"]), + bundle["presigned_url"], + bundle["presigned_checksum"], ) def do_deploy(self, bundle_id, app_id): - bundle_status_response = self._rstudio_client.set_bundle_status(bundle_id, "ready") - self._server.handle_bad_response(bundle_status_response) - + self._rstudio_client.set_bundle_status(bundle_id, "ready") deploy_task = self._rstudio_client.deploy_application(bundle_id, app_id) - self._server.handle_bad_response(deploy_task) - self._rstudio_client.wait_until_task_is_successful(deploy_task.json_data["id"]) + self._rstudio_client.wait_until_task_is_successful(deploy_task["id"]) + + +class CloudResourceIdentity: + def __init__(self, identifier): + if isinstance(identifier, str) and identifier.startswith("lucid:content:"): + self.content_id = identifier.split(":")[-1] + self.application_id = None + else: + self.application_id = identifier + self.content_id = None class CloudService: @@ -1245,64 +1291,73 @@ class CloudService: Encapsulates operations involving multiple API calls to Posit Cloud. """ - def __init__(self, rstudio_client: PositClient, server: CloudServer): - self._rstudio_client = rstudio_client + def __init__(self, cloud_client: PositClient, server: CloudServer): + self._rstudio_client = cloud_client self._server = server def prepare_deploy( self, - app_id: typing.Optional[int], + app_id: typing.Optional[typing.Union[str, int]], app_name: str, bundle_size: int, bundle_hash: str, + app_mode: AppMode, ): + application_type = "static" if app_mode == AppModes.STATIC else "connect" + if app_id is None: + # this is a new deployment. + # get the Posit Cloud project so that we can associate the deployment with it project_application_id = os.getenv("LUCID_APPLICATION_ID") if project_application_id is not None: project_application = self._rstudio_client.get_application(project_application_id) - self._server.handle_bad_response(project_application) - project_id = project_application.json_data["content_id"] + project_id = project_application["content_id"] project = self._rstudio_client.get_content(project_id) - self._server.handle_bad_response(project) - space_id = project.json_data["space_id"] + space_id = project["space_id"] else: project_id = None space_id = None - output = self._rstudio_client.create_output(name=app_name, project_id=project_id, space_id=space_id) - self._server.handle_bad_response(output) - app_id = output.json_data["source_id"] - application = self._rstudio_client.get_application(app_id) - self._server.handle_bad_response(application) + output = self._rstudio_client.create_output( + name=app_name, application_type=application_type, project_id=project_id, space_id=space_id + ) + app_id_int = output["source_id"] else: - application = self._rstudio_client.get_application(app_id) - self._server.handle_bad_response(application) - output = self._rstudio_client.get_content(application.json_data["content_id"]) - self._server.handle_bad_response(output) + # this is a redeployment + cloud_resource_identity = CloudResourceIdentity(app_id) + if cloud_resource_identity.content_id: + output = self._rstudio_client.get_content(cloud_resource_identity.content_id) + app_id_int = output["source_id"] + content_id = output["id"] + else: + application = self._rstudio_client.get_application(app_id) + # content_id will appear on static applications as output_id + content_id = application.get("content_id") or application.get("output_id") + app_id_int = application["id"] + + if application_type == "static": + revision = self._rstudio_client.create_revision(content_id) + app_id_int = revision["application_id"] - app_id_int = application.json_data["id"] - app_url = output.json_data["url"] - output_id = output.json_data["id"] + app_url = output["url"] + output_id = output["id"] bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) - self._server.handle_bad_response(bundle) return PrepareDeployOutputResult( - app_id=app_id_int, + app_id="lucid:content:{}".format(output_id), + application_id=app_id_int, app_url=app_url, - bundle_id=int(bundle.json_data["id"]), - presigned_url=bundle.json_data["presigned_url"], - presigned_checksum=bundle.json_data["presigned_checksum"], + bundle_id=int(bundle["id"]), + presigned_url=bundle["presigned_url"], + presigned_checksum=bundle["presigned_checksum"], output_id=output_id, ) def do_deploy(self, bundle_id, app_id): - bundle_status_response = self._rstudio_client.set_bundle_status(bundle_id, "ready") - self._server.handle_bad_response(bundle_status_response) - + self._rstudio_client.set_bundle_status(bundle_id, "ready") deploy_task = self._rstudio_client.deploy_application(bundle_id, app_id) - self._server.handle_bad_response(deploy_task) - self._rstudio_client.wait_until_task_is_successful(deploy_task.json_data["id"]) + self._rstudio_client.wait_until_task_is_successful(deploy_task["id"]) def verify_server(connect_server): @@ -1372,10 +1427,13 @@ def get_app_info(connect_server, app_id): def get_rstudio_app_info(server, app_id): + cloud_resource_identity = CloudResourceIdentity(app_id) with PositClient(server) as client: - result = client.get_application(app_id) - server.handle_bad_response(result) - return result + if cloud_resource_identity.content_id: + response = client.get_content(cloud_resource_identity.content_id) + return response["source"] + else: + return client.get_application(app_id) def get_app_config(connect_server, app_id): diff --git a/rsconnect/main.py b/rsconnect/main.py index 79744139..388533ac 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -145,12 +145,12 @@ def wrapper(*args, **kwargs): return wrapper -def rstudio_args(func): +def cloud_shinyapps_args(func): @click.option( "--account", "-A", envvar=["SHINYAPPS_ACCOUNT"], - help="The shinyapps.io account name.", + help="The shinyapps.io/Posit Cloud account name.", ) @click.option( "--token", @@ -412,7 +412,7 @@ def bootstrap( help="The path to trusted TLS CA certificates.", ) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") -@rstudio_args +@cloud_shinyapps_args @click.pass_context def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, verbose): @@ -961,7 +961,7 @@ def deploy_voila( ) @server_args @content_args -@rstudio_args +@cloud_shinyapps_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @cli_exception_handler def deploy_manifest( @@ -1133,12 +1133,13 @@ def deploy_quarto( # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="html", - short_help="Deploy html content to Posit Connect.", - help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect."), + short_help="Deploy html content to Posit Connect or Posit Cloud.", + help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect or Posit Cloud."), no_args_is_help=True, ) @server_args @content_args +@cloud_shinyapps_args @click.option( "--entrypoint", "-e", @@ -1177,6 +1178,9 @@ def deploy_html( api_key: str = None, insecure: bool = False, cacert: typing.IO = None, + account: str = None, + token: str = None, + secret: str = None, ): kwargs = locals() ce = None @@ -1218,7 +1222,7 @@ def generate_deploy_python(app_mode, alias, min_version): ) @server_args @content_args - @rstudio_args + @cloud_shinyapps_args @click.option( "--entrypoint", "-e", diff --git a/tests/test_main.py b/tests/test_main.py index ae43e44c..eeb285f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -31,7 +31,7 @@ def _error_to_response(error): HTTPretty is unable to show errors resulting from callbacks, so this method attempts to raise failure visibility by passing the return back through HTTP. """ - return [500, {}, str(error)] + return [555, {}, str(error)] def _load_json(data): @@ -274,22 +274,6 @@ def test_deploy_manifest_cloud(self, project_application_id, project_id): body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), status=200, ) - httpretty.register_uri( - httpretty.GET, - "https://api.posit.cloud/v1/applications" - "?filter=name:like:shinyapp&offset=0&count=100&use_advanced_filters=true", - body=open("tests/testdata/rstudio-responses/get-applications.json", "r").read(), - adding_headers={"Content-Type": "application/json"}, - status=200, - ) - httpretty.register_uri( - httpretty.GET, - "https://api.posit.cloud/v1/accounts/", - body=open("tests/testdata/rstudio-responses/get-accounts.json", "r").read(), - adding_headers={"Content-Type": "application/json"}, - status=200, - ) - if project_application_id: httpretty.register_uri( httpretty.GET, @@ -317,7 +301,12 @@ def post_output_callback(request, uri, response_headers): space_id = 917733 if project_application_id else None parsed_request = _load_json(request.body) try: - assert parsed_request == {"name": "myapp", "space": space_id, "project": project_id} + assert parsed_request == { + "name": "myapp", + "space": space_id, + "project": project_id, + "application_type": "connect", + } except AssertionError as e: return _error_to_response(e) return [ @@ -451,6 +440,170 @@ def post_deploy_callback(request, uri, response_headers): if project_application_id: del os.environ["LUCID_APPLICATION_ID"] + @httpretty.activate(verbose=True, allow_net_connect=False) + @pytest.mark.parametrize( + "command_and_arg", + [ + [ + "manifest", + get_manifest_path("static", parent="py3"), + ], + [ + "html", + join(os.path.dirname(__file__), "testdata", "py3", "static"), + ], + ], + ids=["using manifest", "using html"], + ) + def test_deploy_static_cloud(self, command_and_arg): + """ + Verify that an app with app_mode as static can deploy to cloud. + """ + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + httpretty.register_uri( + httpretty.GET, + "https://api.posit.cloud/v1/users/me", + body=open("tests/testdata/rstudio-responses/get-user.json", "r").read(), + status=200, + ) + + def post_output_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + try: + assert parsed_request == {"name": "myapp", "space": None, "project": None, "application_type": "static"} + except AssertionError as e: + return _error_to_response(e) + return [ + 201, + {"Content-Type": "application/json"}, + open("tests/testdata/rstudio-responses/create-output.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.GET, + "https://api.posit.cloud/v1/applications/8442", + body=open("tests/testdata/rstudio-responses/get-output-application.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + if True: + httpretty.register_uri( + httpretty.POST, + "https://api.posit.cloud/v1/outputs/", + body=post_output_callback, + ) + + def post_bundle_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + del parsed_request["checksum"] + del parsed_request["content_length"] + try: + assert parsed_request == { + "application": 8442, + "content_type": "application/x-tar", + } + except AssertionError as e: + return _error_to_response(e) + return [ + 201, + {"Content-Type": "application/json"}, + open("tests/testdata/rstudio-responses/create-bundle.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.posit.cloud/v1/bundles", + body=post_bundle_callback, + ) + + httpretty.register_uri( + httpretty.PUT, + "https://lucid-uploads-staging.s3.amazonaws.com/bundles/application-8442/" + "6c9ed0d91ee9426687d9ac231d47dc83.tar.gz" + "?AWSAccessKeyId=theAccessKeyId" + "&Signature=dGhlU2lnbmF0dXJlCg%3D%3D" + "&content-md5=D1blMI4qTiI3tgeUOYXwkg%3D%3D" + "&content-type=application%2Fx-tar" + "&x-amz-security-token=dGhlVG9rZW4K" + "&Expires=1656715153", + body="", + ) + + def post_bundle_status_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + try: + assert parsed_request == {"status": "ready"} + except AssertionError as e: + return _error_to_response(e) + return [303, {"Location": "https://api.posit.cloud/v1/bundles/12640"}, ""] + + httpretty.register_uri( + httpretty.POST, + "https://api.posit.cloud/v1/bundles/12640/status", + body=post_bundle_status_callback, + ) + + httpretty.register_uri( + httpretty.GET, + "https://api.posit.cloud/v1/bundles/12640", + body=open("tests/testdata/rstudio-responses/get-accounts.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + def post_deploy_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + try: + assert parsed_request == {"bundle": 12640, "rebuild": False} + except AssertionError as e: + return _error_to_response(e) + return [ + 303, + {"Location": "https://api.posit.cloud/v1/tasks/333"}, + open("tests/testdata/rstudio-responses/post-deploy.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.posit.cloud/v1/applications/8442/deploy", + body=post_deploy_callback, + ) + + httpretty.register_uri( + httpretty.GET, + "https://api.posit.cloud/v1/tasks/333", + body=open("tests/testdata/rstudio-responses/get-task.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + runner = CliRunner() + args = [ + "deploy", + *command_and_arg, + "--server", + "rstudio.cloud", + "--account", + "some-account", + "--token", + "someToken", + "--secret", + "c29tZVNlY3JldAo=", + "--title", + "myApp", + ] + try: + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + def test_deploy_api(self): target = optional_target(get_api_path("flask")) runner = CliRunner() diff --git a/tests/testdata/py3/static/index.html b/tests/testdata/py3/static/index.html new file mode 100644 index 00000000..714aac52 --- /dev/null +++ b/tests/testdata/py3/static/index.html @@ -0,0 +1,9 @@ + + + + +

Very basic HTML page

+

There's not much here.

+ + + diff --git a/tests/testdata/py3/static/manifest.json b/tests/testdata/py3/static/manifest.json new file mode 100644 index 00000000..663bd525 --- /dev/null +++ b/tests/testdata/py3/static/manifest.json @@ -0,0 +1,16 @@ +{ + "metadata": { + "appmode": "static", + "primary_rmd": null, + "primary_html": "index.html", + "content_category": null, + "has_parameters": false + }, + "packages": null, + "files": { + "index.html": { + "checksum": "b7fffcb24c6883ea12b6e80c129daceb" + } + }, + "users": null +} From 4c96ebe28d76010ad8de6de11051dc2472b9b935 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 3 May 2023 16:56:08 -0500 Subject: [PATCH 05/13] use app store file versioning to determine cloud whether id is for application or content --- rsconnect/api.py | 37 ++++++++++++++++--------------------- rsconnect/metadata.py | 20 ++++++++++++++------ tests/test_main.py | 10 +++++++--- tests/test_metadata.py | 4 ++-- 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 701e6963..c5dc2f79 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -25,7 +25,7 @@ from .http_support import HTTPResponse, HTTPServer, append_to_path, CookieJar from .log import logger, connect_logger, cls_logged, console_logger from .models import AppMode, AppModes -from .metadata import ServerStore, AppStore +from .metadata import ServerStore, AppStore, CURRENT_APP_STORE_VERSION from .exception import RSConnectException from .bundle import _default_title, fake_module_file_from_directory from .timeouts import get_timeout @@ -745,12 +745,9 @@ def deploy_bundle( shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) else: cloud_service = CloudService(self.client, self.remote_server) + app_store_version = self.get("app_store_version") prepare_deploy_result = cloud_service.prepare_deploy( - app_id, - deployment_name, - bundle_size, - bundle_hash, - app_mode, + app_id, deployment_name, bundle_size, bundle_hash, app_mode, app_store_version ) self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) cloud_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.application_id) @@ -851,11 +848,16 @@ def validate_app_mode(self, *args, **kwargs): raise RSConnectException("Specify either a new deploy or an app ID but not both.") existing_app_mode = None + app_store_version = CURRENT_APP_STORE_VERSION if not new: if app_id is None: # Possible redeployment - check for saved metadata. # Use the saved app information unless overridden by the user. - app_id, existing_app_mode = app_store.resolve(self.remote_server.url, app_id, app_mode) + app_id, existing_app_mode, app_store_version = app_store.resolve( + self.remote_server.url, app_id, app_mode + ) + self.state["app_store_version"] = app_store_version + logger.debug("Using app mode from app %s: %s" % (app_id, app_mode)) elif app_id is not None: # Don't read app metadata if app-id is specified. Instead, we need @@ -888,6 +890,7 @@ def validate_app_mode(self, *args, **kwargs): self.state["app_id"] = app_id self.state["app_mode"] = app_mode + self.state["app_store_version"] = app_store_version return self @property @@ -1276,16 +1279,6 @@ def do_deploy(self, bundle_id, app_id): self._rstudio_client.wait_until_task_is_successful(deploy_task["id"]) -class CloudResourceIdentity: - def __init__(self, identifier): - if isinstance(identifier, str) and identifier.startswith("lucid:content:"): - self.content_id = identifier.split(":")[-1] - self.application_id = None - else: - self.application_id = identifier - self.content_id = None - - class CloudService: """ Encapsulates operations involving multiple API calls to Posit Cloud. @@ -1302,6 +1295,7 @@ def prepare_deploy( bundle_size: int, bundle_hash: str, app_mode: AppMode, + app_store_version: typing.Optional[int], ): application_type = "static" if app_mode == AppModes.STATIC else "connect" @@ -1324,12 +1318,13 @@ def prepare_deploy( app_id_int = output["source_id"] else: # this is a redeployment - cloud_resource_identity = CloudResourceIdentity(app_id) - if cloud_resource_identity.content_id: - output = self._rstudio_client.get_content(cloud_resource_identity.content_id) + if app_store_version is not None: + # versioned app store files store content id in app_id + output = self._rstudio_client.get_content(app_id) app_id_int = output["source_id"] content_id = output["id"] else: + # unversioned appstore files (deployed using a prior release) store application id id app_id application = self._rstudio_client.get_application(app_id) # content_id will appear on static applications as output_id content_id = application.get("content_id") or application.get("output_id") @@ -1345,7 +1340,7 @@ def prepare_deploy( bundle = self._rstudio_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) return PrepareDeployOutputResult( - app_id="lucid:content:{}".format(output_id), + app_id=output_id, application_id=app_id_int, app_url=app_url, bundle_id=int(bundle["id"]), diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index e9d867ad..647945c9 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -380,6 +380,9 @@ def sha1(s): return base64.urlsafe_b64encode(m.digest()).decode("utf-8").rstrip("=") +CURRENT_APP_STORE_VERSION = 1 + + class AppStore(DataStore): """ Defines a metadata store for information about where the app has been @@ -396,20 +399,23 @@ class AppStore(DataStore): * App GUID * Title * App mode + * App store file version The metadata file for an app is written in the same directory as the app's entry point file, if that directory is writable. Otherwise, it is stored in the user's config directory under `applications/{hash}.json` where the - hash is derived from the entry point file name. + hash is derived from the entry point file name. The file contains a version + field, which is incremented when backwards-incompatible file format changes + are made. """ - def __init__(self, app_file, appstore_version=1): + def __init__(self, app_file, app_store_version=CURRENT_APP_STORE_VERSION): base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json" super(AppStore, self).__init__( join(dirname(app_file), "rsconnect-python", base_name), join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"), ) - self.appstore_version = appstore_version + self.app_store_version = app_store_version def get(self, server_url): """ @@ -447,7 +453,7 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode): app_guid=app_guid, title=title, app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode, - appstore_version=self.appstore_version, + app_store_version=self.app_store_version, ), ) @@ -455,7 +461,7 @@ def resolve(self, server, app_id, app_mode): metadata = self.get(server) if metadata is None: logger.debug("No previous deployment to this server was found; this will be a new deployment.") - return app_id, app_mode + return app_id, app_mode, CURRENT_APP_STORE_VERSION logger.debug("Found previous deployment data in %s" % self.get_path()) @@ -465,7 +471,9 @@ def resolve(self, server, app_id, app_mode): # app mode cannot be changed on redeployment app_mode = AppModes.get_by_name(metadata.get("app_mode")) - return app_id, app_mode + + app_store_version = metadata.get("app_store_version") + return app_id, app_mode, app_store_version DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build") diff --git a/tests/test_main.py b/tests/test_main.py index eeb285f8..198bcd09 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -442,7 +442,8 @@ def post_deploy_callback(request, uri, response_headers): @httpretty.activate(verbose=True, allow_net_connect=False) @pytest.mark.parametrize( - "command_and_arg", + "command,arg", + [ [ "manifest", @@ -455,10 +456,12 @@ def post_deploy_callback(request, uri, response_headers): ], ids=["using manifest", "using html"], ) - def test_deploy_static_cloud(self, command_and_arg): + def test_deploy_static_cloud(self, command, arg): """ Verify that an app with app_mode as static can deploy to cloud. """ + shutil.rmtree(os.path.join(arg, 'rsconnect-python'), ignore_errors=True) + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) original_server_value = os.environ.pop("CONNECT_SERVER", None) @@ -583,7 +586,8 @@ def post_deploy_callback(request, uri, response_headers): runner = CliRunner() args = [ "deploy", - *command_and_arg, + command, + arg, "--server", "rstudio.cloud", "--account", diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 935d4bb2..49c37d68 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -199,7 +199,7 @@ def test_get(self): title="Important Title", app_mode="static", filename="/path/to/file", - appstore_version=1, + app_store_version=1, ), ) @@ -213,7 +213,7 @@ def test_get(self): title="Untitled", app_mode="jupyter-static", filename="/path/to/file", - appstore_version=1, + app_store_version=1, ), ) From 09484fb7d43db71ce9fb87a7af38cd37a05fb348 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 3 May 2023 17:03:12 -0500 Subject: [PATCH 06/13] update changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6302eb08..cf3f386f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- `deploy html` and `deploy manifest` now support deployment to Posit Cloud. + +### Changed +- Cloud deployments accept the content id instead of application id in the --app-id field. +- The `app_id` field in application store files also stores the content id instead of the application id. +- Application store files include a `version` field, set to 1 for this release. + +## Unreleased + ### Fixed - getdefaultlocale deprecated From 7684c8fafab8210ada5312b4bbcfcdbdd5adaa4a Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 3 May 2023 17:10:47 -0500 Subject: [PATCH 07/13] fix get_rstudio_app_info invocation --- rsconnect/api.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index c5dc2f79..824e872d 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1422,14 +1422,9 @@ def get_app_info(connect_server, app_id): def get_rstudio_app_info(server, app_id): - cloud_resource_identity = CloudResourceIdentity(app_id) with PositClient(server) as client: - if cloud_resource_identity.content_id: - response = client.get_content(cloud_resource_identity.content_id) - return response["source"] - else: - return client.get_application(app_id) - + response = client.get_content(app_id) + return response["source"] def get_app_config(connect_server, app_id): """ From e9f590e73cbede8a1673b07edcd9a9feaf070956 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 3 May 2023 17:29:43 -0500 Subject: [PATCH 08/13] get output using old app store versions --- rsconnect/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rsconnect/api.py b/rsconnect/api.py index 824e872d..71146f73 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1330,10 +1330,13 @@ def prepare_deploy( content_id = application.get("content_id") or application.get("output_id") app_id_int = application["id"] + output = self._rstudio_client.get_content(content_id) + if application_type == "static": revision = self._rstudio_client.create_revision(content_id) app_id_int = revision["application_id"] + app_url = output["url"] output_id = output["id"] From 0e4c5a8b95e7e16fbf165ccc09f28de3896f6b2b Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 3 May 2023 17:33:43 -0500 Subject: [PATCH 09/13] formatting --- rsconnect/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 71146f73..62e675d2 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1336,7 +1336,6 @@ def prepare_deploy( revision = self._rstudio_client.create_revision(content_id) app_id_int = revision["application_id"] - app_url = output["url"] output_id = output["id"] @@ -1429,6 +1428,7 @@ def get_rstudio_app_info(server, app_id): response = client.get_content(app_id) return response["source"] + def get_app_config(connect_server, app_id): """ Return the configuration information for an application that has been created From 0589d31ff3104ca736c942db21b1347e870395f2 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Thu, 4 May 2023 09:49:53 -0500 Subject: [PATCH 10/13] add unit tests for CloudService --- rsconnect/api.py | 15 ++-- tests/test_api.py | 194 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 199 insertions(+), 10 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 62e675d2..7c535a12 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1048,7 +1048,6 @@ def __init__( bundle_id: int, presigned_url: str, presigned_checksum: str, - output_id: int, application_id: int, ): super().__init__( @@ -1058,7 +1057,6 @@ def __init__( presigned_url=presigned_url, presigned_checksum=presigned_checksum, ) - self.output_id = output_id self.application_id = application_id @@ -1284,9 +1282,10 @@ class CloudService: Encapsulates operations involving multiple API calls to Posit Cloud. """ - def __init__(self, cloud_client: PositClient, server: CloudServer): + def __init__(self, cloud_client: PositClient, server: CloudServer, project_application_id: typing.Optional[str] = os.getenv("LUCID_APPLICATION_ID")): self._rstudio_client = cloud_client self._server = server + self._project_application_id = project_application_id def prepare_deploy( self, @@ -1296,15 +1295,14 @@ def prepare_deploy( bundle_hash: str, app_mode: AppMode, app_store_version: typing.Optional[int], - ): + ) -> PrepareDeployOutputResult: application_type = "static" if app_mode == AppModes.STATIC else "connect" if app_id is None: # this is a new deployment. # get the Posit Cloud project so that we can associate the deployment with it - project_application_id = os.getenv("LUCID_APPLICATION_ID") - if project_application_id is not None: - project_application = self._rstudio_client.get_application(project_application_id) + if self._project_application_id is not None: + project_application = self._rstudio_client.get_application(self._project_application_id) project_id = project_application["content_id"] project = self._rstudio_client.get_content(project_id) space_id = project["space_id"] @@ -1324,7 +1322,7 @@ def prepare_deploy( app_id_int = output["source_id"] content_id = output["id"] else: - # unversioned appstore files (deployed using a prior release) store application id id app_id + # unversioned appstore files (deployed using a prior release) store application id in app_id application = self._rstudio_client.get_application(app_id) # content_id will appear on static applications as output_id content_id = application.get("content_id") or application.get("output_id") @@ -1348,7 +1346,6 @@ def prepare_deploy( bundle_id=int(bundle["id"]), presigned_url=bundle["presigned_url"], presigned_checksum=bundle["presigned_checksum"], - output_id=output_id, ) def do_deploy(self, bundle_id, app_id): diff --git a/tests/test_api.py b/tests/test_api.py index d3d41b97..b6ae5905 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,11 +6,21 @@ import sys import io from rsconnect.exception import RSConnectException +from rsconnect.metadata import CURRENT_APP_STORE_VERSION +from rsconnect.models import AppModes from .utils import ( require_api_key, require_connect, ) -from rsconnect.api import RSConnectClient, RSConnectExecutor, RSConnectServer, _to_server_check_list +from rsconnect.api import ( + RSConnectClient, + RSConnectExecutor, + RSConnectServer, + _to_server_check_list, + CloudService, + PositClient, + CloudServer, +) class TestAPI(TestCase): @@ -207,3 +217,185 @@ def test_deploy_existing_application_with_failure(self): app_id = Mock() with self.assertRaises(RSConnectException): client.deploy(app_id, app_name=None, app_title=None, title_is_default=None, tarball=None) + + +class CloudServiceTestCase(TestCase): + def test_prepare_new_deploy(self): + cloud_client = Mock(spec=PositClient) + server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret") + project_application_id = "20" + cloud_service = CloudService( + cloud_client=cloud_client, server=server, project_application_id=project_application_id + ) + + app_id = None + app_name = "my app" + bundle_size = 5000 + bundle_hash = "the_hash" + app_mode = AppModes.PYTHON_SHINY + + cloud_client.get_application.return_value = { + "content_id": 2, + } + cloud_client.get_content.return_value = { + "space_id": 1000, + } + cloud_client.create_output.return_value = { + "id": 1, + "source_id": 10, + "url": "https://posit.cloud/content/1", + } + cloud_client.create_bundle.return_value = { + "id": 100, + "presigned_url": "https://presigned.url", + "presigned_checksum": "the_checksum", + } + + prepare_deploy_result = cloud_service.prepare_deploy( + app_id=app_id, + app_name=app_name, + bundle_size=bundle_size, + bundle_hash=bundle_hash, + app_mode=app_mode, + app_store_version=CURRENT_APP_STORE_VERSION, + ) + + cloud_client.get_application.assert_called_with(project_application_id) + cloud_client.get_content.assert_called_with(2) + cloud_client.create_output.assert_called_with( + name=app_name, application_type="connect", project_id=2, space_id=1000 + ) + cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash) + + assert prepare_deploy_result.app_id == 1 + assert prepare_deploy_result.application_id == 10 + assert prepare_deploy_result.app_url == "https://posit.cloud/content/1" + assert prepare_deploy_result.bundle_id == 100 + assert prepare_deploy_result.presigned_url == "https://presigned.url" + assert prepare_deploy_result.presigned_checksum == "the_checksum" + + def test_prepare_redeploy(self): + cloud_client = Mock(spec=PositClient) + server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret") + project_application_id = "20" + cloud_service = CloudService( + cloud_client=cloud_client, server=server, project_application_id=project_application_id + ) + + app_id = 1 + app_name = "my app" + bundle_size = 5000 + bundle_hash = "the_hash" + app_mode = AppModes.PYTHON_SHINY + + cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"} + cloud_client.create_bundle.return_value = { + "id": 100, + "presigned_url": "https://presigned.url", + "presigned_checksum": "the_checksum", + } + + prepare_deploy_result = cloud_service.prepare_deploy( + app_id=app_id, + app_name=app_name, + bundle_size=bundle_size, + bundle_hash=bundle_hash, + app_mode=app_mode, + app_store_version=CURRENT_APP_STORE_VERSION, + ) + cloud_client.get_content.assert_called_with(1) + cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash) + + assert prepare_deploy_result.app_id == 1 + assert prepare_deploy_result.application_id == 10 + assert prepare_deploy_result.app_url == "https://posit.cloud/content/1" + assert prepare_deploy_result.bundle_id == 100 + assert prepare_deploy_result.presigned_url == "https://presigned.url" + assert prepare_deploy_result.presigned_checksum == "the_checksum" + + def test_prepare_redeploy_static(self): + cloud_client = Mock(spec=PositClient) + server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret") + project_application_id = "20" + cloud_service = CloudService( + cloud_client=cloud_client, server=server, project_application_id=project_application_id + ) + + app_id = 1 + app_name = "my app" + bundle_size = 5000 + bundle_hash = "the_hash" + app_mode = AppModes.STATIC + + cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"} + cloud_client.create_revision.return_value = { + "application_id": 11, + } + cloud_client.create_bundle.return_value = { + "id": 100, + "presigned_url": "https://presigned.url", + "presigned_checksum": "the_checksum", + } + + prepare_deploy_result = cloud_service.prepare_deploy( + app_id=app_id, + app_name=app_name, + bundle_size=bundle_size, + bundle_hash=bundle_hash, + app_mode=app_mode, + app_store_version=CURRENT_APP_STORE_VERSION, + ) + cloud_client.get_content.assert_called_with(1) + cloud_client.create_revision.assert_called_with(1) + cloud_client.create_bundle.assert_called_with(11, "application/x-tar", bundle_size, bundle_hash) + + assert prepare_deploy_result.app_id == 1 + assert prepare_deploy_result.application_id == 11 + assert prepare_deploy_result.app_url == "https://posit.cloud/content/1" + assert prepare_deploy_result.bundle_id == 100 + assert prepare_deploy_result.presigned_url == "https://presigned.url" + assert prepare_deploy_result.presigned_checksum == "the_checksum" + + def test_prepare_redeploy_preversioned_app_store(self): + cloud_client = Mock(spec=PositClient) + server = CloudServer("https://api.posit.cloud", "the_account", "the_token", "the_secret") + project_application_id = "20" + cloud_service = CloudService( + cloud_client=cloud_client, server=server, project_application_id=project_application_id + ) + + app_id = 10 + app_name = "my app" + bundle_size = 5000 + bundle_hash = "the_hash" + app_mode = AppModes.PYTHON_SHINY + + cloud_client.get_application.return_value = { + "id": 10, + "content_id": 1, + } + cloud_client.get_content.return_value = {"id": 1, "source_id": 10, "url": "https://posit.cloud/content/1"} + cloud_client.create_bundle.return_value = { + "id": 100, + "presigned_url": "https://presigned.url", + "presigned_checksum": "the_checksum", + } + + prepare_deploy_result = cloud_service.prepare_deploy( + app_id=app_id, + app_name=app_name, + bundle_size=bundle_size, + bundle_hash=bundle_hash, + app_mode=app_mode, + app_store_version=None, + ) + cloud_client.get_application.assert_called_with(10) + cloud_client.get_content.assert_called_with(1) + cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash) + + assert prepare_deploy_result.app_id == 1 + assert prepare_deploy_result.application_id == 10 + assert prepare_deploy_result.app_url == "https://posit.cloud/content/1" + assert prepare_deploy_result.bundle_id == 100 + assert prepare_deploy_result.presigned_url == "https://presigned.url" + assert prepare_deploy_result.presigned_checksum == "the_checksum" From ec2731d6663282f96d0ff9d99d72f4e7865b2b2b Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Thu, 4 May 2023 10:03:13 -0500 Subject: [PATCH 11/13] fix reloading of LUCID_APPLICATION_ID --- rsconnect/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 7c535a12..053b1f45 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -744,7 +744,7 @@ def deploy_bundle( self.upload_rstudio_bundle(prepare_deploy_result, bundle_size, contents) shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) else: - cloud_service = CloudService(self.client, self.remote_server) + cloud_service = CloudService(self.client, self.remote_server, os.getenv("LUCID_APPLICATION_ID")) app_store_version = self.get("app_store_version") prepare_deploy_result = cloud_service.prepare_deploy( app_id, deployment_name, bundle_size, bundle_hash, app_mode, app_store_version @@ -1282,7 +1282,7 @@ class CloudService: Encapsulates operations involving multiple API calls to Posit Cloud. """ - def __init__(self, cloud_client: PositClient, server: CloudServer, project_application_id: typing.Optional[str] = os.getenv("LUCID_APPLICATION_ID")): + def __init__(self, cloud_client: PositClient, server: CloudServer, project_application_id: typing.Optional[str]): self._rstudio_client = cloud_client self._server = server self._project_application_id = project_application_id From 190c1a940163e65a7d32532be42294cbaf039ec8 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 9 May 2023 13:47:28 -0500 Subject: [PATCH 12/13] refactor to remove global variable --- rsconnect/api.py | 3 +-- rsconnect/metadata.py | 14 +++++++------- tests/test_api.py | 7 +++---- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 053b1f45..93de701a 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -25,7 +25,7 @@ from .http_support import HTTPResponse, HTTPServer, append_to_path, CookieJar from .log import logger, connect_logger, cls_logged, console_logger from .models import AppMode, AppModes -from .metadata import ServerStore, AppStore, CURRENT_APP_STORE_VERSION +from .metadata import ServerStore, AppStore from .exception import RSConnectException from .bundle import _default_title, fake_module_file_from_directory from .timeouts import get_timeout @@ -848,7 +848,6 @@ def validate_app_mode(self, *args, **kwargs): raise RSConnectException("Specify either a new deploy or an app ID but not both.") existing_app_mode = None - app_store_version = CURRENT_APP_STORE_VERSION if not new: if app_id is None: # Possible redeployment - check for saved metadata. diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 647945c9..a69b5753 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -380,9 +380,6 @@ def sha1(s): return base64.urlsafe_b64encode(m.digest()).decode("utf-8").rstrip("=") -CURRENT_APP_STORE_VERSION = 1 - - class AppStore(DataStore): """ Defines a metadata store for information about where the app has been @@ -409,13 +406,12 @@ class AppStore(DataStore): are made. """ - def __init__(self, app_file, app_store_version=CURRENT_APP_STORE_VERSION): + def __init__(self, app_file): base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json" super(AppStore, self).__init__( join(dirname(app_file), "rsconnect-python", base_name), join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"), ) - self.app_store_version = app_store_version def get(self, server_url): """ @@ -453,7 +449,7 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode): app_guid=app_guid, title=title, app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode, - app_store_version=self.app_store_version, + app_store_version=self.current_app_store_version, ), ) @@ -461,7 +457,7 @@ def resolve(self, server, app_id, app_mode): metadata = self.get(server) if metadata is None: logger.debug("No previous deployment to this server was found; this will be a new deployment.") - return app_id, app_mode, CURRENT_APP_STORE_VERSION + return app_id, app_mode, self.current_app_store_version logger.debug("Found previous deployment data in %s" % self.get_path()) @@ -475,6 +471,10 @@ def resolve(self, server, app_id, app_mode): app_store_version = metadata.get("app_store_version") return app_id, app_mode, app_store_version + @property + def current_app_store_version(self): + return 1 + DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build") diff --git a/tests/test_api.py b/tests/test_api.py index b6ae5905..18060bf7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,7 +6,6 @@ import sys import io from rsconnect.exception import RSConnectException -from rsconnect.metadata import CURRENT_APP_STORE_VERSION from rsconnect.models import AppModes from .utils import ( require_api_key, @@ -257,7 +256,7 @@ def test_prepare_new_deploy(self): bundle_size=bundle_size, bundle_hash=bundle_hash, app_mode=app_mode, - app_store_version=CURRENT_APP_STORE_VERSION, + app_store_version=1, ) cloud_client.get_application.assert_called_with(project_application_id) @@ -301,7 +300,7 @@ def test_prepare_redeploy(self): bundle_size=bundle_size, bundle_hash=bundle_hash, app_mode=app_mode, - app_store_version=CURRENT_APP_STORE_VERSION, + app_store_version=1, ) cloud_client.get_content.assert_called_with(1) cloud_client.create_bundle.assert_called_with(10, "application/x-tar", bundle_size, bundle_hash) @@ -343,7 +342,7 @@ def test_prepare_redeploy_static(self): bundle_size=bundle_size, bundle_hash=bundle_hash, app_mode=app_mode, - app_store_version=CURRENT_APP_STORE_VERSION, + app_store_version=1, ) cloud_client.get_content.assert_called_with(1) cloud_client.create_revision.assert_called_with(1) From 1d2f9733437e97f961d03ef76ce2862e322774bb Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Tue, 9 May 2023 14:03:04 -0500 Subject: [PATCH 13/13] refactor current_app_store_version -> version as constructor parameter --- rsconnect/metadata.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index a69b5753..e4f81515 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -406,12 +406,13 @@ class AppStore(DataStore): are made. """ - def __init__(self, app_file): + def __init__(self, app_file, version=1): base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json" super(AppStore, self).__init__( join(dirname(app_file), "rsconnect-python", base_name), join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"), ) + self.version = version def get(self, server_url): """ @@ -449,7 +450,7 @@ def set(self, server_url, filename, app_url, app_id, app_guid, title, app_mode): app_guid=app_guid, title=title, app_mode=app_mode.name() if isinstance(app_mode, AppMode) else app_mode, - app_store_version=self.current_app_store_version, + app_store_version=self.version, ), ) @@ -457,7 +458,7 @@ def resolve(self, server, app_id, app_mode): metadata = self.get(server) if metadata is None: logger.debug("No previous deployment to this server was found; this will be a new deployment.") - return app_id, app_mode, self.current_app_store_version + return app_id, app_mode, self.version logger.debug("Found previous deployment data in %s" % self.get_path()) @@ -471,10 +472,6 @@ def resolve(self, server, app_id, app_mode): app_store_version = metadata.get("app_store_version") return app_id, app_mode, app_store_version - @property - def current_app_store_version(self): - return 1 - DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build")