From 0e76188163b95b03f77465cb7a08cc5c57498000 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Wed, 22 Jun 2022 15:56:45 -0500 Subject: [PATCH 1/3] initial work to support shinyapps.io hosting --- rsconnect/actions.py | 37 +++-- rsconnect/actions_content.py | 12 +- rsconnect/api.py | 283 ++++++++++++++++++++++++++++++----- rsconnect/http_support.py | 29 +++- rsconnect/main.py | 203 ++++++++++++++++--------- rsconnect/metadata.py | 75 +++++++--- tests/test_api.py | 6 +- tests/test_main.py | 46 +++++- tests/test_metadata.py | 63 +++++--- 9 files changed, 584 insertions(+), 170 deletions(-) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 4ddfe3ce..79a1a3d2 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -304,7 +304,7 @@ def check_server_capabilities(connect_server, capability_functions, details_sour raise api.RSConnectException(message) -def _make_deployment_name(connect_server, title, force_unique) -> str: +def _make_deployment_name(remote_server: api.RemoteServer, title: str, force_unique: bool) -> str: """ Produce a name for a deployment based on its title. It is assumed that the title is already defaulted and validated as appropriate (meaning the title @@ -315,7 +315,7 @@ def _make_deployment_name(connect_server, title, force_unique) -> str: that we collapse repeating underscores and, if the name is too short, it is padded to the left with underscores. - :param connect_server: the information needed to interact with the Connect server. + :param remote_server: the information needed to interact with the Connect server. :param title: the title to start with. :param force_unique: a flag noting whether the generated name must be forced to be unique. @@ -327,7 +327,12 @@ def _make_deployment_name(connect_server, title, force_unique) -> str: # Now, make sure it's unique, if needed. if force_unique: - name = api.find_unique_name(connect_server, name) + if isinstance(remote_server, api.RSConnectServer): + name = api.find_unique_name(remote_server, name) + else: + # TODO (mslynch): is this necessary to check? + pass + # raise NotImplementedError("TODO (mslynch): find unique name from lucid-server") return name @@ -1384,7 +1389,7 @@ def _generate_gather_basic_deployment_info_for_python(app_mode: AppMode) -> typi """ def gatherer( - connect_server: api.RSConnectServer, + remote_server: api.RemoteServer, app_store: AppStore, directory: str, entry_point: str, @@ -1393,7 +1398,7 @@ def gatherer( title: str, ) -> typing.Tuple[str, int, str, str, bool, AppMode]: return _gather_basic_deployment_info_for_framework( - connect_server, + remote_server, app_store, directory, entry_point, @@ -1414,7 +1419,7 @@ def gatherer( def _gather_basic_deployment_info_for_framework( - connect_server: api.RSConnectServer, + remote_server: api.RemoteServer, app_store: AppStore, directory: str, entry_point: str, @@ -1426,7 +1431,7 @@ def _gather_basic_deployment_info_for_framework( """ Helps to gather the necessary info for performing a deployment. - :param connect_server: the Connect server information. + :param remote_server: the server information. :param app_store: the store for the specified directory. :param directory: the primary file being deployed. :param entry_point: the entry point for the API in ': format. if @@ -1451,13 +1456,17 @@ def _gather_basic_deployment_info_for_framework( 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(connect_server.url, app_id, app_mode) + app_id, existing_app_mode = app_store.resolve(remote_server.url, app_id, app_mode) 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 # to get this from Connect. - app = api.get_app_info(connect_server, app_id) - existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) + if isinstance(remote_server, api.RSConnectServer): + app = api.get_app_info(remote_server, app_id) + existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) + else: + # TODO this + raise NotImplementedError("TODO (mslynch): get app mode from lucid-server") if existing_app_mode and app_mode != existing_app_mode: msg = ( "Deploying with mode '%s',\n" @@ -1475,7 +1484,7 @@ def _gather_basic_deployment_info_for_framework( return ( entry_point, app_id, - _make_deployment_name(connect_server, title, app_id is None), + _make_deployment_name(remote_server, title, app_id is None), title, default_title, app_mode, @@ -1634,7 +1643,7 @@ def create_quarto_deployment_bundle( def deploy_bundle( - connect_server: api.RSConnectServer, + remote_server: api.RemoteServer, app_id: int, name: str, title: str, @@ -1645,7 +1654,7 @@ def deploy_bundle( """ Deploys the specified bundle. - :param connect_server: the Connect server information. + :param remote_server: the server information. :param app_id: the ID of the app to deploy, if this is a redeploy. :param name: the name for the deploy. :param title: the title for the deploy. @@ -1655,7 +1664,7 @@ def deploy_bundle( :return: application information about the deploy. This includes the ID of the task that may be queried for deployment progress. """ - return api.do_bundle_deploy(connect_server, app_id, name, title, title_is_default, bundle, env_vars) + return api.do_bundle_deploy(remote_server, app_id, name, title, title_is_default, bundle, env_vars) def spool_deployment_log(connect_server, app, log_callback): diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index d1b797a3..2c42158b 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -10,7 +10,7 @@ import semver -from .api import RSConnect, RSConnectException, emit_task_log +from .api import RSConnectClient, RSConnectException, emit_task_log from .log import logger from .models import BuildStatus, ContentGuidWithBundle from .metadata import ContentBuildStore @@ -36,7 +36,7 @@ def build_add_content(connect_server, content_guids_with_bundle): + "please wait for it to finish before adding new content." ) - with RSConnect(connect_server, timeout=120) as client: + with RSConnectClient(connect_server, timeout=120) as client: if len(content_guids_with_bundle) == 1: all_content = [client.content_get(content_guids_with_bundle[0].guid)] else: @@ -227,7 +227,7 @@ def _monitor_build(connect_server, content_items): def _build_content_item(connect_server, content, poll_wait): init_content_build_store(connect_server) - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: # Pending futures will still try to execute when ThreadPoolExecutor.shutdown() is called # so just exit immediately if the current build has been aborted. # ThreadPoolExecutor.shutdown(cancel_futures=) isnt available until py3.9 @@ -291,7 +291,7 @@ def download_bundle(connect_server, guid_with_bundle): """ :param guid_with_bundle: models.ContentGuidWithBundle """ - with RSConnect(connect_server, timeout=120) as client: + with RSConnectClient(connect_server, timeout=120) as client: # bundle_id not provided so grab the latest if not guid_with_bundle.bundle_id: content = client.get_content(guid_with_bundle.guid) @@ -310,7 +310,7 @@ def get_content(connect_server, guid): :param guid: a single guid as a string or list of guids. :return: a list of content items. """ - with RSConnect(connect_server, timeout=120) as client: + with RSConnectClient(connect_server, timeout=120) as client: if isinstance(guid, str): result = [client.get_content(guid)] else: @@ -321,7 +321,7 @@ def get_content(connect_server, guid): def search_content( connect_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by ): - with RSConnect(connect_server, timeout=120) as client: + with RSConnectClient(connect_server, timeout=120) as client: result = client.search_content() result = _apply_content_filters( result, published, unpublished, content_type, r_version, py_version, title_contains diff --git a/rsconnect/api.py b/rsconnect/api.py index 73d2532a..06046964 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1,9 +1,20 @@ """ RStudio Connect API client and utility functions """ - +import abc +import base64 +import calendar +import datetime +import hashlib +import hmac import time +import typing +import webbrowser from _ssl import SSLError +from urllib import parse +from urllib.parse import urlparse + +import click from .http_support import HTTPResponse, HTTPServer, append_to_path, CookieJar from .log import logger @@ -17,19 +28,18 @@ def __init__(self, message, cause=None): self.cause = cause -class RSConnectServer(object): - """ - A simple class to encapsulate the information needed to interact with an - instance of the Connect server. - """ - - def __init__(self, url, api_key, insecure=False, ca_data=None): - self.url = url - self.api_key = api_key - self.insecure = insecure - self.ca_data = ca_data - # This is specifically not None. - self.cookie_jar = CookieJar() +class AbstractRemoteServer: + # @property + # @abc.abstractmethod + # def url(self) -> str: + # pass + # + # @property + # @abc.abstractmethod + # def remote_name(self) -> str: + # pass + url: str + remote_name: str def handle_bad_response(self, response): if isinstance(response, HTTPResponse): @@ -41,21 +51,56 @@ def handle_bad_response(self, response): # search page so trap that since we know we're expecting JSON from Connect. This # also catches all error conditions which we will report as "not running Connect". else: - if response.json_data and "error" in response.json_data: - error = "The Connect server reported an error: %s" % response.json_data["error"] + if response.json_data and "error" in response.json_data and response.json_data["error"] is not None: + error = "%s reported an error: %s" % (self.remote_name, response.json_data["error"]) raise RSConnectException(error) if response.status < 200 or response.status > 299: raise RSConnectException( - "Received an unexpected response from RStudio Connect: %s %s" - % (response.status, response.reason) + "Received an unexpected response from %s: %s %s" + % (self.remote_name, response.status, response.reason) ) -class RSConnect(HTTPServer): - def __init__(self, server, cookies=None, timeout=30): +class ShinyappsServer(AbstractRemoteServer): + """ + A simple class to encapsulate the information needed to interact with an + instance of the shinyapps.io server. + """ + + remote_name = "shinyapps.io" + + def __init__(self, url: str, account_name: str, token: str, secret: str): + self.url = url + self.account_name = account_name + self.token = token + self.secret = secret + + +class RSConnectServer(AbstractRemoteServer): + """ + A simple class to encapsulate the information needed to interact with an + instance of the Connect server. + """ + + remote_name = "RStudio Connect" + + def __init__(self, url, api_key, insecure=False, ca_data=None): + self.url = url + self.api_key = api_key + self.insecure = insecure + self.ca_data = ca_data + # This is specifically not None. + self.cookie_jar = CookieJar() + + +RemoteServer = typing.Union[ShinyappsServer, RSConnectServer] + + +class RSConnectClient(HTTPServer): + def __init__(self, server: RSConnectServer, cookies=None, timeout=30): if cookies is None: cookies = server.cookie_jar - super(RSConnect, self).__init__( + super().__init__( append_to_path(server.url, "__api__"), server.insecure, server.ca_data, @@ -268,6 +313,149 @@ def output_task_log(task_status, last_status, log_callback): return new_last_status +class S3Client(HTTPServer): + def upload(self, path, presigned_checksum, bundle_size, contents): + headers = { + "content-type": "application/x-tar", + "content-length": str(bundle_size), + "content-md5": presigned_checksum, + } + return self.put(path, headers=headers, body=contents, decode_response=False) + + +class ShinyappsClient(HTTPServer): + def __init__(self, shinyapps_server: ShinyappsServer, timeout: int = 30): + self._token = shinyapps_server.token + self._key = base64.b64decode(shinyapps_server.secret) + self._server = shinyapps_server + super().__init__(shinyapps_server.url, timeout=timeout) + + def _get_canonical_request(self, method, path, timestamp, content_hash): + return "\n".join([method, path, timestamp, content_hash]) + + def _get_canonical_request_signature(self, request): + result = hmac.new(self._key, request.encode(), hashlib.sha256).hexdigest() + return base64.b64encode(result.encode()).decode() + + def get_extra_headers(self, url, method, body): + canonical_request_method = method.upper() + canonical_request_path = parse.urlparse(url).path + + # TODO (mslynch): there has got to be a better way than this + timetuple = datetime.datetime.utcnow().utctimetuple() + canonical_request_date = "%s, %02d %s %04d %02d:%02d:%02d %s" % ( + ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][timetuple[6]], + timetuple[2], + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][timetuple[1] - 1], + timetuple[0], + timetuple[3], + timetuple[4], + timetuple[5], + "GMT", + ) + + # get request checksum + md5 = hashlib.md5() + body = body or b"" + body_bytes = body if isinstance(body, bytes) else body.encode() + md5.update(body_bytes) + canonical_request_checksum = md5.hexdigest() + + canonical_request = self._get_canonical_request( + canonical_request_method, canonical_request_path, canonical_request_date, canonical_request_checksum + ) + + signature = self._get_canonical_request_signature(canonical_request) + + return { + "X-Auth-Token": "{0}".format(self._token), + "X-Auth-Signature": "{0}; version=1".format(signature), + "Date": canonical_request_date, + "X-Content-Checksum": canonical_request_checksum, + } + + def create_application(self, account_id, application_name): + application_data = { + "account": account_id, + "name": application_name, + "template": "shiny", + } + return self.post("/v1/applications/", body=application_data) + + def get_accounts(self): + return self.get("/v1/accounts/") + + # def get_current_user(self): + # return self.get('v1/users/me') + + def create_bundle(self, application_id: int, content_type: str, content_length: int, checksum: str): + bundle_data = { + "application": application_id, + "content_type": content_type, + "content_length": content_length, + "checksum": checksum, + } + return self.post("/v1/bundles", body=bundle_data) + + def set_bundle_status(self, bundle_id, bundle_status): + return self.post(f"/v1/bundles/{bundle_id}/status", body={"status": bundle_status}) + + def deploy_application(self, bundle_id, app_id): + return self.post(f"/v1/applications/{app_id}/deploy", body={"bundle": bundle_id, "rebuild": False}) + + def get_task(self, task_id): + return self.get(f"/v1/tasks/{task_id}", query_params={"legacy": "true"}) + + def wait_until_task_is_successful(self, task_id, timeout=60): + counter = 1 + status = None + + while counter < timeout and status not in ["success", "failed", "error"]: + task = self.get_task(task_id) + self._server.handle_bad_response(task) + status = task.json_data["status"] + description = task.json_data["description"] + + click.secho(f"Waiting: {status} - {description}") + + if status == "success": + break + + time.sleep(2) + counter += 1 + click.secho(f"Task done: {description}") + + def prepare_deploy(self, app_id, app_name, app_title, title_is_default, bundle_size, bundle_hash, env_vars=None): + accounts = self.get_accounts() + self._server.handle_bad_response(accounts) + account = next( + filter(lambda account: account["name"] == self._server.account_name, accounts.json_data["accounts"]), None + ) + # TODO: also check this during `add` command + if account is None: + raise RSConnectException( + "No account found by name : %s for given user credential" % self._server.account_name + ) + + application = self.create_application(account["id"], app_name) + self._server.handle_bad_response(application) + + bundle = self.create_bundle(application.json_data["id"], "application/x-tar", bundle_size, bundle_hash) + + # TODO (mslynch) _server isn't meant to handle S3 errors + self._server.handle_bad_response(bundle) + + return {"app_id": application.json_data["id"], "app_url": application.json_data["url"], **bundle.json_data} + + def do_deploy(self, bundle_id, app_id): + bundle_status_response = self.set_bundle_status(bundle_id, "ready") + self._server.handle_bad_response(bundle_status_response) + + deploy_task = self.deploy_application(bundle_id, app_id) + self._server.handle_bad_response(deploy_task) + self.wait_until_task_is_successful(deploy_task.json_data["id"]) + + def verify_server(connect_server): """ Verify that the given server information represents a Connect instance that is @@ -278,7 +466,7 @@ def verify_server(connect_server): :return: the server settings from the Connect server. """ try: - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.server_settings() connect_server.handle_bad_response(result) return result @@ -294,7 +482,7 @@ def verify_api_key(connect_server): :param connect_server: the Connect server information, including the API key to test. :return: the username of the user to whom the API key belongs. """ - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.me() if isinstance(result, HTTPResponse): if result.json_data and "code" in result.json_data and result.json_data["code"] == 30: @@ -311,7 +499,7 @@ def get_python_info(connect_server): :param connect_server: the Connect server information. :return: the Python installation information from Connect. """ - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.python_settings() connect_server.handle_bad_response(result) return result @@ -325,7 +513,7 @@ def get_app_info(connect_server, app_id): :param app_id: the ID (numeric or GUID) of the application to get info for. :return: the Python installation information from Connect. """ - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.app_get(app_id) connect_server.handle_bad_response(result) return result @@ -340,17 +528,17 @@ def get_app_config(connect_server, app_id): :param app_id: the ID (numeric or GUID) of the application to get the info for. :return: the Python installation information from Connect. """ - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.app_config(app_id) connect_server.handle_bad_response(result) return result -def do_bundle_deploy(connect_server, app_id, name, title, title_is_default, bundle, env_vars): +def do_bundle_deploy(remote_server: RemoteServer, app_id, name, title, title_is_default, bundle, env_vars): """ Deploys the specified bundle. - :param connect_server: the Connect server information. + :param remote_server: the server information. :param app_id: the ID of the app to deploy, if this is a redeploy. :param name: the name for the deploy. :param title: the title for the deploy. @@ -360,10 +548,37 @@ def do_bundle_deploy(connect_server, app_id, name, title, title_is_default, bund :return: application information about the deploy. This includes the ID of the task that may be queried for deployment progress. """ - with RSConnect(connect_server, timeout=120) as client: - result = client.deploy(app_id, name, title, title_is_default, bundle, env_vars) - connect_server.handle_bad_response(result) - return result + if isinstance(remote_server, RSConnectServer): + with RSConnectClient(remote_server, timeout=120) as client: + result = client.deploy(app_id, name, title, title_is_default, bundle, env_vars) + remote_server.handle_bad_response(result) + return result + else: + contents = bundle.read() + bundle_size = len(contents) + bundle_hash = hashlib.md5(contents).hexdigest() + + with ShinyappsClient(remote_server, timeout=120) as client: + prepare_deploy_result = client.prepare_deploy( + app_id, name, title, title_is_default, bundle_size, bundle_hash, env_vars + ) + + parsed_upload_url = urlparse(prepare_deploy_result["presigned_url"]) + with S3Client(f"{parsed_upload_url.scheme}://{parsed_upload_url.netloc}", timeout=120) as client: + upload_result = client.upload( + prepare_deploy_result["presigned_url"], + prepare_deploy_result["presigned_checksum"], + bundle_size, + contents, + ) + remote_server.handle_bad_response(upload_result) + + with ShinyappsClient(remote_server, timeout=120) as client: + deploy_result = client.do_deploy(prepare_deploy_result["id"], prepare_deploy_result["app_id"]) + + webbrowser.open_new(prepare_deploy_result["app_url"]) + + return {"app_url": prepare_deploy_result["app_url"], "app_id": prepare_deploy_result["id"], "app_guid": None} def emit_task_log( @@ -393,7 +608,7 @@ def emit_task_log( :return: the ultimate URL where the deployed app may be accessed and the sequence of log lines. The log lines value will be None if a log callback was provided. """ - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error) connect_server.handle_bad_response(result) app_config = client.app_config(app_id) @@ -428,7 +643,7 @@ def retrieve_matching_apps(connect_server, filters=None, limit=None, mapping_fun maximum = limit finished = False - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: while not finished: response = client.app_search(search_filters) connect_server.handle_bad_response(response) diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index decab61d..52f6eaa4 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -225,14 +225,34 @@ def post(self, path, query_params=None, body=None): def patch(self, path, query_params=None, body=None): return self.request("PATCH", path, query_params, body) - def request(self, method, path, query_params=None, body=None, maximum_redirects=5, decode_response=True): + def put(self, path, query_params=None, body=None, headers=None, decode_response=True): + if headers is None: + headers = {} + return self.request( + "PUT", path, query_params=query_params, body=body, headers=headers, decode_response=decode_response + ) + + def request( + self, + method, + path, + query_params=None, + body=None, + maximum_redirects=5, + decode_response=True, + headers=None, + ): path = self._get_full_path(path) - extra_headers = None + extra_headers = headers or {} if isinstance(body, (dict, list)): body = json.dumps(body).encode("utf-8") extra_headers = {"Content-Type": "application/json; charset=utf-8"} + extra_headers = {**extra_headers, **self.get_extra_headers(path, method, body)} return self._do_request(method, path, query_params, body, maximum_redirects, extra_headers, decode_response) + def get_extra_headers(self, url, method, body): + return {} + def _do_request( self, method, path, query_params, body, maximum_redirects, extra_headers=None, decode_response=True ): @@ -287,13 +307,14 @@ def _do_request( logger.debug("--> Redirected to: %s" % next_url) + redirect_extra_headers = self.get_extra_headers(next_url, "GET", body) return self._do_request( - method, + "GET", next_url, query_params, body, maximum_redirects - 1, - extra_headers, + {**extra_headers, **redirect_extra_headers}, ) self._handle_set_cookie(response) diff --git a/rsconnect/main.py b/rsconnect/main.py index 645da90d..8cd7e1c4 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -226,7 +226,7 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): # noinspection SpellCheckingInspection @cli.command( - short_help="Define a nickname for an RStudio Connect server.", + short_help="Define a nickname for an RStudio Connect or shinyapps.io server and credential.", help=( "Associate a simple nickname with the information needed to interact with an RStudio Connect server. " "Specifying an existing nickname will cause its stored information to be replaced by what is given " @@ -234,17 +234,23 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): ), ) @click.option("--name", "-n", required=True, help="The nickname of the RStudio Connect server to deploy to.") +@click.option( + "--target", + "-t", + type=click.Choice(["connect", "shinyapps"]), + default="connect", + envvar="RSCONNECT_SERVER_TYPE", + help="The target platform of this credential (Connect or shinyapps.io).", +) @click.option( "--server", "-s", - required=True, envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", ) @click.option( "--api-key", "-k", - required=True, envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", ) @@ -262,27 +268,79 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): type=click.File(), help="The path to trusted TLS CA certificates.", ) +@click.option( + "--token", + "-T", + envvar="SHINYAPPS_TOKEN", + help="The shinyapps.io token.", +) +@click.option( + "--secret", + "-S", + envvar="SHINYAPPS_SECRET", + help="The shinyapps.io token secret.", +) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") -def add(name, server, api_key, insecure, cacert, verbose): +def add(name, target, server, api_key, insecure, cacert, token, secret, verbose): set_verbosity(verbose) old_server = server_store.get_by_name(name) - # Server must be pingable and the API key must work to be added. - real_server, _ = _test_server_and_api(server, api_key, insecure, cacert) - - server_store.set( - name, - real_server.url, - real_server.api_key, - real_server.insecure, - real_server.ca_data, - ) + # validate options + if target == "connect": + # # disallowed + # if token is not None: + # raise api.RSConnectException("Token may not be specified when using target type 'connect'.") + # if secret is not None: + # raise api.RSConnectException("Secret may not be specified when using target type 'connect'.") + + # required + if server is None: + raise api.RSConnectException("Server must be specified when using target type 'connect'.") + if api_key is None: + raise api.RSConnectException("API key must be specified when using target type 'connect'.") + else: + # # disallowed + # if api_key is not None: + # raise api.RSConnectException("API key may not be specified when using target type 'shinyapps'.") + # if insecure is not None: + # raise api.RSConnectException("Insecure may not be specified when using target type 'shinyapps'.") + # if cacert is not None: + # raise api.RSConnectException("CA certificate may not be specified when using target type 'shinyapps'.") + + # required + if token is None: + raise api.RSConnectException("Server must be specified when using target type 'shinyapps'.") + if secret is None: + raise api.RSConnectException("API key must be specified when using target type 'shinyapps'.") + + if target == "connect": + # Server must be pingable and the API key must work to be added. + real_server, _ = _test_server_and_api(server, api_key, insecure, cacert) + + server_store.set( + name, + target, + real_server.url, + real_server.api_key, + real_server.insecure, + real_server.ca_data, + ) - if old_server: - click.echo('Updated server "%s" with URL %s' % (name, real_server.url)) + if old_server: + click.echo('Updated Connect server "%s" with URL %s' % (name, real_server.url)) + else: + click.echo('Added Connect server "%s" with URL %s' % (name, real_server.url)) else: - click.echo('Added server "%s" with URL %s' % (name, real_server.url)) + shinyapps_server = api.ShinyappsServer(server or "https://api.shinyapps.io", name, token, secret) + # TODO (mslynch): check credential and account here + server_store.set( + name, target, shinyapps_server.url, token=shinyapps_server.token, secret=shinyapps_server.secret + ) + if old_server: + click.echo('Added shinyapps.io credential "%s".' % name) + else: + click.echo('Updated shinyapps.io credential "%s".' % name) @cli.command( @@ -459,7 +517,7 @@ def deploy(): pass -def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_required=True): +def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_required=True) -> api.RemoteServer: """ Validate that the user gave us enough information to talk to a Connect server. @@ -470,38 +528,44 @@ def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_r :param ca_cert: the name of a CA certs file containing certificates to use. :param api_key_is_required: a flag that notes whether the API key is required or may be omitted. - :return: a ConnectServer object that carries all the right info. + :return: a ConnectServer or ShinyappsServer object that carries all the right info. """ ca_data = ca_cert and text_type(ca_cert.read()) if name and url: raise api.RSConnectException("You must specify only one of -n/--name or -s/--server, not both.") - real_server, api_key, insecure, ca_data, from_store = server_store.resolve(name, url, api_key, insecure, ca_data) + server_data = server_store.resolve(name, url, api_key, insecure, ca_data) # This can happen if the user specifies neither --name or --server and there's not # a single default to go with. - if not real_server: + if not server_data.url: raise api.RSConnectException("You must specify one of -n/--name or -s/--server.") - connect_server = api.RSConnectServer(real_server, None, insecure, ca_data) + if server_data.target == "connect": - # If our info came from the command line, make sure the URL really works. - if not from_store: - connect_server, _ = test_server(connect_server) + connect_server = api.RSConnectServer(server_data.url, None, server_data.insecure, server_data.ca_data) - connect_server.api_key = api_key + # If our info came from the command line, make sure the URL really works. + if not server_data.from_store: + connect_server, _ = test_server(connect_server) - if not connect_server.api_key: - if api_key_is_required: - raise api.RSConnectException('An API key must be specified for "%s".' % connect_server.url) - return connect_server + connect_server.api_key = server_data.api_key - # If our info came from the command line, make sure the key really works. - if not from_store: - _ = test_api_key(connect_server) + if not connect_server.api_key: + if api_key_is_required: + raise api.RSConnectException('An API key must be specified for "%s".' % connect_server.url) + return connect_server - return connect_server + # If our info came from the command line, make sure the key really works. + if not server_data.from_store: + _ = test_api_key(connect_server) + + return connect_server + else: + # TODO (mslynch): validate shinyapps data + # TODO (mslynch): replace nickname with account name + return api.ShinyappsServer(server_data.url, server_data.name, server_data.token, server_data.secret) def _warn_on_ignored_manifest(directory): @@ -578,7 +642,7 @@ def _warn_on_ignored_requirements(directory, requirements_file_name): def _deploy_bundle( - connect_server, + remote_server: api.RemoteServer, app_store, primary_path, app_id, @@ -589,10 +653,10 @@ def _deploy_bundle( bundle, env_vars, ): - """ + """œ Does the work of uploading a prepared bundle. - :param connect_server: the Connect server information. + :param remote_server: the Connect server information. :param app_store: the store where data is saved about deployments. :param primary_path: the base path (file or directory) that's being deployed. :param app_id: the ID of the app. @@ -604,12 +668,12 @@ def _deploy_bundle( :param env_vars: list of NAME=VALUE pairs to be set as the app environment """ with cli_feedback("Uploading bundle"): - app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle, env_vars) + app = deploy_bundle(remote_server, app_id, name, title, title_is_default, bundle, env_vars) with cli_feedback("Saving deployment data"): # Note we are NOT saving image into the deployment record for now. app_store.set( - connect_server.url, + remote_server.url, abspath(primary_path), app["app_url"], app["app_id"], @@ -618,26 +682,27 @@ def _deploy_bundle( app_mode, ) - with cli_feedback(""): - click.secho("\nDeployment log:") - app_url, _, _ = spool_deployment_log(connect_server, app, click.echo) - click.secho("Deployment completed successfully.") - click.secho(" Dashboard content URL: ", nl=False) - click.secho(app_url, fg="green") - click.secho(" Direct content URL: ", nl=False) - click.secho(app["app_url"], fg="green") - - # save the config URL, replacing the old app URL we got during deployment - # (which is the Open Solo URL). - app_store.set( - connect_server.url, - abspath(primary_path), - app_url, - app["app_id"], - app["app_guid"], - app["title"], - app_mode, - ) + if isinstance(remote_server, api.RSConnectServer): + with cli_feedback(""): + click.secho("\nDeployment log:") + app_url, _, _ = spool_deployment_log(remote_server, app, click.echo) + click.secho("Deployment completed successfully.") + click.secho(" Dashboard content URL: ", nl=False) + click.secho(app_url, fg="green") + click.secho(" Direct content URL: ", nl=False) + click.secho(app["app_url"], fg="green") + + # save the config URL, replacing the old app URL we got during deployment + # (which is the Open Solo URL). + app_store.set( + remote_server.url, + abspath(primary_path), + app_url, + app["app_id"], + app["app_guid"], + app["title"], + app_mode, + ) # noinspection SpellCheckingInspection,DuplicatedCode @@ -1262,15 +1327,15 @@ def _deploy_by_framework( set_verbosity(verbose) with cli_feedback("Checking arguments"): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) + remote_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) module_file = fake_module_file_from_directory(directory) extra_files = validate_extra_files(directory, extra_files) app_store = AppStore(module_file) entrypoint, app_id, deployment_name, title, default_title, app_mode = gatherer( - connect_server, app_store, directory, entrypoint, new, app_id, title + remote_server, app_store, directory, entrypoint, new, app_id, title ) - click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url)) + click.secho(' Deploying %s to server "%s"' % (directory, remote_server.url)) _warn_on_ignored_manifest(directory) _warn_if_no_requirements_file(directory) @@ -1279,11 +1344,13 @@ def _deploy_by_framework( with cli_feedback("Inspecting Python environment"): _, environment = get_python_env_info(module_file, python, conda, force_generate) - with cli_feedback("Checking server capabilities"): - checks = [are_apis_supported_on_server] - if environment.package_manager == "conda": - checks.append(is_conda_supported_on_server) - check_server_capabilities(connect_server, checks) + # TODO (mslynch): check shinyapps capabilities? hard-code? skip? + if isinstance(remote_server, api.RSConnectServer): + with cli_feedback("Checking server capabilities"): + checks = [are_apis_supported_on_server] + if environment.package_manager == "conda": + checks.append(is_conda_supported_on_server) + check_server_capabilities(remote_server, checks) _warn_on_ignored_conda_env(environment) @@ -1296,7 +1363,7 @@ def _deploy_by_framework( ) _deploy_bundle( - connect_server, + remote_server, app_store, directory, app_id, diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 72ba7a22..df16de4b 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -7,6 +7,7 @@ import os import glob import sys +import typing from datetime import datetime, timezone from os.path import abspath, basename, dirname, exists, join from urllib.parse import urlparse @@ -137,7 +138,7 @@ def _get_sorted_values(self, sort_by): """ Return all the values in the store sorted by the given lambda expression. - :param sort_by: a lambda expression to use to sort the values.. + :param sort_by: a lambda expression to use to sort the values. :return: the sorted values. """ return sorted(self._data.values(), key=sort_by) @@ -217,6 +218,30 @@ def save(self, open=open): os.chmod(self._real_path, 0o600) +class ServerData: + def __init__( + self, + name: str, + target: str, + url: str, + from_store: bool, + api_key: typing.Optional[str] = None, + insecure: typing.Optional[bool] = None, + ca_data: typing.Optional[str] = None, + token: typing.Optional[str] = None, + secret: typing.Optional[str] = None, + ): + self.name = name + self.target = target + self.url = url + self.from_store = from_store + self.api_key = api_key + self.insecure = insecure + self.ca_data = ca_data + self.token = token + self.secret = secret + + class ServerStore(DataStore): """Defines a metadata store for server information. @@ -252,26 +277,29 @@ def get_all_servers(self): """ return self._get_sorted_values(lambda s: s["name"]) - def set(self, name, url, api_key, insecure=False, ca_data=None): + def set(self, name, target, url, api_key=None, insecure=False, ca_data=None, token=None, secret=None): """ Add (or update) information about a Connect server :param name: the nickname for the Connect server. + :param target: the type of target (connect/shinyapps). :param url: the full URL for the Connect server. :param api_key: the API key to use to authenticate with the Connect server. :param insecure: a flag to disable TLS verification. :param ca_data: client side certificate data to use for TLS. + :param token: shinyapps.io token. + :param token: shinyapps.io secret. """ - self._set( - name, - dict( - name=name, - url=url, - api_key=api_key, - insecure=insecure, - ca_cert=ca_data, - ), + common_data = dict( + target=target, + name=name, + url=url, ) + if target == "connect": + target_data = dict(api_key=api_key, insecure=insecure, ca_cert=ca_data) + else: + target_data = dict(token=token, secret=secret) + self._set(name, {**common_data, **target_data}) def remove_by_name(self, name): """ @@ -289,7 +317,7 @@ def remove_by_url(self, url): """ return self._remove_by_value_attr("name", "url", url) - def resolve(self, name, url, api_key, insecure, ca_data): + def resolve(self, name, url, api_key, insecure, ca_data, target="connect"): """ This function will resolve the given inputs into a set of server information. It assumes that either `name` or `url` is provided. @@ -327,15 +355,28 @@ def resolve(self, name, url, api_key, insecure, ca_data): entry = None if entry: - return ( + return ServerData( + name, + entry["target"], entry["url"], - entry["api_key"], - entry["insecure"], - entry["ca_cert"], True, + insecure=entry.get("insecure"), + ca_data=entry.get("ca_cert"), + api_key=entry.get("api_key"), + token=entry.get("token"), + secret=entry.get("secret"), ) else: - return url, api_key, insecure, ca_data, False + return ServerData( + name, + # TODO (mslynch): this function needs to receive target + "connect", + url, + False, + insecure=insecure, + ca_data=ca_data, + api_key=api_key, + ) def sha1(s): diff --git a/tests/test_api.py b/tests/test_api.py index ec12637d..c3e29c76 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,6 @@ from unittest import TestCase -from rsconnect.api import RSConnect +from rsconnect.api import RSConnectClient class TestAPI(TestCase): @@ -14,12 +14,12 @@ def test_output_task_log(self): } output = [] - self.assertEqual(RSConnect.output_task_log(task_status, 0, output.append), 3) + self.assertEqual(RSConnectClient.output_task_log(task_status, 0, output.append), 3) self.assertEqual(lines, output) task_status["last_status"] = 4 task_status["status"] = ["line 4"] - self.assertEqual(RSConnect.output_task_log(task_status, 3, output.append), 4) + self.assertEqual(RSConnectClient.output_task_log(task_status, 3, output.append), 4) self.assertEqual(len(output), 4) self.assertEqual(output[3], "line 4") diff --git a/tests/test_main.py b/tests/test_main.py index 9ebc2319..b5c198af 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -21,7 +21,7 @@ class TestMain(TestCase): def test_validate_deploy_to_args(self): - server_store.set("fake", "http://example.com", None) + server_store.set("fake", "connect", "http://example.com", None) try: with self.assertRaises(RSConnectException): @@ -113,3 +113,47 @@ def test_deploy_api(self): result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) self.assertIn("OK", result.output) + + def test_add_connect(self): + connect_server = self.require_connect() + api_key = self.require_api_key() + runner = CliRunner() + result = runner.invoke(cli, ["add", "--name", "my-connect", "--server", connect_server, "--api-key", api_key]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("OK", result.output) + + def test_add_shinyapps(self): + runner = CliRunner() + result = runner.invoke( + cli, + [ + "add", + "--target", + "shinyapps", + "--name", + "my-shinyapps", + "--token", + "someToken", + "--secret", + "c29tZVNlY3JldAo=", + ], + ) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("shinyapps.io credential", result.output) + + def test_add_shinyapps_missing_options(self): + runner = CliRunner() + result = runner.invoke( + cli, + [ + "add", + "--target", + "shinyapps", + "--name", + "my-shinyapps", + "--token", + "someToken", + ], + ) + self.assertEqual(result.exit_code, 1, result.output) + self.assertEqual(str(result.exception), "API key must be specified when using target type 'shinyapps'.") diff --git a/tests/test_metadata.py b/tests/test_metadata.py index ffcefe48..b4f60ceb 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -17,9 +17,10 @@ def setUp(self): self.server_store_path = join(self.tempDir, "servers.json") self.assertFalse(exists(self.server_store_path)) - self.server_store.set("foo", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") - self.server_store.set("bar", "http://connect.remote", "differentApiKey", insecure=True) - self.assertEqual(len(self.server_store.get_all_servers()), 2, "Unexpected servers after setup") + self.server_store.set("foo", "connect", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") + self.server_store.set("bar", "connect", "http://connect.remote", "differentApiKey", insecure=True) + self.server_store.set("baz", "shinyapps", "https://shinyapps.io", token="someToken", secret="c29tZVNlY3JldAo=") + self.assertEqual(len(self.server_store.get_all_servers()), 3, "Unexpected servers after setup") def tearDown(self): # clean up our temp test directory created with tempfile.mkdtemp() @@ -29,6 +30,7 @@ def test_add(self): self.assertEqual( self.server_store.get_by_name("foo"), dict( + target="connect", name="foo", url="http://connect.local", api_key="notReallyAnApiKey", @@ -40,6 +42,7 @@ def test_add(self): self.assertEqual( self.server_store.get_by_name("bar"), dict( + target="connect", name="bar", url="http://connect.remote", api_key="differentApiKey", @@ -48,6 +51,17 @@ def test_add(self): ), ) + self.assertEqual( + self.server_store.get_by_name("baz"), + dict( + target="shinyapps", + name="baz", + url="https://shinyapps.io", + token="someToken", + secret="c29tZVNlY3JldAo=", + ), + ) + def test_remove_by_name(self): self.server_store.remove_by_name("foo") self.assertIsNone(self.server_store.get_by_name("foo")) @@ -64,28 +78,30 @@ def test_remove_by_url(self): def test_remove_not_found(self): self.assertFalse(self.server_store.remove_by_name("frazzle")) - self.assertEqual(len(self.server_store.get_all_servers()), 2) + self.assertEqual(len(self.server_store.get_all_servers()), 3) self.assertFalse(self.server_store.remove_by_url("http://frazzle")) - self.assertEqual(len(self.server_store.get_all_servers()), 2) + self.assertEqual(len(self.server_store.get_all_servers()), 3) def test_list(self): servers = self.server_store.get_all_servers() - self.assertEqual(len(servers), 2) + self.assertEqual(len(servers), 3) self.assertEqual(servers[0]["name"], "bar") self.assertEqual(servers[0]["url"], "http://connect.remote") - self.assertEqual(servers[1]["name"], "foo") - self.assertEqual(servers[1]["url"], "http://connect.local") + self.assertEqual(servers[1]["name"], "baz") + self.assertEqual(servers[1]["url"], "https://shinyapps.io") + self.assertEqual(servers[2]["name"], "foo") + self.assertEqual(servers[2]["url"], "http://connect.local") def check_resolve_call(self, name, server, api_key, insecure, ca_cert, should_be_from_store): - server, api_key, insecure, ca_cert, from_store = self.server_store.resolve( + server_data = self.server_store.resolve( name, server, api_key, insecure, ca_cert ) - self.assertEqual(server, "http://connect.local") - self.assertEqual(api_key, "notReallyAnApiKey") - self.assertEqual(insecure, False) - self.assertEqual(ca_cert, "/certs/connect") - self.assertTrue(from_store, should_be_from_store) + self.assertEqual(server_data.url, "http://connect.local") + self.assertEqual(server_data.api_key, "notReallyAnApiKey") + self.assertEqual(server_data.insecure, False) + self.assertEqual(server_data.ca_data, "/certs/connect") + self.assertTrue(server_data.from_store, should_be_from_store) def test_resolve_by_name(self): self.check_resolve_call("foo", None, None, None, None, True) @@ -96,11 +112,12 @@ def test_resolve_by_url(self): def test_resolve_by_default(self): # with multiple entries, server None will not resolve by default name, server, api_key, insecure, ca_cert = None, None, None, None, None - server, api_key, insecure, ca_cert, _ = self.server_store.resolve(name, server, api_key, insecure, ca_cert) - self.assertEqual(server, None) + server_data = self.server_store.resolve(name, server, api_key, insecure, ca_cert) + self.assertEqual(server_data.url, None) # with only a single entry, server None will resolve to that entry self.server_store.remove_by_url("http://connect.remote") + self.server_store.remove_by_url("https://shinyapps.io") self.check_resolve_call(None, None, None, None, None, True) def test_resolve_from_args(self): @@ -111,15 +128,15 @@ def test_resolve_from_args(self): True, "fake-cert", ) - server, api_key, insecure, ca_cert, from_store = self.server_store.resolve( + server_data = self.server_store.resolve( name, server, api_key, insecure, ca_cert ) - self.assertEqual(server, "https://secured.connect") - self.assertEqual(api_key, "an-api-key") - self.assertTrue(insecure) - self.assertEqual(ca_cert, "fake-cert") - self.assertFalse(from_store) + self.assertEqual(server_data.url, "https://secured.connect") + self.assertEqual(server_data.api_key, "an-api-key") + self.assertTrue(server_data.insecure) + self.assertEqual(server_data.ca_data, "fake-cert") + self.assertFalse(server_data.from_store) def test_save_and_load(self): temp = tempfile.mkdtemp() @@ -128,7 +145,7 @@ def test_save_and_load(self): self.assertFalse(exists(path)) - server_store.set("foo", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") + server_store.set("foo", "connect", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") self.assertTrue(exists(path)) From 7173bd0dffd60f38684626b3620bcddf3c2fee79 Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Mon, 27 Jun 2022 08:40:42 -0500 Subject: [PATCH 2/3] clearer date formatting for shinyapps.io signatures --- rsconnect/api.py | 17 +---------------- rsconnect/main.py | 2 +- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 06046964..db480a37 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -340,19 +340,7 @@ def _get_canonical_request_signature(self, request): def get_extra_headers(self, url, method, body): canonical_request_method = method.upper() canonical_request_path = parse.urlparse(url).path - - # TODO (mslynch): there has got to be a better way than this - timetuple = datetime.datetime.utcnow().utctimetuple() - canonical_request_date = "%s, %02d %s %04d %02d:%02d:%02d %s" % ( - ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][timetuple[6]], - timetuple[2], - ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][timetuple[1] - 1], - timetuple[0], - timetuple[3], - timetuple[4], - timetuple[5], - "GMT", - ) + canonical_request_date = datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') # get request checksum md5 = hashlib.md5() @@ -385,9 +373,6 @@ def create_application(self, account_id, application_name): def get_accounts(self): return self.get("/v1/accounts/") - # def get_current_user(self): - # return self.get('v1/users/me') - def create_bundle(self, application_id: int, content_type: str, content_length: int, checksum: str): bundle_data = { "application": application_id, diff --git a/rsconnect/main.py b/rsconnect/main.py index 8cd7e1c4..a7714ffd 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -333,7 +333,7 @@ def add(name, target, server, api_key, insecure, cacert, token, secret, verbose) click.echo('Added Connect server "%s" with URL %s' % (name, real_server.url)) else: shinyapps_server = api.ShinyappsServer(server or "https://api.shinyapps.io", name, token, secret) - # TODO (mslynch): check credential and account here + # TODO (mslynch): test credential and account against server here server_store.set( name, target, shinyapps_server.url, token=shinyapps_server.token, secret=shinyapps_server.secret ) From 983b8245f1624332ca7b8a1ad0fcdbbc9acdf16b Mon Sep 17 00:00:00 2001 From: Matthew Lynch Date: Mon, 27 Jun 2022 11:06:43 -0500 Subject: [PATCH 3/3] better S3 error handling and shinyapps server connection/auth check --- rsconnect/actions.py | 9 +++++++++ rsconnect/api.py | 39 ++++++++++++++++++++++++--------------- rsconnect/main.py | 20 ++++++++++++++++---- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 79a1a3d2..923de397 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -212,6 +212,15 @@ def test_server(connect_server): raise api.RSConnectException("\n".join(failures)) +def test_shinyapps_server(server: api.ShinyappsServer): + with api.ShinyappsClient(server) as client: + try: + result = client.get_current_user() + server.handle_bad_response(result) + except api.RSConnectException as exc: + raise api.RSConnectException(f"Failed to verify with shinyapps.io ({str(exc)}).") + + def test_api_key(connect_server): """ Test that an API Key may be used to authenticate with the given RStudio Connect server. diff --git a/rsconnect/api.py b/rsconnect/api.py index db480a37..13980686 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -93,6 +93,13 @@ def __init__(self, url, api_key, insecure=False, ca_data=None): self.cookie_jar = CookieJar() +class S3Server(AbstractRemoteServer): + remote_name = 'S3' + + def __init__(self, url: str): + self.url = url + + RemoteServer = typing.Union[ShinyappsServer, RSConnectServer] @@ -243,7 +250,7 @@ def get_content(self, content_guid): return results def wait_for_task( - self, task_id, log_callback, abort_func=lambda: False, timeout=None, poll_wait=0.5, raise_on_error=True + self, task_id, log_callback, abort_func=lambda: False, timeout=None, poll_wait=0.5, raise_on_error=True ): last_status = None @@ -391,6 +398,9 @@ def deploy_application(self, bundle_id, app_id): def get_task(self, task_id): return self.get(f"/v1/tasks/{task_id}", query_params={"legacy": "true"}) + def get_current_user(self): + return self.get('/v1/users/me') + def wait_until_task_is_successful(self, task_id, timeout=60): counter = 1 status = None @@ -426,8 +436,6 @@ def prepare_deploy(self, app_id, app_name, app_title, title_is_default, bundle_s self._server.handle_bad_response(application) bundle = self.create_bundle(application.json_data["id"], "application/x-tar", bundle_size, bundle_hash) - - # TODO (mslynch) _server isn't meant to handle S3 errors self._server.handle_bad_response(bundle) return {"app_id": application.json_data["id"], "app_url": application.json_data["url"], **bundle.json_data} @@ -548,18 +556,19 @@ def do_bundle_deploy(remote_server: RemoteServer, app_id, name, title, title_is_ app_id, name, title, title_is_default, bundle_size, bundle_hash, env_vars ) - parsed_upload_url = urlparse(prepare_deploy_result["presigned_url"]) + upload_url = prepare_deploy_result["presigned_url"] + parsed_upload_url = urlparse(upload_url) with S3Client(f"{parsed_upload_url.scheme}://{parsed_upload_url.netloc}", timeout=120) as client: upload_result = client.upload( - prepare_deploy_result["presigned_url"], + upload_url, prepare_deploy_result["presigned_checksum"], bundle_size, contents, ) - remote_server.handle_bad_response(upload_result) + S3Server(upload_url).handle_bad_response(upload_result) with ShinyappsClient(remote_server, timeout=120) as client: - deploy_result = client.do_deploy(prepare_deploy_result["id"], prepare_deploy_result["app_id"]) + client.do_deploy(prepare_deploy_result["id"], prepare_deploy_result["app_id"]) webbrowser.open_new(prepare_deploy_result["app_url"]) @@ -567,14 +576,14 @@ def do_bundle_deploy(remote_server: RemoteServer, app_id, name, title, title_is_ def emit_task_log( - connect_server, - app_id, - task_id, - log_callback, - abort_func=lambda: False, - timeout=None, - poll_wait=0.5, - raise_on_error=True, + connect_server, + app_id, + task_id, + log_callback, + abort_func=lambda: False, + timeout=None, + poll_wait=0.5, + raise_on_error=True, ): """ Helper for spooling the deployment log for an app. diff --git a/rsconnect/main.py b/rsconnect/main.py index a7714ffd..367cda1c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -47,7 +47,7 @@ write_environment_file, write_notebook_manifest_json, write_quarto_manifest_json, - fake_module_file_from_directory, + fake_module_file_from_directory, test_shinyapps_server, ) from .actions_content import ( download_bundle, @@ -223,6 +223,10 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): return real_server, me +def _test_shinyappsio_creds(server: api.ShinyappsServer): + with cli_feedback("Checking shinyapps.io credential"): + test_shinyapps_server(server) + # noinspection SpellCheckingInspection @cli.command( @@ -333,7 +337,8 @@ def add(name, target, server, api_key, insecure, cacert, token, secret, verbose) click.echo('Added Connect server "%s" with URL %s' % (name, real_server.url)) else: shinyapps_server = api.ShinyappsServer(server or "https://api.shinyapps.io", name, token, secret) - # TODO (mslynch): test credential and account against server here + _test_shinyappsio_creds(shinyapps_server) + server_store.set( name, target, shinyapps_server.url, token=shinyapps_server.token, secret=shinyapps_server.secret ) @@ -518,6 +523,7 @@ def deploy(): def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_required=True) -> api.RemoteServer: + # TODO (mslynch): accept non-saved credentials here (maybe)? """ Validate that the user gave us enough information to talk to a Connect server. @@ -563,9 +569,15 @@ def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_r return connect_server else: - # TODO (mslynch): validate shinyapps data # TODO (mslynch): replace nickname with account name - return api.ShinyappsServer(server_data.url, server_data.name, server_data.token, server_data.secret) + if not server_data.token: + raise api.RSConnectException("A token must be specified.") + if not server_data.token: + raise api.RSConnectException("A secret must be specified.") + + server = api.ShinyappsServer(server_data.url, server_data.name, server_data.token, server_data.secret) + test_shinyapps_server(server) + return server def _warn_on_ignored_manifest(directory):