diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 142d133b..5d32b730 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,9 +5,6 @@ on: tags: ['*'] pull_request: branches: [master] -permissions: - id-token: write - contents: write jobs: test: strategy: @@ -128,7 +125,8 @@ jobs: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: aws-actions/configure-aws-credentials@v1 with: - role-to-assume: ${{ secrets.DOCS_AWS_ROLE }} + aws-access-key-id: ${{ secrets.DOCS_AWS_ID }} + aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET }} aws-region: us-east-1 - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') run: make promote-docs-in-s3 diff --git a/.gitignore b/.gitignore index ff527bc2..a54596f9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /rsconnect/version.py htmlcov /tests/testdata/**/rsconnect-python/ +test-home/ /docs/docs/index.md /docs/docs/changelog.md /rsconnect-build diff --git a/my-shiny-app/app.py b/my-shiny-app/app.py new file mode 100644 index 00000000..79bb9d0c --- /dev/null +++ b/my-shiny-app/app.py @@ -0,0 +1,14 @@ +from shiny import * + +app_ui = ui.page_fluid( + ui.input_slider("n", "N", 0, 100, 20), + ui.output_text_verbatim("txt", placeholder=True), +) + +def server(input, output, session): + @output() + @render_text() + def txt(): + return f"n*2 is {input.n() * 2}" + +app = App(app_ui, server) \ No newline at end of file diff --git a/my-shiny-app/manifest.json b/my-shiny-app/manifest.json new file mode 100644 index 00000000..82fb02de --- /dev/null +++ b/my-shiny-app/manifest.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "metadata": { + "appmode": "python-shiny", + "entrypoint": "app" + }, + "locale": "en_US.UTF-8", + "python": { + "version": "3.8.12", + "package_manager": { + "name": "pip", + "version": "22.0.4", + "package_file": "requirements.txt" + } + }, + "files": { + "requirements.txt": { + "checksum": "aa7771af430e482763c29ce773e399ae" + }, + "app.py": { + "checksum": "3aa0db2cc926c4e573783a56749cfb7c" + } + } +} \ No newline at end of file diff --git a/my-shiny-app/requirements.txt b/my-shiny-app/requirements.txt new file mode 100644 index 00000000..56460135 --- /dev/null +++ b/my-shiny-app/requirements.txt @@ -0,0 +1,3 @@ +--extra-index-url https://rstudio.github.io/pyshiny-site/pypi/ +shiny +websockets diff --git a/requirements.txt b/requirements.txt index 5a53544e..1baec990 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ click>=7.0.0 coverage flake8 funcsigs +httpretty==1.1.4 importlib-metadata ipykernel ipython diff --git a/rsconnect/actions.py b/rsconnect/actions.py index a7cfca04..e49037f1 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -218,6 +218,15 @@ def test_server(connect_server): raise 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 RSConnectException as exc: + raise RSConnectException("Failed to verify with shinyapps.io ({}).".format(exc)) + + def test_api_key(connect_server): """ Test that an API Key may be used to authenticate with the given RStudio Connect server. @@ -313,7 +322,7 @@ def check_server_capabilities(connect_server, capability_functions, details_sour raise RSConnectException(message) -def _make_deployment_name(connect_server, title, force_unique) -> str: +def _make_deployment_name(remote_server: api.TargetableServer, 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 @@ -324,7 +333,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. @@ -338,7 +347,7 @@ 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) + name = api.find_unique_name(remote_server, name) return name @@ -903,6 +912,62 @@ def deploy_python_fastapi( ) +def deploy_python_shiny( + connect_server, + directory, + extra_files, + excludes, + entry_point, + new=False, + app_id=None, + title=None, + python=None, + conda_mode=False, + force_generate=False, + log_callback=None, +): + """ + A function to deploy a Python Shiny module to RStudio Connect. Depending on the files involved + and network latency, this may take a bit of time. + + :param connect_server: the Connect server information. + :param directory: the app directory to deploy. + :param extra_files: any extra files that should be included in the deploy. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param entry_point: the module/executable object for the WSGi framework. + :param new: a flag to force this as a new deploy. + :param app_id: the ID of an existing application to deploy new files for. + :param title: an optional title for the deploy. If this is not provided, ne will + be generated. + :param python: the optional name of a Python executable. + :param conda_mode: use conda to build an environment.yml + instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param force_generate: force generating "requirements.txt" or "environment.yml", + even if it already exists. + :param log_callback: the callback to use to write the log to. If this is None + (the default) the lines from the deployment log will be returned as a sequence. + If a log callback is provided, then None will be returned for the log lines part + of the return tuple. + :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. + """ + return _deploy_by_python_framework( + connect_server, + directory, + extra_files, + excludes, + entry_point, + gather_basic_deployment_info_for_shiny, + new, + app_id, + title, + python, + conda_mode, + force_generate, + log_callback, + ) + + def deploy_dash_app( connect_server: api.RSConnectServer, directory: str, @@ -1447,7 +1512,7 @@ def _generate_gather_basic_deployment_info_for_python(app_mode: AppMode) -> typi """ def gatherer( - connect_server: api.RSConnectServer, + remote_server: api.TargetableServer, app_store: AppStore, directory: str, entry_point: str, @@ -1456,7 +1521,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, @@ -1474,10 +1539,11 @@ def gatherer( gather_basic_deployment_info_for_dash = _generate_gather_basic_deployment_info_for_python(AppModes.DASH_APP) gather_basic_deployment_info_for_streamlit = _generate_gather_basic_deployment_info_for_python(AppModes.STREAMLIT_APP) gather_basic_deployment_info_for_bokeh = _generate_gather_basic_deployment_info_for_python(AppModes.BOKEH_APP) +gather_basic_deployment_info_for_shiny = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_SHINY) def _gather_basic_deployment_info_for_framework( - connect_server: api.RSConnectServer, + remote_server: api.TargetableServer, app_store: AppStore, directory: str, entry_point: str, @@ -1489,7 +1555,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 @@ -1514,13 +1580,19 @@ 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) + elif isinstance(remote_server, api.ShinyappsServer): + app = api.get_shinyapp_info(remote_server, app_id) + existing_app_mode = AppModes.get_by_cloud_name(app.json_data["mode"]) + else: + raise RSConnectException("Unable to infer Connect client.") if existing_app_mode and app_mode != existing_app_mode: msg = ( "Deploying with mode '%s',\n" @@ -1538,7 +1610,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, @@ -1697,7 +1769,7 @@ def create_quarto_deployment_bundle( def deploy_bundle( - connect_server: api.RSConnectServer, + remote_server: api.TargetableServer, app_id: int, name: str, title: str, @@ -1708,7 +1780,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. @@ -1718,7 +1790,17 @@ 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) + ce = RSConnectExecutor( + server=remote_server, + app_id=app_id, + name=name, + title=title, + title_is_default=title_is_default, + bundle=bundle, + env_vars=env_vars, + ) + ce.deploy_bundle() + return ce.state["deployed_info"] def spool_deployment_log(connect_server, app, log_callback): diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index 509bdb57..906d3566 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -4,13 +4,11 @@ import json import time import traceback - from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta - import semver -from .api import RSConnect, emit_task_log +from .api import RSConnectClient, emit_task_log from .log import logger from .models import BuildStatus, ContentGuidWithBundle from .metadata import ContentBuildStore @@ -37,7 +35,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: @@ -228,7 +226,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 @@ -292,7 +290,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) @@ -311,7 +309,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: @@ -322,7 +320,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 224f5d3e..87840477 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1,36 +1,37 @@ """ RStudio Connect API client and utility functions """ - -from os.path import abspath, basename +from os.path import abspath import time from typing import IO, Callable +import base64 +import datetime +import hashlib +import hmac +import typing +import webbrowser from _ssl import SSLError +from urllib import parse +from urllib.parse import urlparse + import re from warnings import warn from six import text_type import gc -from .bundle import fake_module_file_from_directory + +from . import validation 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 .metadata import ServerStore, AppStore from .exception import RSConnectException +from .bundle import _default_title, fake_module_file_from_directory -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): +class AbstractRemoteServer: + def __init__(self, url: str, remote_name: str): 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() + self.remote_name = remote_name def handle_bad_response(self, response): if isinstance(response, HTTPResponse): @@ -42,21 +43,66 @@ 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 (calling %s): %s" % ( + self.remote_name, + response.full_uri, + 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 (calling %s): %s %s" + % ( + self.remote_name, + response.full_uri, + 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. + """ + + def __init__(self, url: str, account_name: str, token: str, secret: str): + super().__init__(url or "https://api.shinyapps.io", "shinyapps.io") + 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. + """ + + def __init__(self, url, api_key, insecure=False, ca_data=None): + super().__init__(url, "RStudio Connect") + self.api_key = api_key + self.insecure = insecure + self.ca_data = ca_data + # This is specifically not None. + self.cookie_jar = CookieJar() + + +TargetableServer = typing.Union[ShinyappsServer, RSConnectServer] + + +class S3Server(AbstractRemoteServer): + def __init__(self, url: str): + super().__init__(url, "S3") + + +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, @@ -269,6 +315,10 @@ def output_task_log(task_status, last_status, log_callback): return new_last_status +# for backwards compatibility with rsconnect-jupyter +RSConnect = RSConnectClient + + class RSConnectExecutor: def __init__( self, @@ -279,13 +329,26 @@ def __init__( cacert: IO = None, ca_data: str = None, cookies=None, + account=None, + token: str = None, + secret: str = None, timeout: int = 30, logger=console_logger, **kwargs ) -> None: self.reset() self._d = kwargs - self.setup_connect_server(name, url or kwargs.get("server"), api_key, insecure, cacert, ca_data) + self.setup_remote_server( + name=name, + url=url or kwargs.get("server"), + api_key=api_key, + insecure=insecure, + cacert=cacert, + ca_data=ca_data, + account_name=account, + token=token, + secret=secret, + ) self.setup_client(cookies, timeout) self.logger = logger @@ -301,7 +364,7 @@ def fromConnectServer(cls, connect_server, **kwargs): def reset(self): self._d = None - self.connect_server = None + self.remote_server = None self.client = None self.logger = None gc.collect() @@ -312,7 +375,7 @@ def drop_context(self): gc.collect() return self - def setup_connect_server( + def setup_remote_server( self, name: str = None, url: str = None, @@ -320,20 +383,49 @@ def setup_connect_server( insecure: bool = False, cacert: IO = None, ca_data: str = None, + account_name: str = None, + token: str = None, + secret: str = None, ): - if name and url: - raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both.") - if not name and not url: - raise RSConnectException("You must specify one of -n/--name or -s/--server.") + validation.validate_connection_options( + url=url, + api_key=api_key, + insecure=insecure, + cacert=cacert, + account_name=account_name, + token=token, + secret=secret, + name=name, + ) if cacert and not ca_data: ca_data = text_type(cacert.read()) - url, api_key, insecure, ca_data, _ = ServerStore().resolve(name, url, api_key, insecure, ca_data) - self.connect_server = RSConnectServer(url, api_key, insecure, ca_data) + server_data = ServerStore().resolve(name, url) + if server_data.from_store: + url = server_data.url + api_key = server_data.api_key + insecure = server_data.insecure + ca_data = server_data.ca_data + account_name = server_data.account_name + token = server_data.token + secret = server_data.secret + self.is_server_from_store = server_data.from_store + + if api_key: + self.remote_server = RSConnectServer(url, api_key, insecure, ca_data) + elif token and secret: + self.remote_server = ShinyappsServer(url, account_name, token, secret) + else: + raise RSConnectException("Unable to infer Connect server type and setup server.") def setup_client(self, cookies=None, timeout=30, **kwargs): - self.client = RSConnect(self.connect_server, cookies, timeout) + if isinstance(self.remote_server, RSConnectServer): + self.client = RSConnectClient(self.remote_server, cookies, timeout) + elif isinstance(self.remote_server, ShinyappsServer): + self.client = ShinyappsClient(self.remote_server, timeout) + else: + raise RSConnectException("Unable to infer Connect client.") @property def state(self): @@ -347,6 +439,27 @@ def pipe(self, func, *args, **kwargs): @cls_logged("Validating server...") def validate_server( + self, + name: str = None, + url: str = None, + api_key: str = None, + insecure: bool = False, + cacert: IO = None, + api_key_is_required: bool = False, + account_name: str = None, + token: str = None, + secret: str = None, + ): + if (url and api_key) or isinstance(self.remote_server, RSConnectServer): + self.validate_connect_server(name, url, api_key, insecure, cacert, api_key_is_required) + elif (url and token and secret) or isinstance(self.remote_server, ShinyappsServer): + self.validate_shinyapps_server(url, account_name, token, secret) + else: + raise RSConnectException("Unable to validate server from information provided.") + + return self + + def validate_connect_server( self, name: str = None, url: str = None, @@ -357,8 +470,7 @@ def validate_server( **kwargs ): """ - Validate that the user gave us enough information to talk to a Connect server. - + Validate that the user gave us enough information to talk to shinyapps.io or a Connect server. :param name: the nickname, if any, specified by the user. :param url: the URL, if any, specified by the user. :param api_key: the API key, if any, specified by the user. @@ -366,36 +478,34 @@ def validate_server( :param cacert: the file object 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. + :param token: The shinyapps.io authentication token. + :param secret: The shinyapps.io authentication secret. """ - url = url or self.connect_server.url - api_key = api_key or self.connect_server.api_key - insecure = insecure or self.connect_server.insecure + url = url or self.remote_server.url + api_key = api_key or self.remote_server.api_key + insecure = insecure or self.remote_server.insecure api_key_is_required = api_key_is_required or self.get("api_key_is_required", **kwargs) - server_store = ServerStore() + ca_data = None if cacert: ca_data = text_type(cacert.read()) - else: - ca_data = self.connect_server.ca_data + api_key = api_key or self.remote_server.api_key + insecure = insecure or self.remote_server.insecure + if not ca_data: + ca_data = self.remote_server.ca_data + + api_key_is_required = api_key_is_required or self.get("api_key_is_required", **kwargs) if name and url: - raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both.") + raise RSConnectException("You must specify only one of -n/--name or -s/--server, not both") if not name and not url: raise RSConnectException("You must specify one of -n/--name or -s/--server.") - real_server, api_key, insecure, ca_data, from_store = 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: - raise RSConnectException("You must specify one of -n/--name or -s/--server.") - - connect_server = RSConnectServer(real_server, None, insecure, ca_data) + server_data = ServerStore().resolve(name, url) + connect_server = RSConnectServer(url, None, insecure, ca_data) # If our info came from the command line, make sure the URL really works. - if not from_store: + if not server_data.from_store: self.server_settings connect_server.api_key = api_key @@ -406,14 +516,30 @@ def validate_server( return self # If our info came from the command line, make sure the key really works. - if not from_store: - _ = self.verify_api_key() + if not server_data.from_store: + _ = self.verify_api_key(connect_server) - self.connect_server = connect_server - self.client = RSConnect(self.connect_server) + self.remote_server = connect_server + self.client = RSConnectClient(self.remote_server) return self + def validate_shinyapps_server( + self, url: str = None, account_name: str = None, token: str = None, secret: str = None, **kwargs + ): + url = url or self.remote_server.url + account_name = account_name or self.remote_server.account_name + token = token or self.remote_server.token + secret = secret or self.remote_server.secret + server = ShinyappsServer(url, account_name, token, secret) + + with ShinyappsClient(server) as client: + try: + result = client.get_current_user() + server.handle_bad_response(result) + except RSConnectException as exc: + raise RSConnectException("Failed to verify with shinyapps.io ({}).".format(exc)) + @cls_logged("Making bundle ...") def make_bundle(self, func: Callable, *args, **kwargs): path = ( @@ -433,7 +559,8 @@ def make_bundle(self, func: Callable, *args, **kwargs): d = self.state d["title_is_default"] = not bool(title) d["title"] = title or _default_title(path) - d["deployment_name"] = self.make_deployment_name(d["title"], app_id is None) + force_unique_name = app_id is None + d["deployment_name"] = self.make_deployment_name(d["title"], force_unique_name) try: bundle = func(*args, **kwargs) @@ -463,6 +590,9 @@ def check_server_capabilities(self, capability_functions): :param details_source: the source for obtaining server details, gather_server_details(), by default. """ + if isinstance(self.remote_server, ShinyappsServer): + return self + details = self.server_details for function in capability_functions: @@ -485,17 +615,62 @@ def deploy_bundle( bundle: IO = None, env_vars=None, ): - result = self.client.deploy( - app_id or self.get("app_id"), - deployment_name or self.get("deployment_name"), - title or self.get("title"), - title_is_default or self.get("title_is_default"), - bundle or self.get("bundle"), - env_vars or self.get("env_vars"), - ) - self.connect_server.handle_bad_response(result) - self.state["deployed_info"] = result - return self + app_id = app_id or self.get("app_id") + deployment_name = deployment_name or self.get("deployment_name") + title = title or self.get("title") + 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") + + if isinstance(self.remote_server, RSConnectServer): + result = self.client.deploy( + app_id, + deployment_name, + title, + title_is_default, + bundle, + env_vars, + ) + self.remote_server.handle_bad_response(result) + self.state["deployed_info"] = result + return self + else: + contents = bundle.read() + bundle_size = len(contents) + bundle_hash = hashlib.md5(contents).hexdigest() + + prepare_deploy_result = self.client.prepare_deploy( + app_id, + deployment_name, + bundle_size, + bundle_hash, + ) + + upload_url = prepare_deploy_result.presigned_url + parsed_upload_url = urlparse(upload_url) + with S3Client( + "{}://{}".format(parsed_upload_url.scheme, parsed_upload_url.netloc), timeout=120 + ) as s3_client: + upload_result = s3_client.upload( + "{}?{}".format(parsed_upload_url.path, parsed_upload_url.query), + prepare_deploy_result.presigned_checksum, + bundle_size, + contents, + ) + S3Server(upload_url).handle_bad_response(upload_result) + + self.client.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id) + + print("Application successfully deployed to {}".format(prepare_deploy_result.app_url)) + webbrowser.open_new(prepare_deploy_result.app_url) + + self.state["deployed_info"] = { + "app_url": prepare_deploy_result.app_url, + "app_id": prepare_deploy_result.app_id, + "app_guid": None, + "title": title, + } + return self def emit_task_log( self, @@ -510,7 +685,6 @@ def emit_task_log( """ Helper for spooling the deployment log for an app. - :param connect_server: the Connect server information. :param app_id: the ID of the app that was deployed. :param task_id: the ID of the task that is tracking the deployment of the app.. :param log_callback: the callback to use to write the log to. If this is None @@ -522,18 +696,19 @@ def emit_task_log( :param raise_on_error: whether to raise an exception when a task is failed, otherwise we return the task_result so we can record the exit code. """ - app_id = app_id or self.state["deployed_info"]["app_id"] - task_id = task_id or self.state["deployed_info"]["task_id"] - log_lines, _ = self.client.wait_for_task( - task_id, log_callback.info, abort_func, timeout, poll_wait, raise_on_error - ) - self.connect_server.handle_bad_response(log_lines) - app_config = self.client.app_config(app_id) - self.connect_server.handle_bad_response(app_config) - app_dashboard_url = app_config.get("config_url") - log_callback.info("Deployment completed successfully.") - log_callback.info("\t Dashboard content URL: %s", app_dashboard_url) - log_callback.info("\t Direct content URL: %s", self.state["deployed_info"]["app_url"]) + if isinstance(self.remote_server, RSConnectServer): + app_id = app_id or self.state["deployed_info"]["app_id"] + task_id = task_id or self.state["deployed_info"]["task_id"] + log_lines, _ = self.client.wait_for_task( + task_id, log_callback.info, abort_func, timeout, poll_wait, raise_on_error + ) + self.remote_server.handle_bad_response(log_lines) + app_config = self.client.app_config(app_id) + self.remote_server.handle_bad_response(app_config) + app_dashboard_url = app_config.get("config_url") + log_callback.info("Deployment completed successfully.") + log_callback.info("\t Dashboard content URL: %s", app_dashboard_url) + log_callback.info("\t Direct content URL: %s", self.state["deployed_info"]["app_url"]) return self @@ -550,7 +725,7 @@ def save_deployed_info(self, *args, **kwargs): deployed_info = self.get("deployed_info", *args, **kwargs) app_store.set( - self.connect_server.url, + self.remote_server.url, abspath(path), deployed_info["app_url"], deployed_info["app_id"], @@ -563,7 +738,6 @@ def save_deployed_info(self, *args, **kwargs): @cls_logged("Validating app mode...") def validate_app_mode(self, *args, **kwargs): - connect_server = self.connect_server path = ( self.get("path", **kwargs) or self.get("file", **kwargs) @@ -587,13 +761,19 @@ def validate_app_mode(self, *args, **kwargs): 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(self.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 = get_app_info(connect_server, app_id) - existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) + # to get this from the remote. + if isinstance(self.remote_server, RSConnectServer): + app = get_app_info(self.remote_server, app_id) + existing_app_mode = AppModes.get_by_ordinal(app.get("app_mode", 0), True) + elif isinstance(self.remote_server, ShinyappsServer): + app = get_shinyapp_info(self.remote_server, app_id) + existing_app_mode = AppModes.get_by_cloud_name(app.json_data["mode"]) + else: + raise RSConnectException("Unable to infer Connect client.") if existing_app_mode and app_mode != existing_app_mode: msg = ( "Deploying with mode '%s',\n" @@ -610,27 +790,32 @@ def validate_app_mode(self, *args, **kwargs): def server_settings(self): try: result = self.client.server_settings() - self.connect_server.handle_bad_response(result) + self.remote_server.handle_bad_response(result) except SSLError as ssl_error: raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error) return result - def verify_api_key(self): + def verify_api_key(self, server=None): """ Verify that an API Key may be used to authenticate with the given RStudio Connect server. If the API key verifies, we return the username of the associated user. """ - result = self.client.me() - if isinstance(result, HTTPResponse): - if result.json_data and "code" in result.json_data and result.json_data["code"] == 30: - raise RSConnectException("The specified API key is not valid.") - raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason)) + if not server: + server = self.remote_server + if isinstance(server, ShinyappsServer): + raise RSConnectException("Shinnyapps server does not use an API key.") + with RSConnectClient(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: + raise RSConnectException("The specified API key is not valid.") + raise RSConnectException("Could not verify the API key: %s %s" % (result.status, result.reason)) return self @property def api_username(self): result = self.client.me() - self.connect_server.handle_bad_response(result) + self.remote_server.handle_bad_response(result) return result["username"] @property @@ -642,7 +827,7 @@ def python_info(self): :return: the Python installation information from Connect. """ result = self.client.python_settings() - self.connect_server.handle_bad_response(result) + self.remote_server.handle_bad_response(result) return result @property @@ -687,7 +872,6 @@ def make_deployment_name(self, title, force_unique): 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 title: the title to start with. :param force_unique: a flag noting whether the generated name must be forced to be unique. @@ -702,7 +886,7 @@ def make_deployment_name(self, title, force_unique): # Now, make sure it's unique, if needed. if force_unique: - name = find_unique_name(self.connect_server, name) + name = find_unique_name(self.remote_server, name) return name @@ -713,6 +897,186 @@ def filter_out_server_info(**kwargs): return new_kwargs +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 PrepareDeployResult: + def __init__(self, app_id: int, 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 + self.presigned_url = presigned_url + self.presigned_checksum = presigned_checksum + + +class ShinyappsClient(HTTPServer): + + _TERMINAL_STATUSES = {"success", "failed", "error"} + + 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 + canonical_request_date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S 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 get_application(self, application_id): + return self.get("/v1/applications/{}".format(application_id)) + + 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_applications_like_name_page(self, name: str, offset: int): + return self.get( + "/v1/applications?filter=name:like:{}&offset={}&count=100&use_advanced_filters=true".format(name, offset) + ) + + 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, + } + result = self.post("/v1/bundles", body=bundle_data) + return result + + def set_bundle_status(self, bundle_id, bundle_status): + return self.post("/v1/bundles/{}/status".format(bundle_id), body={"status": bundle_status}) + + def deploy_application(self, bundle_id, app_id): + return self.post("/v1/applications/{}/deploy".format(app_id), body={"bundle": bundle_id, "rebuild": False}) + + def get_task(self, task_id): + return self.get("/v1/tasks/{}".format(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=180): + print() + print("Waiting for task: {}".format(task_id)) + start_time = time.time() + while time.time() - start_time < timeout: + task = self.get_task(task_id) + self._server.handle_bad_response(task) + status = task.json_data["status"] + description = task.json_data["description"] + error = task.json_data["error"] + + if status == "success": + break + + if status in {"failed", "error"}: + raise RSConnectException("Application deployment failed with error: {}".format(error)) + + print(" {} - {}".format(status, description)) + time.sleep(2) + + print("Task done: {}".format(description)) + + def prepare_deploy(self, app_id: typing.Optional[str], app_name: str, bundle_size: int, bundle_hash: str): + accounts = self.get_accounts() + self._server.handle_bad_response(accounts) + account = next( + filter(lambda acct: acct["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 + ) + + if app_id is None: + application = self.create_application(account["id"], app_name) + else: + application = self.get_application(app_id) + self._server.handle_bad_response(application) + app_id_int = application.json_data["id"] + app_url = application.json_data["url"] + + bundle = self.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash) + self._server.handle_bad_response(bundle) + + return PrepareDeployResult( + app_id_int, + app_url, + int(bundle.json_data["id"]), + bundle.json_data["presigned_url"], + bundle.json_data["presigned_checksum"], + ) + + 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 get_applications_like_name(self, name): + applications = [] + + results = self._get_applications_like_name_page(name, 0) + self._server.handle_bad_response(results) + offset = 0 + + while len(applications) < int(results.json_data["total"]): + results = self._get_applications_like_name_page(name, offset) + self._server.handle_bad_response(results) + applications = results.json_data["applications"] + applications.extend(applications) + offset += int(results.json_data["count"]) + + return [app["name"] for app in applications] + + def verify_server(connect_server): """ Verify that the given server information represents a Connect instance that is @@ -724,7 +1088,7 @@ def verify_server(connect_server): """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) 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 @@ -741,8 +1105,7 @@ def verify_api_key(connect_server): :return: the username of the user to whom the API key belongs. """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - - 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: @@ -760,8 +1123,7 @@ def get_python_info(connect_server): :return: the Python installation information from Connect. """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - - with RSConnect(connect_server) as client: + with RSConnectClient(connect_server) as client: result = client.python_settings() connect_server.handle_bad_response(result) return result @@ -775,12 +1137,19 @@ 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 +def get_shinyapp_info(server, app_id): + with ShinyappsClient(server) as client: + result = client.get_application(app_id) + server.handle_bad_response(result) + return result + + def get_app_config(connect_server, app_id): """ Return the configuration information for an application that has been created @@ -790,32 +1159,12 @@ 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): - """ - Deploys the specified bundle. - - :param connect_server: the Connect 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. - :param title_is_default: a flag noting whether the title carries a defaulted value. - :param bundle: the bundle to deploy. - :param env_vars: list of NAME=VALUE pairs for the app environment - :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 - - def emit_task_log( connect_server, app_id, @@ -843,7 +1192,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) @@ -878,7 +1227,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) @@ -988,20 +1337,24 @@ def mapping_filter(client, app): return apps -def find_unique_name(connect_server, name): +def find_unique_name(remote_server: TargetableServer, name: str): """ Poll through existing apps to see if anything with a similar name exists. If so, start appending numbers until a unique name is found. - :param connect_server: the Connect server information. + :param remote_server: the remote server information. :param name: the default name for an app. :return: the name, potentially with a suffixed number to guarantee uniqueness. """ - existing_names = retrieve_matching_apps( - connect_server, - filters={"search": name}, - mapping_function=lambda client, app: app["name"], - ) + if isinstance(remote_server, RSConnectServer): + existing_names = retrieve_matching_apps( + remote_server, + filters={"search": name}, + mapping_function=lambda client, app: app["name"], + ) + else: + client = ShinyappsClient(remote_server) + existing_names = client.get_applications_like_name(name) if name in existing_names: suffix = 1 @@ -1032,18 +1385,3 @@ def _to_server_check_list(url): items = ["%s"] return [item % url for item in items] - - -def _default_title(file_name): - """ - Produce a default content title from the given file path. The result is - guaranteed to be between 3 and 1024 characters long, as required by RStudio - Connect. - - :param file_name: the name from which the title will be derived. - :return: the derived title. - """ - # Make sure we have enough of a path to derive text from. - file_name = abspath(file_name) - # noinspection PyTypeChecker - return basename(file_name).rsplit(".", 1)[0][:1024].rjust(3, "0") diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 43ed8d2c..42715024 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 ): @@ -283,17 +303,27 @@ def _do_request( raise http.CannotSendRequest("Too many redirects") location = response.getheader("Location") - next_url = urljoin(self._url.geturl(), location) - logger.debug("--> Redirected to: %s" % next_url) + # Assume the redirect location will always be on the same domain. + if location.startswith("http"): + parsed_location = urlparse(location) + if parsed_location.query: + next_url = "{}?{}".format(parsed_location.path, parsed_location.query) + else: + next_url = parsed_location.path + else: + next_url = location + + logger.debug("--> Redirected to: %s" % urljoin(self._url.geturl(), location)) + 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 a528334c..97b431d9 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -21,6 +21,7 @@ test_server, validate_quarto_engines, which_quarto, + test_shinyapps_server, ) from .actions_content import ( download_bundle, @@ -34,7 +35,7 @@ emit_build_log, ) -from . import api, VERSION +from . import api, VERSION, validation from .api import RSConnectExecutor, filter_out_server_info from .bundle import ( are_apis_supported_on_server, @@ -132,6 +133,40 @@ def wrapper(*args, **kwargs): return wrapper +def shinyapps_args(func): + @click.option( + "--account", + "-A", + envvar="SHINYAPPS_ACCOUNT", + help="The shinyapps.io account name.", + ) + @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.", + ) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def _passthrough(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + def validate_env_vars(ctx, param, all_values): vars = {} @@ -238,9 +273,14 @@ 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( - 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 " @@ -251,14 +291,12 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): @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.", ) @@ -276,27 +314,55 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): type=click.File(), help="The path to trusted TLS CA certificates.", ) +@shinyapps_args @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") -def add(name, server, api_key, insecure, cacert, verbose): +def add(name, server, api_key, insecure, cacert, account, token, secret, verbose): + set_verbosity(verbose) - old_server = server_store.get_by_name(name) + validation.validate_connection_options( + url=server, + api_key=api_key, + insecure=insecure, + cacert=cacert, + account_name=account, + token=token, + secret=secret, + ) - # Server must be pingable and the API key must work to be added. - real_server, _ = _test_server_and_api(server, api_key, insecure, cacert) + old_server = server_store.get_by_name(name) - server_store.set( - name, - real_server.url, - real_server.api_key, - real_server.insecure, - real_server.ca_data, - ) + if account: + shinyapps_server = api.ShinyappsServer(server, account, token, secret) + _test_shinyappsio_creds(shinyapps_server) - if old_server: - click.echo('Updated server "%s" with URL %s' % (name, real_server.url)) + server_store.set( + name, + shinyapps_server.url, + account_name=shinyapps_server.account_name, + token=shinyapps_server.token, + secret=shinyapps_server.secret, + ) + if old_server: + click.echo('Updated shinyapps.io credential "%s".' % name) + else: + click.echo('Added shinyapps.io credential "%s".' % name) else: - click.echo('Added server "%s" with URL %s' % (name, real_server.url)) + # 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, + ) + + 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)) @cli.command( @@ -344,9 +410,9 @@ def details(name, server, api_key, insecure, cacert, verbose): ce = RSConnectExecutor(name, server, api_key, insecure, cacert).validate_server() - click.echo(" RStudio Connect URL: %s" % ce.connect_server.url) + click.echo(" RStudio Connect URL: %s" % ce.remote_server.url) - if not ce.connect_server.api_key: + if not ce.remote_server.api_key: return with cli_feedback("Gathering details"): @@ -473,51 +539,6 @@ def deploy(): pass -def _validate_deploy_to_args(name, url, api_key, insecure, ca_cert, api_key_is_required=True): - """ - Validate that the user gave us enough information to talk to a Connect server. - - :param name: the nickname, if any, specified by the user. - :param url: the URL, if any, specified by the user. - :param api_key: the API key, if any, specified by the user. - :param insecure: a flag noting whether TLS host/validation should be skipped. - :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. - """ - ca_data = ca_cert and text_type(ca_cert.read()) - - if name and url: - raise 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) - - # 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: - raise RSConnectException("You must specify one of -n/--name or -s/--server.") - - connect_server = api.RSConnectServer(real_server, None, insecure, ca_data) - - # 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_key = api_key - - if not connect_server.api_key: - if api_key_is_required: - raise RSConnectException('An API key must be specified for "%s".' % connect_server.url) - return connect_server - - # If our info came from the command line, make sure the key really works. - if not from_store: - _ = test_api_key(connect_server) - - return connect_server - - def _warn_on_ignored_manifest(directory): """ Checks for the existence of a file called manifest.json in the given directory. @@ -724,6 +745,7 @@ def deploy_notebook( ) @server_args @content_args +@shinyapps_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @cli_exception_handler def deploy_manifest( @@ -732,6 +754,9 @@ def deploy_manifest( api_key: str, insecure: bool, cacert: typing.IO, + account: str, + token: str, + secret: str, new: bool, app_id: str, title: str, @@ -959,7 +984,9 @@ def deploy_html( ) -def generate_deploy_python(app_mode, alias, min_version): +def generate_deploy_python(app_mode, alias, min_version, supported_by_shinyapps=False): + shinyapps = shinyapps_args if supported_by_shinyapps else _passthrough + # noinspection SpellCheckingInspection @deploy.command( name=alias, @@ -973,6 +1000,7 @@ def generate_deploy_python(app_mode, alias, min_version): ) @server_args @content_args + @shinyapps @click.option( "--entrypoint", "-e", @@ -1044,6 +1072,9 @@ def deploy_app( extra_files, env_vars: typing.Dict[str, str], image: str, + account: str = None, + token: str = None, + secret: str = None, ): kwargs = locals() kwargs["entrypoint"] = entrypoint = validate_entry_point(entrypoint, directory) @@ -1085,6 +1116,9 @@ def deploy_app( deploy_dash_app = generate_deploy_python(app_mode=AppModes.DASH_APP, alias="dash", min_version="1.8.2") deploy_streamlit_app = generate_deploy_python(app_mode=AppModes.STREAMLIT_APP, alias="streamlit", min_version="1.8.4") deploy_bokeh_app = generate_deploy_python(app_mode=AppModes.BOKEH_APP, alias="bokeh", min_version="1.8.4") +deploy_shiny = generate_deploy_python( + app_mode=AppModes.PYTHON_SHINY, alias="shiny", min_version="2022.07.0", supported_by_shinyapps=True +) @deploy.command( @@ -1423,6 +1457,7 @@ def manifest_writer( write_manifest_dash = generate_write_manifest_python(AppModes.DASH_APP, alias="dash") write_manifest_streamlit = generate_write_manifest_python(AppModes.STREAMLIT_APP, alias="streamlit") write_manifest_bokeh = generate_write_manifest_python(AppModes.BOKEH_APP, alias="bokeh") +write_manifest_shiny = generate_write_manifest_python(AppModes.PYTHON_SHINY, alias="shiny") # noinspection SpellCheckingInspection @@ -1593,7 +1628,7 @@ def content_search( with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() result = search_content( - ce.connect_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by + ce.remote_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by ) json.dump(result, sys.stdout, indent=2) @@ -1645,7 +1680,7 @@ def content_describe(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - result = get_content(ce.connect_server, guid) + result = get_content(ce.remote_server, guid) json.dump(result, sys.stdout, indent=2) @@ -1709,7 +1744,7 @@ def content_bundle_download(name, server, api_key, insecure, cacert, guid, outpu if exists(output) and not overwrite: raise RSConnectException("The output file already exists: %s" % output) - result = download_bundle(ce.connect_server, guid) + result = download_bundle(ce.remote_server, guid) with open(output, "wb") as f: f.write(result.response_body) @@ -1764,7 +1799,7 @@ def add_content_build(name, server, api_key, insecure, cacert, guid, verbose): set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - build_add_content(ce.connect_server, guid) + build_add_content(ce.remote_server, guid) if len(guid) == 1: logger.info('Added "%s".' % guid[0]) else: @@ -1829,7 +1864,7 @@ def remove_content_build(name, server, api_key, insecure, cacert, guid, all, pur with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() _validate_build_rm_args(guid, all, purge) - guids = build_remove_content(ce.connect_server, guid, all, purge) + guids = build_remove_content(ce.remote_server, guid, all, purge) if len(guids) == 1: logger.info('Removed "%s".' % guids[0]) else: @@ -1882,7 +1917,7 @@ def list_content_build(name, server, api_key, insecure, cacert, status, guid, ve set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - result = build_list_content(ce.connect_server, guid, status) + result = build_list_content(ce.remote_server, guid, status) json.dump(result, sys.stdout, indent=2) @@ -1930,7 +1965,7 @@ def get_build_history(name, server, api_key, insecure, cacert, guid, verbose): with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert) ce.validate_server() - result = build_history(ce.connect_server, guid) + result = build_history(ce.remote_server, guid) json.dump(result, sys.stdout, indent=2) @@ -1993,7 +2028,7 @@ def get_build_logs(name, server, api_key, insecure, cacert, guid, task_id, forma set_verbosity(verbose) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - for line in emit_build_log(ce.connect_server, guid, format, task_id): + for line in emit_build_log(ce.remote_server, guid, format, task_id): sys.stdout.write(line) @@ -2060,7 +2095,7 @@ def start_content_build( logger.set_log_output_format(format) with cli_feedback("", stderr=True): ce = RSConnectExecutor(name, server, api_key, insecure, cacert, logger=None).validate_server() - build_start(ce.connect_server, parallelism, aborted, error, all, poll_wait, debug) + build_start(ce.remote_server, parallelism, aborted, error, all, poll_wait, debug) if __name__ == "__main__": diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index d735bc77..33fa9d99 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, + url: str, + from_store: bool, + api_key: typing.Optional[str] = None, + insecure: typing.Optional[bool] = None, + ca_data: typing.Optional[str] = None, + account_name: typing.Optional[str] = None, + token: typing.Optional[str] = None, + secret: typing.Optional[str] = None, + ): + self.name = name + self.url = url + self.from_store = from_store + self.api_key = api_key + self.insecure = insecure + self.ca_data = ca_data + self.account_name = account_name + self.token = token + self.secret = secret + + class ServerStore(DataStore): """Defines a metadata store for server information. @@ -252,7 +277,7 @@ 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, url, api_key=None, insecure=False, ca_data=None, account_name=None, token=None, secret=None): """ Add (or update) information about a Connect server @@ -261,17 +286,19 @@ def set(self, name, url, api_key, insecure=False, ca_data=None): :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 account_name: shinyapps.io account name. + :param token: shinyapps.io token. + :param secret: shinyapps.io secret. """ - self._set( - name, - dict( - name=name, - url=url, - api_key=api_key, - insecure=insecure, - ca_cert=ca_data, - ), + common_data = dict( + name=name, + url=url, ) + if api_key: + target_data = dict(api_key=api_key, insecure=insecure, ca_cert=ca_data) + else: + target_data = dict(account_name=account_name, token=token, secret=secret) + self._set(name, {**common_data, **target_data}) def remove_by_name(self, name): """ @@ -289,7 +316,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): """ This function will resolve the given inputs into a set of server information. It assumes that either `name` or `url` is provided. @@ -307,9 +334,6 @@ def resolve(self, name, url, api_key, insecure, ca_data): :param name: the nickname to look for. :param url: the Connect server URL to look for. - :param api_key: the API key provided on the command line. - :param insecure: the insecure flag provided on the command line. - :param ca_data: the CA certification data provided on the command line. :return: the information needed to interact with the resolved server and whether it came from the store or the arguments. """ @@ -327,15 +351,23 @@ def resolve(self, name, url, api_key, insecure, ca_data): entry = None if entry: - return ( + return ServerData( + name, 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"), + account_name=entry.get("account_name"), + token=entry.get("token"), + secret=entry.get("secret"), ) else: - return url, api_key, insecure, ca_data, False + return ServerData( + name, + url, + False, + ) def sha1(s): diff --git a/rsconnect/models.py b/rsconnect/models.py index 82fbac97..872eb6c0 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -77,6 +77,7 @@ class AppModes(object): PYTHON_FASTAPI = AppMode(12, "python-fastapi", "Python FastAPI") SHINY_QUARTO = AppMode(13, "quarto-shiny", "Shiny Quarto Document") STATIC_QUARTO = AppMode(14, "quarto-static", "Quarto Document", ".qmd") + PYTHON_SHINY = AppMode(15, "python-shiny", "Python Shiny Application") _modes = [ UNKNOWN, @@ -94,8 +95,21 @@ class AppModes(object): PYTHON_FASTAPI, SHINY_QUARTO, STATIC_QUARTO, + PYTHON_SHINY, ] + _cloud_to_connect_modes = { + "shiny": SHINY, + "rmarkdown_static": RMD, + "rmarkdown": SHINY_RMD, + "plumber": PLUMBER, + "flask": PYTHON_API, + "dash": DASH_APP, + "streamlit": STREAMLIT_APP, + "fastapi": PYTHON_FASTAPI, + "bokeh": BOKEH_APP, + } + @classmethod def get_by_ordinal(cls, ordinal, return_unknown=False): """Get an AppMode by its associated ordinal (integer)""" @@ -125,6 +139,10 @@ def get_by_extension(cls, extension, return_unknown=False): return_unknown, ) + @classmethod + def get_by_cloud_name(cls, name): + return cls._cloud_to_connect_modes.get(name, cls.UNKNOWN) + @classmethod def _find_by(cls, predicate, message, return_unknown): for mode in cls._modes: diff --git a/rsconnect/validation.py b/rsconnect/validation.py new file mode 100644 index 00000000..561f82cd --- /dev/null +++ b/rsconnect/validation.py @@ -0,0 +1,43 @@ +import typing + +from rsconnect.exception import RSConnectException + + +def _get_present_options(options: typing.Dict[str, typing.Optional[str]]) -> typing.List[str]: + return [k for k, v in options.items() if v] + + +def validate_connection_options(url, api_key, insecure, cacert, account_name, token, secret, name=None): + """ + Validates provided Connect or shinyapps.io connection options and returns which target to use given the provided + options. + """ + connect_options = {"-k/--api-key": api_key, "-i/--insecure": insecure, "-c/--cacert": cacert} + shinyapps_options = {"-T/--token": token, "-S/--secret": secret, "-A/--account": account_name} + options_mutually_exclusive_with_name = {"-s/--server": url, **connect_options, **shinyapps_options} + present_options_mutually_exclusive_with_name = _get_present_options(options_mutually_exclusive_with_name) + + if name and present_options_mutually_exclusive_with_name: + raise RSConnectException( + "-n/--name cannot be specified in conjunction with options {}".format( + ", ".join(present_options_mutually_exclusive_with_name) + ) + ) + if not name and not url and not shinyapps_options: + raise RSConnectException( + "You must specify one of -n/--name OR -s/--server OR -A/--account, -T/--token, -S/--secret." + ) + + present_connect_options = _get_present_options(connect_options) + present_shinyapps_options = _get_present_options(shinyapps_options) + + if present_connect_options and present_shinyapps_options: + raise RSConnectException( + "Connect options ({}) may not be passed alongside shinyapps.io options ({}).".format( + ", ".join(present_connect_options), ", ".join(present_shinyapps_options) + ) + ) + + if present_shinyapps_options: + if len(present_shinyapps_options) != 3: + raise RSConnectException("-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io.") diff --git a/tests/rsconnect-build-test/connect_remote_6443.json b/tests/rsconnect-build-test/connect_remote_6443.json new file mode 100644 index 00000000..32f7e05c --- /dev/null +++ b/tests/rsconnect-build-test/connect_remote_6443.json @@ -0,0 +1,83 @@ +{ + "rsconnect_build_running": false, + "rsconnect_content": { + "c96db3f3-87a1-4df5-9f58-eb109c397718": { + "guid": "c96db3f3-87a1-4df5-9f58-eb109c397718", + "bundle_id": "177", + "title": "orphan-proc-shiny-test", + "name": "orphan-proc-shiny-test", + "app_mode": "shiny", + "content_url": "https://connect.remote:6443/content/c96db3f3-87a1-4df5-9f58-eb109c397718/", + "dashboard_url": "https://connect.remote:6443/connect/#/apps/c96db3f3-87a1-4df5-9f58-eb109c397718", + "created_time": "2021-11-04T18:07:12Z", + "last_deployed_time": "2021-11-10T19:10:56Z", + "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", + "rsconnect_build_status": "NEEDS_BUILD" + }, + "fe673896-f92a-40cc-be4c-e4872bb90a37": { + "guid": "fe673896-f92a-40cc-be4c-e4872bb90a37", + "bundle_id": "185", + "title": "interactive-rmd", + "name": "interactive-rmd", + "app_mode": "rmd-shiny", + "content_url": "https://connect.remote:6443/content/fe673896-f92a-40cc-be4c-e4872bb90a37/", + "dashboard_url": "https://connect.remote:6443/connect/#/apps/fe673896-f92a-40cc-be4c-e4872bb90a37", + "created_time": "2021-11-15T15:37:53Z", + "last_deployed_time": "2021-11-15T15:37:57Z", + "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", + "rsconnect_build_status": "ERROR" + }, + "a0b6b5a2-5fbe-4293-8310-4f80054bc24f": { + "guid": "a0b6b5a2-5fbe-4293-8310-4f80054bc24f", + "bundle_id": "184", + "title": "stock-report-jupyter", + "name": "stock-report-jupyter", + "app_mode": "jupyter-static", + "content_url": "https://connect.remote:6443/content/a0b6b5a2-5fbe-4293-8310-4f80054bc24f/", + "dashboard_url": "https://connect.remote:6443/connect/#/apps/a0b6b5a2-5fbe-4293-8310-4f80054bc24f", + "created_time": "2021-11-15T15:27:18Z", + "last_deployed_time": "2021-11-15T15:35:27Z", + "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", + "rsconnect_build_status": "RUNNING" + }, + "23315cc9-ed2a-40ad-9e99-e5e49066531a": { + "guid": "23315cc9-ed2a-40ad-9e99-e5e49066531a", + "bundle_id": "180", + "title": "static-rmd", + "name": "static-rmd2", + "app_mode": "rmd-static", + "content_url": "https://connect.remote:6443/content/23315cc9-ed2a-40ad-9e99-e5e49066531a/", + "dashboard_url": "https://connect.remote:6443/connect/#/apps/23315cc9-ed2a-40ad-9e99-e5e49066531a", + "created_time": "2021-11-15T15:20:58Z", + "last_deployed_time": "2021-11-15T15:25:31Z", + "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", + "rsconnect_build_status": "COMPLETE", + "rsconnect_last_build_time": "2021-12-13T18:10:38Z", + "rsconnect_last_build_log": "/logs/localhost_3939/23315cc9-ed2a-40ad-9e99-e5e49066531a/ZUf44zVWHjODv1Rq.log", + "rsconnect_build_task_result": { + "id": "ZUf44zVWHjODv1Rq", + "user_id": 1, + "result": { + "type": "", + "data": null + }, + "finished": true, + "code": 0, + "error": "" + } + }, + "015143da-b75f-407c-81b1-99c4a724341e": { + "guid": "015143da-b75f-407c-81b1-99c4a724341e", + "bundle_id": "176", + "title": "plumber-async", + "name": "plumber-async", + "app_mode": "api", + "content_url": "https://connect.remote:6443/content/015143da-b75f-407c-81b1-99c4a724341e/", + "dashboard_url": "https://connect.remote:6443/connect/#/apps/015143da-b75f-407c-81b1-99c4a724341e", + "created_time": "2021-11-01T20:43:32Z", + "last_deployed_time": "2021-11-03T17:48:59Z", + "owner_guid": "edf26318-0027-4d9d-bbbb-54703ebb1855", + "rsconnect_build_status": "ERROR" + } + } +} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index f527a13e..cbd0d5ba 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,7 +3,7 @@ require_api_key, require_connect, ) -from rsconnect.api import RSConnect, RSConnectExecutor, _to_server_check_list +from rsconnect.api import RSConnectClient, RSConnectExecutor, _to_server_check_list class TestAPI(TestCase): @@ -11,7 +11,7 @@ def test_executor_init(self): connect_server = require_connect(self) api_key = require_api_key(self) ce = RSConnectExecutor(None, connect_server, api_key, True, None) - self.assertEqual(ce.connect_server.url, connect_server) + self.assertEqual(ce.remote_server.url, connect_server) def test_output_task_log(self): lines = ["line 1", "line 2", "line 3"] @@ -23,12 +23,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 7828ac13..bed0820a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,11 @@ +import json import os +import shutil from os.path import join from unittest import TestCase + +import httpretty from click.testing import CliRunner from .utils import ( @@ -14,26 +18,28 @@ require_api_key, require_connect, ) -from rsconnect.exception import RSConnectException -from rsconnect.main import cli, _validate_deploy_to_args, server_store +from rsconnect.main import cli from rsconnect import VERSION -class TestMain(TestCase): - def test_validate_deploy_to_args(self): - server_store.set("fake", "http://example.com", None) +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)] - try: - with self.assertRaises(RSConnectException): - _validate_deploy_to_args("name", "url", None, False, None) - with self.assertRaises(RSConnectException): - _validate_deploy_to_args(None, None, None, False, None) +def _load_json(data): + if isinstance(data, bytes): + return json.loads(data.decode()) + return json.loads(data) - with self.assertRaises(RSConnectException): - _validate_deploy_to_args("fake", None, None, False, None) - finally: - server_store.remove_by_name("fake") + +class TestMain(TestCase): + def setUp(self): + shutil.rmtree("test-home", ignore_errors=True) + os.environ["HOME"] = "test-home" def require_connect(self): connect_server = os.environ.get("CONNECT_SERVER", None) @@ -104,9 +110,233 @@ def test_deploy_manifest(self): result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) + # noinspection SpellCheckingInspection + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_manifest_shinyapps(self): + 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.shinyapps.io/v1/users/me", + body=open("tests/testdata/shinyapps-responses/get-user.json", "r").read(), + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.shinyapps.io/v1/applications" + "?filter=name:like:shinyapp&offset=0&count=100&use_advanced_filters=true", + body=open("tests/testdata/shinyapps-responses/get-applications.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.shinyapps.io/v1/accounts/", + body=open("tests/testdata/shinyapps-responses/get-accounts.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + def post_application_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + try: + self.assertDictEqual(parsed_request, {"account": 82069, "name": "myapp", "template": "shiny"}) + except AssertionError as e: + return _error_to_response(e) + return [ + 201, + {"Content-Type": "application/json"}, + open("tests/testdata/shinyapps-responses/create-application.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.shinyapps.io/v1/applications/", + body=post_application_callback, + status=200, + ) + + def post_bundle_callback(request, uri, response_headers): + parsed_request = _load_json(request.body) + del parsed_request["checksum"] + del parsed_request["content_length"] + try: + self.assertDictEqual( + 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/shinyapps-responses/create-bundle.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.shinyapps.io/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: + self.assertDictEqual(parsed_request, {"status": "ready"}) + except AssertionError as e: + return _error_to_response(e) + return [303, {"Location": "https://api.shinyapps.io/v1/bundles/12640"}, ""] + + httpretty.register_uri( + httpretty.POST, + "https://api.shinyapps.io/v1/bundles/12640/status", + body=post_bundle_status_callback, + ) + + httpretty.register_uri( + httpretty.GET, + "https://api.shinyapps.io/v1/bundles/12640", + body=open("tests/testdata/shinyapps-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: + self.assertDictEqual(parsed_request, {"bundle": 12640, "rebuild": False}) + except AssertionError as e: + return _error_to_response(e) + return [ + 303, + {"Location": "https://api.shinyapps.io/v1/tasks/333"}, + open("tests/testdata/shinyapps-responses/post-deploy.json", "r").read(), + ] + + httpretty.register_uri( + httpretty.POST, + "https://api.shinyapps.io/v1/applications/8442/deploy", + body=post_deploy_callback, + ) + + httpretty.register_uri( + httpretty.GET, + "https://api.shinyapps.io/v1/tasks/333", + body=open("tests/testdata/shinyapps-responses/get-task.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + runner = CliRunner() + args = [ + "deploy", + "manifest", + get_manifest_path("shinyapp"), + "--account", + "some-account", + "--token", + "someToken", + "--secret", + "c29tZVNlY3JldAo=", + "--title", + "myApp", + ] + try: + result = runner.invoke(cli, args) + self.assertEqual(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() args = self.create_deploy_args("api", target) result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, 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) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_add_shinyapps(self): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + try: + httpretty.register_uri( + httpretty.GET, "https://api.shinyapps.io/v1/users/me", body='{"id": 1000}', status=200 + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "add", + "--account", + "some-account", + "--name", + "my-shinyapps", + "--token", + "someToken", + "--secret", + "c29tZVNlY3JldAo=", + ], + ) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("shinyapps.io credential", 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_add_shinyapps_missing_options(self): + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + try: + runner = CliRunner() + result = runner.invoke( + cli, + [ + "add", + "--name", + "my-shinyapps", + "--token", + "someToken", + ], + ) + self.assertEqual(result.exit_code, 1, result.output) + self.assertEqual( + str(result.exception), + "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io.", + ) + 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 diff --git a/tests/test_metadata.py b/tests/test_metadata.py index ffcefe48..651d20cf 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -19,7 +19,14 @@ def setUp(self): 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( + "baz", + "https://shinyapps.io", + account_name="some-account", + 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() @@ -48,6 +55,17 @@ def test_add(self): ), ) + self.assertEqual( + self.server_store.get_by_name("baz"), + dict( + name="baz", + url="https://shinyapps.io", + account_name="some-account", + 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 +82,28 @@ 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( - name, server, api_key, insecure, ca_cert - ) + server_data = self.server_store.resolve(name, server) - 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) @@ -95,31 +113,26 @@ 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(None, None) + 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): - name, server, api_key, insecure, ca_cert = ( + name, server = ( None, "https://secured.connect", - "an-api-key", - True, - "fake-cert", - ) - server, api_key, insecure, ca_cert, from_store = self.server_store.resolve( - name, server, api_key, insecure, ca_cert ) + server_data = self.server_store.resolve(name, server) - 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, None) + self.assertEqual(server_data.insecure, None) + self.assertEqual(server_data.ca_data, None) + self.assertFalse(server_data.from_store) def test_save_and_load(self): temp = tempfile.mkdtemp() @@ -128,7 +141,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", "http://connect.local", api_key="notReallyAnApiKey", ca_data="/certs/connect") self.assertTrue(exists(path)) @@ -255,8 +268,8 @@ def test_normalize_server_url(self): class TestBuildMetadata(TestCase): def setUp(self): self.server_store = ServerStore() - self.server_store.set("connect", "https://connect.remote:6443", "apiKey", insecure=True) - self.server = RSConnectServer("https://connect.remote:6443", "apiKey", True, None) + self.server_store.set("connect", "https://connect.remote:6443", api_key="apiKey", insecure=True) + self.server = RSConnectServer("https://connect.remote:6443", api_key="apiKey", insecure=True, ca_data=None) self.build_store = ContentBuildStore(self.server) self.build_store._set("rsconnect_build_running", False) self.build_store._set( diff --git a/tests/testdata/shinyapps-responses/create-application.json b/tests/testdata/shinyapps-responses/create-application.json new file mode 100644 index 00000000..f238305f --- /dev/null +++ b/tests/testdata/shinyapps-responses/create-application.json @@ -0,0 +1,124 @@ +{ + "id": 8442, + "name": "myapp", + "uuid": "799b44bc47c1424cab750fe9e5261792", + "url": "https://some-account.staging.shinyapps.io/myapp/", + "type": "shiny", + "mode": null, + "scheduler": "legacy", + "status": "pending", + "account_id": 1037, + "content_id": null, + "logplex_channel": null, + "logplex_token": null, + "storage_initialized": true, + "deployment_id": 10258, + "deployment": { + "id": 10258, + "application_memory_limit": 1024, + "application_cpu_limit": 1, + "application_timeout_minutes": 15, + "application_timeout_kill_minutes": 60, + "application_os_version": "xenial", + "image": { + "id": 3341, + "app_id": 8442, + "active": true, + "status": "ready", + "bundle_id": 485, + "bundle": { + "id": 485, + "app_id": 8442, + "user_id": 26, + "status": "ready", + "name": null, + "url": "s3://lucid-uploads-staging/bundles/application-8442/f34d4cd69fc74eeab7ddbf944b63c3af.tar.gz", + "checksum": "101f6fb239fdab6852f20257640abfda", + "parent_id": null, + "created_time": "2018-07-20T23:05:44", + "updated_time": "2018-07-20T23:05:45" + }, + "manifest": null, + "repository": "app-8442", + "registry": "registry01", + "tag": "image-3341", + "agents": [], + "created_time": "2018-07-20T23:05:47", + "updated_time": "2021-06-03T21:44:03" + }, + "properties": { + "application.visibility": "public", + "application.base-image.registry": null, + "application.base-image.repository": null, + "application.base-image.compiler": null, + "application.build-pool": null, + "application.ide.image.tag": null, + "application.jupyter.image.tag": null, + "application.initialize.image": null, + "application.initialize.image.tag": null, + "application.sidecar.image.tag": null, + "application.ide.autosave.on.idle": false, + "application.instances.template": "large", + "application.instances.start": 1, + "application.instances.max": 3, + "application.instances.load.factor": 0.5, + "application.instances.idle-threshold": 15, + "application.instances.down-threshold": 1, + "application.instances.fault-threshold": 1, + "application.instances.agent-pool": null, + "application.shiny.timeout.init": 60, + "application.shiny.timeout.idle": 5, + "application.shiny.timeout.conn": 900, + "application.shiny.timeout.read": 3600, + "application.shiny.timeout.session": 3600, + "application.shiny.timeout.reconnect": null, + "application.shiny.scheduler.max.requests": 20, + "application.shiny.scheduler.max.processes": 3, + "application.shiny.scheduler.min.processes": 0, + "application.shiny.scheduler.load.factor": 0.05, + "application.shiny.websockets": false, + "application.shiny.sockjs.protocols.disabled": null, + "application.connect.debug.log": "", + "application.connect.client.debugging": false, + "application.connect.client.transport.debugging": false, + "application.connect.version": "current", + "application.package.cache": true, + "application.connect.injection.version": 62, + "application.connect.branding": false, + "application.ide.version": "current", + "application.frontend.iFrameResizer.log": false, + "application.frontend.iFrameResizer.sizeHeight": true, + "application.frontend.iFrameResizer.sizeWidth": false, + "application.frontend.iFrameResizer.heightCalculationMethod": "bodyOffset", + "application.frontend.iFrameResizer.widthCalculationMethod": "bodyOffset", + "application.storage.size": "20G", + "application.unmigratable": "" + }, + "environment": {}, + "user": null, + "created_time": "2018-07-20T23:05:47", + "updated_time": "2018-07-20T23:05:47" + }, + "environment": {}, + "resources": { + "memory_limit": 1024, + "cpu_limit": 1, + "effective_memory_limit": 1024, + "effective_cpu_limit": 1 + }, + "configuration": { + "timeout_minutes": 15, + "timeout_kill_minutes": 60, + "effective_timeout_minutes": 15, + "effective_timeout_kill_minutes": 60 + }, + "runtime_options": null, + "next_deployment_id": null, + "prev_deployment_id": 10257, + "clone_parent_id": null, + "copy_parent_id": null, + "storage": [], + "exportable": true, + "created_time": "2018-07-20T22:46:41", + "updated_time": "2018-07-20T23:06:06" +} diff --git a/tests/testdata/shinyapps-responses/create-bundle.json b/tests/testdata/shinyapps-responses/create-bundle.json new file mode 100644 index 00000000..3d1d71e6 --- /dev/null +++ b/tests/testdata/shinyapps-responses/create-bundle.json @@ -0,0 +1,14 @@ +{ + "id": 12640, + "app_id": 8442, + "user_id": 47261, + "status": "pending", + "name": null, + "url": "s3://lucid-uploads-staging/bundles/application-1122350/6c9ed0d91ee9426687d9ac231d47dc83.tar.gz", + "checksum": "0f56e5308e2a4e2237b607943985f092", + "parent_id": null, + "created_time": "2022-07-01T21:39:13", + "updated_time": "2022-07-01T21:39:13", + "presigned_url": "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", + "presigned_checksum": "D1blMI4qTiI3tgeUOYXwkg==" +} diff --git a/tests/testdata/shinyapps-responses/get-accounts.json b/tests/testdata/shinyapps-responses/get-accounts.json new file mode 100644 index 00000000..1c31c457 --- /dev/null +++ b/tests/testdata/shinyapps-responses/get-accounts.json @@ -0,0 +1,198 @@ +{ + "count": 1, + "total": 1, + "offset": 0, + "accounts": [ + { + "id": 82069, + "name": "some-account", + "type": "individual", + "display_name": null, + "permissions": [ + "DELETE_LEGACY_TASK", + "CREATE_LEGACY_TASK_LOGS", + "MODIFY_VOLUME", + "DELETE_VOLUME", + "VIEW_ACCOUNT_TASK", + "VIEW_VOLUME", + "VIEW_DOMAIN", + "VIEW_ACCOUNT_NOTIFICATION_PREFERENCE", + "CREATE_APPLICATION", + "VIEW_ACCOUNT_USERS", + "CREATE_VOLUME", + "MANAGE_ACCOUNT_NOTIFICATION_PREFERENCE", + "DELETE_ACCOUNT", + "VIEW_ACCOUNT", + "DELETE_DOMAIN", + "VIEW_LEGACY_TASK", + "PURGE_VOLUME", + "MODIFY_LEGACY_TASK", + "CREATE_CONTENT_IN_NULL_SPACE", + "KILL_LEGACY_TASK", + "CREATE_SPACE", + "MANAGE_ACCOUNT", + "VIEW_ACCOUNT_USAGE", + "CREATE_CONTENT_IN_NAMED_SPACE", + "MANAGE_ACCOUNT_SUBSCRIPTION", + "VIEW_LEGACY_TASK_LOGS", + "PROVISION_VOLUME", + "MANAGE_ACCOUNT_ACCESS", + "CREATE_DOMAIN" + ], + "suspended_until": null, + "suspended_reason": null, + "suspended_pending": null, + "sso_enabled": false, + "sso_hint": null, + "beta_enabled": false, + "beta": { + "jupyter_enabled": false, + "publishing_enabled": true, + "sharing_enabled": false + }, + "owner": { + "id": 47261, + "first_name": "Example", + "last_name": "User", + "display_name": "Example User", + "organization": null, + "homepage": null, + "location": null, + "picture_url": "https://www.gravatar.com/avatar/e1bc03fb1393b9b1c3b3dc309cc0b4cf", + "email": "example.user@rstudio.com", + "superuser": false, + "email_verified": true, + "local_auth": true, + "referral": null, + "google_auth_id": "100000000000000000000", + "github_auth_id": null, + "github_auth_token": null, + "last_login_attempt": "2022-05-31T22:20:28", + "login_attempts": 0, + "lockout_until": null, + "sso_account_id": null, + "grant": null, + "created_time": "2021-09-28T19:32:47", + "updated_time": "2022-05-31T22:20:28" + }, + "subscription": { + "id": 82069, + "type": { + "id": 4, + "name": "professional", + "premium": true, + "created_time": "2016-11-03T15:37:09", + "updated_time": "2016-11-03T15:37:09" + }, + "start_date": null, + "end_date": null, + "cancel_date": null, + "cycle_start_date": "2022-06-28T19:32:52", + "cycle_end_date": "2022-07-28T19:32:52", + "status": null, + "plan": null, + "discount": null, + "billing": { + "name": null, + "email": null, + "organization": null, + "address": { + "address_1": null, + "address_2": null, + "city": null, + "state": null, + "zip": null, + "country": null + } + }, + "payment": null, + "entitlements": { + "AuthenticationFeature": { + "enabled": true + }, + "BrandingFeature": { + "enabled": true + }, + "MaxApplicationsLimit": { + "limit": 1000 + }, + "MaxInstancesLimit": { + "limit": 10 + }, + "InstanceIdleLimit": { + "limit": 480 + }, + "BundleSizeLimit": { + "limit": 50000000000 + }, + "MaxCustomDomainsLimit": { + "limit": 100 + }, + "MaxAccountUsersLimit": { + "limit": 25 + }, + "MaxApplicationWorkersLimit": { + "limit": 10 + }, + "MaxApplicationHoursLimit": { + "limit": 10000 + }, + "MaxProjectsLimit": { + "limit": 100000 + }, + "MaxPublicSpacesLimit": { + "limit": 100 + }, + "MaxPrivateSpacesLimit": { + "limit": 100 + }, + "MaxApplicationCpuLimit": { + "limit": 1 + }, + "MaxApplicationMemoryLimit": { + "limit": 1024 + }, + "ApplicationInstanceType": { + "items": [ + "small", + "medium", + "large", + "xlarge", + "xxlarge", + "xxxlarge" + ] + } + }, + "created_time": "2021-09-28T19:32:52", + "updated_time": "2021-09-28T19:32:52" + }, + "licenses": [ + { + "id": 103876, + "type": "cloud", + "status": "active", + "name": "cloud-premium", + "expires": null, + "exempt": false, + "exempt_until": null, + "suspended": false, + "suspended_until": null, + "suspended_reason": null, + "suspension_pending": false, + "suspension_pending_until": null, + "cycle_anchor": "2021-11-09T16:09:31", + "cycle_start": "2022-06-09T16:09:31", + "cycle_end": "2022-07-09T16:09:31", + "overage_until": null, + "overage": false, + "created_time": "2021-09-28T19:32:52", + "updated_time": "2021-12-06T21:11:49" + } + ], + "account_role": "none", + "stripe_customer_id": null, + "created_time": "2021-09-28T19:32:52", + "updated_time": "2021-09-28T19:32:52" + } + ] +} diff --git a/tests/testdata/shinyapps-responses/get-applications.json b/tests/testdata/shinyapps-responses/get-applications.json new file mode 100644 index 00000000..db604309 --- /dev/null +++ b/tests/testdata/shinyapps-responses/get-applications.json @@ -0,0 +1,6 @@ +{ + "count": 0, + "total": 0, + "offset": 0, + "applications": [] +} diff --git a/tests/testdata/shinyapps-responses/get-bundle.json b/tests/testdata/shinyapps-responses/get-bundle.json new file mode 100644 index 00000000..20c42f4e --- /dev/null +++ b/tests/testdata/shinyapps-responses/get-bundle.json @@ -0,0 +1,12 @@ +{ + "id": 12640, + "app_id": 8442, + "user_id": 47261, + "status": "ready", + "name": null, + "url": "s3://lucid-uploads-staging/bundles/application-8442/6c9ed0d91ee9426687d9ac231d47dc83.tar.gz", + "checksum": "0f56e5308e2a4e2237b607943985f092", + "parent_id": null, + "created_time": "2022-07-01T21:39:13", + "updated_time": "2022-07-01T21:39:14" +} diff --git a/tests/testdata/shinyapps-responses/get-task.json b/tests/testdata/shinyapps-responses/get-task.json new file mode 100644 index 00000000..0bdb2962 --- /dev/null +++ b/tests/testdata/shinyapps-responses/get-task.json @@ -0,0 +1,17 @@ +{ + "id": 333, + "account_id": 82069, + "parent_id": null, + "action": "application-deploy", + "description": "Stopping old instances", + "status": "success", + "error": null, + "finished": true, + "finished_time": "2022-07-05T16:07:16", + "arguments": { + "app_id": 8442, + "deployment_id": 850618 + }, + "created_time": "2022-07-05T16:06:42", + "updated_time": "2022-07-05T16:07:16" +} diff --git a/tests/testdata/shinyapps-responses/get-user.json b/tests/testdata/shinyapps-responses/get-user.json new file mode 100644 index 00000000..1e5351fb --- /dev/null +++ b/tests/testdata/shinyapps-responses/get-user.json @@ -0,0 +1,25 @@ +{ + "id": 47261, + "first_name": "Example", + "last_name": "User", + "display_name": "Example User", + "organization": null, + "homepage": null, + "location": null, + "picture_url": "https://www.gravatar.com/avatar/e1bc03fb1393b9b1c3b3dc309cc0b4cf", + "email": "example.user@rstudio.com", + "superuser": false, + "email_verified": true, + "local_auth": true, + "referral": null, + "google_auth_id": "100000000000000000000", + "github_auth_id": null, + "github_auth_token": null, + "last_login_attempt": "2022-05-31T22:20:28", + "login_attempts": 0, + "lockout_until": null, + "sso_account_id": null, + "grant": null, + "created_time": "2021-09-28T19:32:47", + "updated_time": "2022-05-31T22:20:28" +} diff --git a/tests/testdata/shinyapps-responses/post-deploy.json b/tests/testdata/shinyapps-responses/post-deploy.json new file mode 100644 index 00000000..c045572f --- /dev/null +++ b/tests/testdata/shinyapps-responses/post-deploy.json @@ -0,0 +1,3 @@ +{ + "task_id": 333 +}