From 8e7db5bcd7f24198219e3c3883727526617ee0fd Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 11:32:48 -0400 Subject: [PATCH 01/11] verify app deployments --- rsconnect/api.py | 25 +++++++++++++++++++++++-- rsconnect/main.py | 8 ++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 9fd55962..834e5d65 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -195,6 +195,23 @@ def app_publish(self, app_id, access): def app_config(self, app_id): return self.get("applications/%s/config" % app_id) + def is_app_failed_response(self, response): + return isinstance(response, HTTPResponse) and response.status >= 500 + + def app_access(self, app_guid): + method = "GET" + path = f"/content/{app_guid}/" + response = self._do_request(method, path, None, None, 3, {}, False) + + # response = self.get("/content/%s/" % app_guid) + if self.is_app_failed_response(response): + print(response.status, end=None) + raise RSConnectException( + "Could not access the deployed content. " + + "The app might not have started successfully. " + + "Visit it in Connect to view the logs." + ) + def bundle_download(self, content_guid, bundle_id): response = self.get("v1/content/%s/bundles/%s/download" % (content_guid, bundle_id), decode_response=False) self._server.handle_bad_response(response) @@ -300,7 +317,6 @@ def wait_for_task( poll_wait=0.5, raise_on_error=True, ): - if log_callback is None: log_lines = [] log_callback = log_lines.append @@ -805,6 +821,12 @@ def save_deployed_info(self, *args, **kwargs): return self + @cls_logged("Verifying deployed content...") + def verify_deployment(self, *args, **kwargs): + deployed_info = self.get("deployed_info", *args, **kwargs) + app_guid = deployed_info["app_guid"] + self.client.app_access(app_guid) + @cls_logged("Validating app mode...") def validate_app_mode(self, *args, **kwargs): path = ( @@ -1331,7 +1353,6 @@ def prepare_deploy( app_mode: AppMode, app_store_version: typing.Optional[int], ) -> PrepareDeployOutputResult: - application_type = "static" if app_mode in [AppModes.STATIC, AppModes.STATIC_QUARTO] else "connect" logger.debug(f"application_type: {application_type}") diff --git a/rsconnect/main.py b/rsconnect/main.py index c10d77fd..7ee87fac 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1312,6 +1312,11 @@ def generate_deploy_python(app_mode, alias, min_version): ) @shinyapps_deploy_args @cli_exception_handler + @click.option( + "--no-verify", + is_flag=True, + help="Don't access the deployed app to verify that it started correctly.", + ) def deploy_app( name: str, server: str, @@ -1337,6 +1342,7 @@ def deploy_app( account: str = None, token: str = None, secret: str = None, + no_verify: bool = False, ): set_verbosity(verbose) kwargs = locals() @@ -1368,6 +1374,8 @@ def deploy_app( .save_deployed_info() .emit_task_log() ) + if not no_verify: + ce.verify_deployment() return deploy_app From c1abe46a8806743f4298a6b182e6560f17d40f08 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 12:06:46 -0400 Subject: [PATCH 02/11] add content verification to all deployment paths --- rsconnect/main.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 7ee87fac..daf03f1e 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -237,6 +237,11 @@ def content_args(func): "or just NAME to use the value from the local environment. " "May be specified multiple times. [v1.8.6+]", ) + @click.option( + "--no-verify", + is_flag=True, + help="Don't access the deployed content to verify that it started correctly.", + ) @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) @@ -849,6 +854,7 @@ def deploy_notebook( disable_env_management: bool, env_management_py: bool, env_management_r: bool, + no_verify: bool = False, ): kwargs = locals() set_verbosity(verbose) @@ -891,6 +897,8 @@ def deploy_notebook( env_management_r=env_management_r, ) ce.deploy_bundle().save_deployed_info().emit_task_log() + if not no_verify: + ce.verify_deployment() # noinspection SpellCheckingInspection,DuplicatedCode @@ -969,6 +977,7 @@ def deploy_voila( cacert: typing.IO = None, connect_server: api.RSConnectServer = None, multi_notebook: bool = False, + no_verify: bool = False, ): kwargs = locals() set_verbosity(verbose) @@ -992,6 +1001,8 @@ def deploy_voila( env_management_r=env_management_r, multi_notebook=multi_notebook, ).deploy_bundle().save_deployed_info().emit_task_log() + if not no_verify: + ce.verify_deployment() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1027,6 +1038,7 @@ def deploy_manifest( file: str, env_vars: typing.Dict[str, str], visibility: typing.Optional[str], + no_verify: bool = False, ): kwargs = locals() set_verbosity(verbose) @@ -1047,6 +1059,8 @@ def deploy_manifest( .save_deployed_info() .emit_task_log() ) + if not no_verify: + ce.verify_deployment() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1124,6 +1138,7 @@ def deploy_quarto( disable_env_management: bool, env_management_py: bool, env_management_r: bool, + no_verify: bool = False, ): kwargs = locals() set_verbosity(verbose) @@ -1174,6 +1189,8 @@ def deploy_quarto( .save_deployed_info() .emit_task_log() ) + if not no_verify: + ce.verify_deployment() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1227,6 +1244,7 @@ def deploy_html( account: str = None, token: str = None, secret: str = None, + no_verify: bool = False, ): kwargs = locals() set_verbosity(verbose) @@ -1252,6 +1270,8 @@ def deploy_html( .save_deployed_info() .emit_task_log() ) + if not no_verify: + ce.verify_deployment() def generate_deploy_python(app_mode, alias, min_version): @@ -1312,11 +1332,6 @@ def generate_deploy_python(app_mode, alias, min_version): ) @shinyapps_deploy_args @cli_exception_handler - @click.option( - "--no-verify", - is_flag=True, - help="Don't access the deployed app to verify that it started correctly.", - ) def deploy_app( name: str, server: str, From 2838d8dccd0d63dc34998a0d666ee29eb5b0502c Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 12:37:43 -0400 Subject: [PATCH 03/11] tests for verification --- rsconnect/api.py | 7 ++++--- tests/test_main.py | 16 +++++++++++++++- tests/testdata/api/flask-bad/app.py | 19 +++++++++++++++++++ tests/testdata/api/flask-bad/requirements.txt | 7 +++++++ tests/testdata/api/flask/requirements.txt | 8 +++++++- 5 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 tests/testdata/api/flask-bad/app.py create mode 100644 tests/testdata/api/flask-bad/requirements.txt diff --git a/rsconnect/api.py b/rsconnect/api.py index 834e5d65..8f3087d0 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -823,9 +823,10 @@ def save_deployed_info(self, *args, **kwargs): @cls_logged("Verifying deployed content...") def verify_deployment(self, *args, **kwargs): - deployed_info = self.get("deployed_info", *args, **kwargs) - app_guid = deployed_info["app_guid"] - self.client.app_access(app_guid) + if isinstance(self.remote_server, RSConnectServer): + deployed_info = self.get("deployed_info", *args, **kwargs) + app_guid = deployed_info["app_guid"] + self.client.app_access(app_guid) @cls_logged("Validating app mode...") def validate_app_mode(self, *args, **kwargs): diff --git a/tests/test_main.py b/tests/test_main.py index b6f825d3..0ee007c9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -810,6 +810,21 @@ def test_deploy_api(self): result = runner.invoke(cli, args) assert result.exit_code == 0, result.output + def test_deploy_api_fail_verify(self): + target = optional_target(get_api_path("flask-bad")) + runner = CliRunner() + args = self.create_deploy_args("api", target) + result = runner.invoke(cli, args) + assert result.exit_code == 1, result.output + + def test_deploy_api_fail_no_verify(self): + target = optional_target(get_api_path("flask-bad")) + runner = CliRunner() + args = self.create_deploy_args("api", target) + args.append("--no-verify") + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + def test_add_connect(self): connect_server = require_connect() api_key = require_api_key() @@ -944,7 +959,6 @@ def setUp(self): def create_bootstrap_mock_callback(self, status, json_data): def request_callback(request, uri, response_headers): - # verify auth header is sent correctly authorization = request.headers.get("Authorization") auth_split = authorization.split(" ") diff --git a/tests/testdata/api/flask-bad/app.py b/tests/testdata/api/flask-bad/app.py new file mode 100644 index 00000000..891f2dd3 --- /dev/null +++ b/tests/testdata/api/flask-bad/app.py @@ -0,0 +1,19 @@ +import os +from flask import Flask, jsonify, request, url_for + +app = Flask(__name__) + + +@app.route("/ping") +def ping(): + return jsonify( + { + "headers": dict(request.headers), + "environ": dict(os.environ), + "link": url_for("ping"), + "external_link": url_for("ping", _external=True), + } + ) + + +raise Exception("this test app fails to start!") diff --git a/tests/testdata/api/flask-bad/requirements.txt b/tests/testdata/api/flask-bad/requirements.txt new file mode 100644 index 00000000..b336e0f5 --- /dev/null +++ b/tests/testdata/api/flask-bad/requirements.txt @@ -0,0 +1,7 @@ +blinker==1.6.3 +click==8.1.7 +Flask==3.0.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +Werkzeug==3.0.0 diff --git a/tests/testdata/api/flask/requirements.txt b/tests/testdata/api/flask/requirements.txt index 623d209e..b336e0f5 100644 --- a/tests/testdata/api/flask/requirements.txt +++ b/tests/testdata/api/flask/requirements.txt @@ -1 +1,7 @@ -flask ~= 1.1.1 +blinker==1.6.3 +click==8.1.7 +Flask==3.0.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +Werkzeug==3.0.0 From 880ac54feff808839408d49d56b15d24ad9486a9 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 12:45:28 -0400 Subject: [PATCH 04/11] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab26432..7bc1df77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a new verbose logging level. Specifying `-v` on the command line uses this new level. Currently this will cause filenames to be logged as they are added to a bundle. To enable maximum verbosity (debug level), use `-vv`. +- Added a verification step to the deployment process that accesses the deployed content. + This is a `GET` request to the content without parameters. The request is + considered successful if there isn't a 5xx code returned (errors like + 400 Bad Request or 405 Method Not Allowed because not all apps support `GET /`). + For cases where this is not desired, use the `--no-verify` flag on the command line. ### Changed - Removing experimental support for Conda. Connect does not support restoring Conda environments. From cbcdfca1faff3b99b9410c8492791611b4ca9515 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 12:48:34 -0400 Subject: [PATCH 05/11] update readme --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cf4da9df..fc6dfdfc 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ you will need to include the `--cacert` option that points to your certificate authority (CA) trusted certificates file. Both of these options can be saved along with the URL and API Key for a server. -> **Note** +> **Note** > When certificate information is saved for the server, the specified file > is read and its _contents_ are saved under the server's nickname. If the CA file's > contents are ever changed, you will need to add the server information again. @@ -135,7 +135,7 @@ rsconnect add \ --name myserver ``` -> **Note** +> **Note** > The `rsconnect` CLI will verify that the serve URL and API key > are valid. If either is found not to be, no information will be saved. @@ -428,9 +428,17 @@ containing the API or application. When using `rsconnect deploy manifest`, the title is derived from the primary filename referenced in the manifest. +#### Verification After Deployment +After deploying your content, rsconnect accesses the deployed content +to verify that the deployment is live. This is done with a `GET` request +to the content, without parameters. The request is +considered successful if there isn't a 5xx code returned. Errors like +400 Bad Request or 405 Method Not Allowed because not all apps support `GET /`. +For cases where this is not desired, use the `--no-verify` flag on the command line. + ### Environment variables You can set environment variables during deployment. Their names and values will be -passed to Posit Connect during deployment so you can use them in your code. Note that +passed to Posit Connect during deployment so you can use them in your code. Note that if you are using `rsconnect` to deploy to shinyapps.io, environment variable management is not supported on that platform. @@ -985,9 +993,9 @@ xargs printf -- '-g %s\n' < guids.txt | xargs rsconnect content build add ``` ## Programmatic Provisioning -Posit Connect supports the programmatic bootstrapping of an administrator API key +Posit Connect supports the programmatic bootstrapping of an administrator API key for scripted provisioning tasks. This process is supported by the `rsconnect bootstrap` command, -which uses a JSON Web Token to request an initial API key from a fresh Connect instance. +which uses a JSON Web Token to request an initial API key from a fresh Connect instance. > **Warning** > This feature **requires Python version 3.6 or higher**. @@ -998,7 +1006,7 @@ rsconnect bootstrap \ --jwt-keypath /path/to/secret.key ``` -A full description on how to use `rsconnect bootstrap` in a provisioning workflow is provided in the Connect administrator guide's +A full description on how to use `rsconnect bootstrap` in a provisioning workflow is provided in the Connect administrator guide's [programmatic provisioning](https://docs.posit.co/connect/admin/programmatic-provisioning) documentation. ## Server Administration Tasks From 03facf811ffbd1cee76f30806419f1e096183113 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 13:21:30 -0400 Subject: [PATCH 06/11] handle path prefix --- rsconnect/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 8f3087d0..9215e680 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3,7 +3,7 @@ """ import binascii import os -from os.path import abspath +from os.path import abspath, dirname import time from typing import IO, Callable import base64 @@ -200,8 +200,10 @@ def is_app_failed_response(self, response): def app_access(self, app_guid): method = "GET" - path = f"/content/{app_guid}/" + base = dirname(self._url.path) # remove __api__ + path = f"{base}/content/{app_guid}/" response = self._do_request(method, path, None, None, 3, {}, False) + print(response.status, end=None) # response = self.get("/content/%s/" % app_guid) if self.is_app_failed_response(response): From e48efe1e3676d6ff72e9187a7bcb9acf99ac44e7 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 13:28:17 -0400 Subject: [PATCH 07/11] rename module to avoid mypy conflict --- tests/testdata/api/flask-bad/{app.py => app2.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/testdata/api/flask-bad/{app.py => app2.py} (100%) diff --git a/tests/testdata/api/flask-bad/app.py b/tests/testdata/api/flask-bad/app2.py similarity index 100% rename from tests/testdata/api/flask-bad/app.py rename to tests/testdata/api/flask-bad/app2.py From 5e75fadada2ff43fd45a46814235a8e61acc2798 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 14:41:26 -0400 Subject: [PATCH 08/11] specify entrypoint for renamed app --- tests/test_main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 0ee007c9..cc78ffdf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -814,6 +814,7 @@ def test_deploy_api_fail_verify(self): target = optional_target(get_api_path("flask-bad")) runner = CliRunner() args = self.create_deploy_args("api", target) + args.extend(["-e", "app2"]) result = runner.invoke(cli, args) assert result.exit_code == 1, result.output @@ -821,7 +822,7 @@ def test_deploy_api_fail_no_verify(self): target = optional_target(get_api_path("flask-bad")) runner = CliRunner() args = self.create_deploy_args("api", target) - args.append("--no-verify") + args.extend(["--no-verify", "-e", "app2"]) result = runner.invoke(cli, args) assert result.exit_code == 0, result.output From d844d65314620e9a162eb49e37f028faf25c1dcc Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 14:58:18 -0400 Subject: [PATCH 09/11] app rename --- tests/test_main.py | 4 ++-- tests/testdata/api/flask-bad/{app2.py => badapp.py} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/testdata/api/flask-bad/{app2.py => badapp.py} (100%) diff --git a/tests/test_main.py b/tests/test_main.py index cc78ffdf..fb160a00 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -814,7 +814,7 @@ def test_deploy_api_fail_verify(self): target = optional_target(get_api_path("flask-bad")) runner = CliRunner() args = self.create_deploy_args("api", target) - args.extend(["-e", "app2"]) + args.extend(["-e", "badapp"]) result = runner.invoke(cli, args) assert result.exit_code == 1, result.output @@ -822,7 +822,7 @@ def test_deploy_api_fail_no_verify(self): target = optional_target(get_api_path("flask-bad")) runner = CliRunner() args = self.create_deploy_args("api", target) - args.extend(["--no-verify", "-e", "app2"]) + args.extend(["--no-verify", "-e", "badapp"]) result = runner.invoke(cli, args) assert result.exit_code == 0, result.output diff --git a/tests/testdata/api/flask-bad/app2.py b/tests/testdata/api/flask-bad/badapp.py similarity index 100% rename from tests/testdata/api/flask-bad/app2.py rename to tests/testdata/api/flask-bad/badapp.py From a7be6a5d02bb34ab9fb4242c6e49f2e3a52a73c2 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 15:11:30 -0400 Subject: [PATCH 10/11] run make test --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 851d2cc2..c49747dc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -57,7 +57,7 @@ jobs: - run: make fmt - run: make lint - run: python setup.py --version - - run: make mock-test-3.8 + - run: make test-3.8 distributions: needs: test From c026f90839ab8648291732fada17b848e75927ba Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Thu, 19 Oct 2023 16:15:41 -0400 Subject: [PATCH 11/11] remove debugging code --- rsconnect/api.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 9215e680..a3e4ee41 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -203,11 +203,8 @@ def app_access(self, app_guid): base = dirname(self._url.path) # remove __api__ path = f"{base}/content/{app_guid}/" response = self._do_request(method, path, None, None, 3, {}, False) - print(response.status, end=None) - # response = self.get("/content/%s/" % app_guid) if self.is_app_failed_response(response): - print(response.status, end=None) raise RSConnectException( "Could not access the deployed content. " + "The app might not have started successfully. "