Skip to content

Commit 210ab4d

Browse files
authored
Merge pull request #511 from rstudio/mm-verify-deployments
Verify deployments
2 parents f11851c + 9fbc8f3 commit 210ab4d

File tree

9 files changed

+110
-6
lines changed

9 files changed

+110
-6
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
- run: make fmt
6464
- run: make lint
6565
- run: rsconnect version
66-
- run: make mock-test-3.8
66+
- run: make test-3.8
6767

6868
distributions:
6969
needs: test

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- Added a new verbose logging level. Specifying `-v` on the command line uses this
3333
new level. Currently this will cause filenames to be logged as they are added to
3434
a bundle. To enable maximum verbosity (debug level), use `-vv`.
35+
- Added a verification step to the deployment process that accesses the deployed content.
36+
This is a `GET` request to the content without parameters. The request is
37+
considered successful if there isn't a 5xx code returned (errors like
38+
400 Bad Request or 405 Method Not Allowed because not all apps support `GET /`).
39+
For cases where this is not desired, use the `--no-verify` flag on the command line.
3540
- Added the `deploy flask` command.
3641
- Added the `write-manifest flask` command.
3742

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ containing the API or application.
429429
When using `rsconnect deploy manifest`, the title is derived from the primary
430430
filename referenced in the manifest.
431431

432+
#### Verification After Deployment
433+
After deploying your content, rsconnect accesses the deployed content
434+
to verify that the deployment is live. This is done with a `GET` request
435+
to the content, without parameters. The request is
436+
considered successful if there isn't a 5xx code returned. Errors like
437+
400 Bad Request or 405 Method Not Allowed because not all apps support `GET /`.
438+
For cases where this is not desired, use the `--no-verify` flag on the command line.
439+
432440
### Environment variables
433441
You can set environment variables during deployment. Their names and values will be
434442
passed to Posit Connect during deployment so you can use them in your code. Note that

rsconnect/api.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
import binascii
55
import os
6-
from os.path import abspath
6+
from os.path import abspath, dirname
77
import time
88
from typing import IO, Callable
99
import base64
@@ -195,6 +195,22 @@ def app_publish(self, app_id, access):
195195
def app_config(self, app_id):
196196
return self.get("applications/%s/config" % app_id)
197197

198+
def is_app_failed_response(self, response):
199+
return isinstance(response, HTTPResponse) and response.status >= 500
200+
201+
def app_access(self, app_guid):
202+
method = "GET"
203+
base = dirname(self._url.path) # remove __api__
204+
path = f"{base}/content/{app_guid}/"
205+
response = self._do_request(method, path, None, None, 3, {}, False)
206+
207+
if self.is_app_failed_response(response):
208+
raise RSConnectException(
209+
"Could not access the deployed content. "
210+
+ "The app might not have started successfully. "
211+
+ "Visit it in Connect to view the logs."
212+
)
213+
198214
def bundle_download(self, content_guid, bundle_id):
199215
response = self.get("v1/content/%s/bundles/%s/download" % (content_guid, bundle_id), decode_response=False)
200216
self._server.handle_bad_response(response)
@@ -300,7 +316,6 @@ def wait_for_task(
300316
poll_wait=0.5,
301317
raise_on_error=True,
302318
):
303-
304319
if log_callback is None:
305320
log_lines = []
306321
log_callback = log_lines.append
@@ -805,6 +820,13 @@ def save_deployed_info(self, *args, **kwargs):
805820

806821
return self
807822

823+
@cls_logged("Verifying deployed content...")
824+
def verify_deployment(self, *args, **kwargs):
825+
if isinstance(self.remote_server, RSConnectServer):
826+
deployed_info = self.get("deployed_info", *args, **kwargs)
827+
app_guid = deployed_info["app_guid"]
828+
self.client.app_access(app_guid)
829+
808830
@cls_logged("Validating app mode...")
809831
def validate_app_mode(self, *args, **kwargs):
810832
path = (
@@ -1331,7 +1353,6 @@ def prepare_deploy(
13311353
app_mode: AppMode,
13321354
app_store_version: typing.Optional[int],
13331355
) -> PrepareDeployOutputResult:
1334-
13351356
application_type = "static" if app_mode in [AppModes.STATIC, AppModes.STATIC_QUARTO] else "connect"
13361357
logger.debug(f"application_type: {application_type}")
13371358

rsconnect/main.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ def content_args(func):
239239
"or just NAME to use the value from the local environment. "
240240
"May be specified multiple times. [v1.8.6+]",
241241
)
242+
@click.option(
243+
"--no-verify",
244+
is_flag=True,
245+
help="Don't access the deployed content to verify that it started correctly.",
246+
)
242247
@functools.wraps(func)
243248
def wrapper(*args, **kwargs):
244249
return func(*args, **kwargs)
@@ -851,6 +856,7 @@ def deploy_notebook(
851856
disable_env_management: bool,
852857
env_management_py: bool,
853858
env_management_r: bool,
859+
no_verify: bool = False,
854860
):
855861
kwargs = locals()
856862
set_verbosity(verbose)
@@ -893,6 +899,8 @@ def deploy_notebook(
893899
env_management_r=env_management_r,
894900
)
895901
ce.deploy_bundle().save_deployed_info().emit_task_log()
902+
if not no_verify:
903+
ce.verify_deployment()
896904

897905

898906
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -971,6 +979,7 @@ def deploy_voila(
971979
cacert: typing.IO = None,
972980
connect_server: api.RSConnectServer = None,
973981
multi_notebook: bool = False,
982+
no_verify: bool = False,
974983
):
975984
kwargs = locals()
976985
set_verbosity(verbose)
@@ -994,6 +1003,8 @@ def deploy_voila(
9941003
env_management_r=env_management_r,
9951004
multi_notebook=multi_notebook,
9961005
).deploy_bundle().save_deployed_info().emit_task_log()
1006+
if not no_verify:
1007+
ce.verify_deployment()
9971008

9981009

9991010
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -1029,6 +1040,7 @@ def deploy_manifest(
10291040
file: str,
10301041
env_vars: typing.Dict[str, str],
10311042
visibility: typing.Optional[str],
1043+
no_verify: bool = False,
10321044
):
10331045
kwargs = locals()
10341046
set_verbosity(verbose)
@@ -1049,6 +1061,8 @@ def deploy_manifest(
10491061
.save_deployed_info()
10501062
.emit_task_log()
10511063
)
1064+
if not no_verify:
1065+
ce.verify_deployment()
10521066

10531067

10541068
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -1126,6 +1140,7 @@ def deploy_quarto(
11261140
disable_env_management: bool,
11271141
env_management_py: bool,
11281142
env_management_r: bool,
1143+
no_verify: bool = False,
11291144
):
11301145
kwargs = locals()
11311146
set_verbosity(verbose)
@@ -1176,6 +1191,8 @@ def deploy_quarto(
11761191
.save_deployed_info()
11771192
.emit_task_log()
11781193
)
1194+
if not no_verify:
1195+
ce.verify_deployment()
11791196

11801197

11811198
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -1229,6 +1246,7 @@ def deploy_html(
12291246
account: str = None,
12301247
token: str = None,
12311248
secret: str = None,
1249+
no_verify: bool = False,
12321250
):
12331251
kwargs = locals()
12341252
set_verbosity(verbose)
@@ -1254,6 +1272,8 @@ def deploy_html(
12541272
.save_deployed_info()
12551273
.emit_task_log()
12561274
)
1275+
if not no_verify:
1276+
ce.verify_deployment()
12571277

12581278

12591279
def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc: Optional[str] = None):
@@ -1343,6 +1363,7 @@ def deploy_app(
13431363
account: str = None,
13441364
token: str = None,
13451365
secret: str = None,
1366+
no_verify: bool = False,
13461367
):
13471368
set_verbosity(verbose)
13481369
kwargs = locals()
@@ -1374,6 +1395,8 @@ def deploy_app(
13741395
.save_deployed_info()
13751396
.emit_task_log()
13761397
)
1398+
if not no_verify:
1399+
ce.verify_deployment()
13771400

13781401
return deploy_app
13791402

tests/test_main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,22 @@ def test_deploy_api(self):
810810
result = runner.invoke(cli, args)
811811
assert result.exit_code == 0, result.output
812812

813+
def test_deploy_api_fail_verify(self):
814+
target = optional_target(get_api_path("flask-bad"))
815+
runner = CliRunner()
816+
args = self.create_deploy_args("api", target)
817+
args.extend(["-e", "badapp"])
818+
result = runner.invoke(cli, args)
819+
assert result.exit_code == 1, result.output
820+
821+
def test_deploy_api_fail_no_verify(self):
822+
target = optional_target(get_api_path("flask-bad"))
823+
runner = CliRunner()
824+
args = self.create_deploy_args("api", target)
825+
args.extend(["--no-verify", "-e", "badapp"])
826+
result = runner.invoke(cli, args)
827+
assert result.exit_code == 0, result.output
828+
813829
def test_add_connect(self):
814830
connect_server = require_connect()
815831
api_key = require_api_key()
@@ -944,7 +960,6 @@ def setUp(self):
944960

945961
def create_bootstrap_mock_callback(self, status, json_data):
946962
def request_callback(request, uri, response_headers):
947-
948963
# verify auth header is sent correctly
949964
authorization = request.headers.get("Authorization")
950965
auth_split = authorization.split(" ")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import os
2+
from flask import Flask, jsonify, request, url_for
3+
4+
app = Flask(__name__)
5+
6+
7+
@app.route("/ping")
8+
def ping():
9+
return jsonify(
10+
{
11+
"headers": dict(request.headers),
12+
"environ": dict(os.environ),
13+
"link": url_for("ping"),
14+
"external_link": url_for("ping", _external=True),
15+
}
16+
)
17+
18+
19+
raise Exception("this test app fails to start!")
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
blinker==1.6.3
2+
click==8.1.7
3+
Flask==3.0.0
4+
itsdangerous==2.1.2
5+
Jinja2==3.1.2
6+
MarkupSafe==2.1.3
7+
Werkzeug==3.0.0
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
flask ~= 1.1.1
1+
blinker==1.6.3
2+
click==8.1.7
3+
Flask==3.0.0
4+
itsdangerous==2.1.2
5+
Jinja2==3.1.2
6+
MarkupSafe==2.1.3
7+
Werkzeug==3.0.0

0 commit comments

Comments
 (0)