From 40d164e30097b6f4b8914683e3eff01f55f918a9 Mon Sep 17 00:00:00 2001 From: Cole Arendt Date: Wed, 21 Sep 2022 13:49:11 -0400 Subject: [PATCH 1/7] add boilerplate for git deployment --- rsconnect/api.py | 23 +++++++++++++++++++++ rsconnect/main.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index c004958d..4d9f2e18 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -233,6 +233,29 @@ def task_get(self, task_id, first_status=None): self._server.handle_bad_response(response) return response + + def deploy_git(self, app_name, repository, branch, subdirectory): + app = self.app_create(app_name) + self._server.handle_bad_response(app) + + self.post( + "applications/%s/repo" % app["guid"], + body={ + "repository": repository, "branch": branch , "subdirectory": subdirectory + } + ) + + task = self.post("applications/%s/deploy" % app["guid"], body=dict()) + self._server.handle_bad_response(task) + + return { + "task_id": task["id"], + "app_id": app["id"], + "app_guid": app["guid"], + "app_url": app["url"], + "title": app["title"], + } + def deploy(self, app_id, app_name, app_title, title_is_default, tarball, env_vars=None): if app_id is None: # create an app if id is not provided diff --git a/rsconnect/main.py b/rsconnect/main.py index 5af3128f..31301539 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1436,6 +1436,56 @@ def deploy_help(): click.echo() +@deploy.command( + name="git", + short_help="Deploy git repository with exisiting manifest file", + help="Deploy git repository with exisiting manifest file" +) +@click.option("--name", "-n", help="The nickname of the RStudio Connect server to deploy to.") +@click.option( + "--server", "-s", envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", +) +@click.option( + "--api-key", "-k", envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", +) +@click.option( + "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certification/host validation.", +) +@click.option( + "--cacert", + "-c", + envvar="CONNECT_CA_CERTIFICATE", + type=click.File(), + help="The path to trusted TLS CA certificates.", +) +@click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") +@click.option('--app_name', '-a') +@click.option('--repository', "-r") +@click.option('--branch', "-b") +@click.option('--subdirectory', "-d") +def deploy_git(name, server, api_key, insecure, cacert, verbose, app_name, repository, branch, subdirectory): + set_verbosity(verbose) + + with cli_feedback("Checking arguments"): + connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) + + connect_client = api.RSConnect(connect_server) + + with cli_feedback("Deploying git repository"): + app = connect_client.deploy_git(app_name, repository, branch, subdirectory) + + with cli_feedback(""): + click.secho("\nDeployment log:", fg="bright_white") + app_url, _ = spool_deployment_log(connect_server, app, click.echo) + click.secho("Deployment completed successfully.", fg="bright_white") + click.secho(" Dashboard content URL: %s" % app_url, fg="bright_white") + click.secho(" Direct content URL: %s" % app["app_url"], fg="bright_white") + + + click.secho("Git deployment completed successfully.", fg="bright_white") + click.secho("App available as %s" % app_name, fg="bright_white") + + @cli.group( name="write-manifest", no_args_is_help=True, @@ -1449,7 +1499,6 @@ def deploy_help(): def write_manifest(): pass - @write_manifest.command( name="notebook", short_help="Create a manifest.json file for a Jupyter notebook.", From cb7eaa8c0c6fe7e4dec49fbeb7ba7fa777942690 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Tue, 17 Oct 2023 09:48:44 -0400 Subject: [PATCH 2/7] update to use executor --- rsconnect/api.py | 49 +++++++++++++++-------------- rsconnect/main.py | 79 ++++++++++++++++++----------------------------- 2 files changed, 56 insertions(+), 72 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 4d9f2e18..ce13e156 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -233,19 +233,17 @@ def task_get(self, task_id, first_status=None): self._server.handle_bad_response(response) return response - def deploy_git(self, app_name, repository, branch, subdirectory): app = self.app_create(app_name) self._server.handle_bad_response(app) - self.post( + resp = self.post( "applications/%s/repo" % app["guid"], - body={ - "repository": repository, "branch": branch , "subdirectory": subdirectory - } + body={"repository": repository, "branch": branch, "subdirectory": subdirectory}, ) + self._server.handle_bad_response(resp) - task = self.post("applications/%s/deploy" % app["guid"], body=dict()) + task = self.app_deploy(app["guid"]) self._server.handle_bad_response(task) return { @@ -323,7 +321,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 @@ -764,6 +761,18 @@ def deploy_bundle( } return self + @cls_logged("Deploying git repository ...") + def deploy_git(self, app_name: str = None, repository: str = None, branch: str = None, subdirectory: str = None): + app_name = app_name or self.get("app_name") + repository = repository or self.get("repository") + branch = branch or self.get("branch") + subdirectory = subdirectory or self.get("subdirectory") + + result = self.client.deploy_git(app_name, repository, branch, subdirectory) + self.remote_server.handle_bad_response(result) + self.state["deployed_info"] = result + return self + def emit_task_log( self, app_id: int = None, @@ -1144,14 +1153,9 @@ def create_application(self, account_id, application_name): return response def create_output(self, name: str, application_type: str, project_id=None, space_id=None, render_by=None): - data = { - "name": name, - "space": space_id, - "project": project_id, - "application_type": application_type - } + data = {"name": name, "space": space_id, "project": project_id, "application_type": application_type} if render_by: - data['render_by'] = render_by + data["render_by"] = render_by response = self.post("/v1/outputs/", body=data) self._server.handle_bad_response(response) return response @@ -1364,10 +1368,7 @@ 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" + application_type = "static" if app_mode in [AppModes.STATIC, AppModes.STATIC_QUARTO] else "connect" logger.debug(f"application_type: {application_type}") render_by = "server" if app_mode == AppModes.STATIC_QUARTO else None @@ -1385,11 +1386,13 @@ def prepare_deploy( space_id = None # create the new output and associate it with the current Posit Cloud project and space - output = self._rstudio_client.create_output(name=app_name, - application_type=application_type, - project_id=project_id, - space_id=space_id, - render_by=render_by) + output = self._rstudio_client.create_output( + name=app_name, + application_type=application_type, + project_id=project_id, + space_id=space_id, + render_by=render_by, + ) app_id_int = output["source_id"] else: # this is a redeployment of an existing output diff --git a/rsconnect/main.py b/rsconnect/main.py index 31301539..bef08a10 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -243,6 +243,7 @@ def wrapper(*args, **kwargs): return wrapper + # This callback handles the "shorthand" --disable-env-management option. # If the shorthand flag is provided, then it takes precendence over the R and Python flags. # This callback also inverts the --disable-env-management-r and @@ -252,7 +253,7 @@ def wrapper(*args, **kwargs): # which is more consistent when writing these values to the manifest. def env_management_callback(ctx, param, value) -> typing.Optional[bool]: # eval the shorthand flag if it was provided - disable_env_management = ctx.params.get('disable_env_management') + disable_env_management = ctx.params.get("disable_env_management") if disable_env_management is not None: value = disable_env_management @@ -486,7 +487,6 @@ def bootstrap( @cloud_shinyapps_args @click.pass_context def add(ctx, name, server, api_key, insecure, cacert, account, token, secret, verbose): - set_verbosity(verbose) if click.__version__ >= "8.0.0" and sys.version_info >= (3, 7): click.echo("Detected the following inputs:") @@ -1081,11 +1081,11 @@ def deploy_manifest( name="quarto", short_help="Deploy Quarto content to Posit Connect [v2021.08.0+] or Posit Cloud.", help=( - 'Deploy a Quarto document or project to Posit Connect or Posit Cloud. Should the content use the Quarto ' + "Deploy a Quarto document or project to Posit Connect or Posit Cloud. Should the content use the Quarto " 'Jupyter engine, an environment file ("requirements.txt") is created and included in the deployment if one ' - 'does not already exist. Requires Posit Connect 2021.08.0 or later.' - '\n\n' - 'FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project.' + "does not already exist. Requires Posit Connect 2021.08.0 or later." + "\n\n" + "FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project." ), no_args_is_help=True, ) @@ -1439,51 +1439,31 @@ def deploy_help(): @deploy.command( name="git", short_help="Deploy git repository with exisiting manifest file", - help="Deploy git repository with exisiting manifest file" -) -@click.option("--name", "-n", help="The nickname of the RStudio Connect server to deploy to.") -@click.option( - "--server", "-s", envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", -) -@click.option( - "--api-key", "-k", envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", -) -@click.option( - "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certification/host validation.", + help="Deploy git repository with exisiting manifest file", ) -@click.option( - "--cacert", - "-c", - envvar="CONNECT_CA_CERTIFICATE", - type=click.File(), - help="The path to trusted TLS CA certificates.", -) -@click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") -@click.option('--app_name', '-a') -@click.option('--repository', "-r") -@click.option('--branch', "-b") -@click.option('--subdirectory', "-d") -def deploy_git(name, server, api_key, insecure, cacert, verbose, app_name, repository, branch, subdirectory): +@server_args +@click.option("--app_name", "-a") +@click.option("--repository", "-r", required=True) +@click.option("--branch", "-b", default="main") +@click.option("--subdirectory", "-d", default="/") +@cli_exception_handler +def deploy_git( + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: typing.IO, + verbose, + app_name: str, + repository: str, + branch: str, + subdirectory: str, +): + subdirectory = subdirectory.strip("/") + kwargs = locals() set_verbosity(verbose) - - with cli_feedback("Checking arguments"): - connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) - - connect_client = api.RSConnect(connect_server) - - with cli_feedback("Deploying git repository"): - app = connect_client.deploy_git(app_name, repository, branch, subdirectory) - - with cli_feedback(""): - click.secho("\nDeployment log:", fg="bright_white") - app_url, _ = spool_deployment_log(connect_server, app, click.echo) - click.secho("Deployment completed successfully.", fg="bright_white") - click.secho(" Dashboard content URL: %s" % app_url, fg="bright_white") - click.secho(" Direct content URL: %s" % app["app_url"], fg="bright_white") - - - click.secho("Git deployment completed successfully.", fg="bright_white") - click.secho("App available as %s" % app_name, fg="bright_white") + ce = RSConnectExecutor(**kwargs) + ce.validate_server().deploy_git().emit_task_log() @cli.group( @@ -1499,6 +1479,7 @@ def deploy_git(name, server, api_key, insecure, cacert, verbose, app_name, repos def write_manifest(): pass + @write_manifest.command( name="notebook", short_help="Create a manifest.json file for a Jupyter notebook.", From 2ecf14ad3155fcff87949b807b0fb6702a8fe98f Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Tue, 17 Oct 2023 09:53:02 -0400 Subject: [PATCH 3/7] update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dff38c..c3b8d371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `*_app.py` are now considered. However, if the directory contains more than one file matching these new patterns, you must provide rsconnect-python with an explicit `--entrypoint` argument. +- Added support for deploying directly from remote git repositories. Only + Connect server targets are supported, and the Connect server must have git + configured with access to your git repositories. See the + [Connect administrator guide](https://docs.posit.co/connect/admin/content-management/git-backed/) + and + [Connect user guide](https://docs.posit.co/connect/user/git-backed/) for details. ## [1.20.0] - 2023-09-11 From 7524b1d503655e0e37e0072bf0b1828c2d34f09c Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Tue, 17 Oct 2023 10:22:19 -0400 Subject: [PATCH 4/7] add docs to README --- README.md | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cf4da9df..841ed08a 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. @@ -407,6 +407,35 @@ library(rsconnect) ?rsconnect::writeManifest ``` +### Deploying from Git Repositories +You can deploy content directly from from hosted Git repositories to Posit Connect. +The content must have an existing `manifest.json` file to identify the content +type. For Python content, a `requirements.txt` file must also be present. + +See the [Connect user guide](https://docs.posit.co/connect/user/git-backed/) +for details on how to prepare your content for Git publishing. + +Once your git repository contains the prepared content, use the `deploy git` command: +``` +rsconnect deploy git -r https://my.repository.server/repository +``` + +To deploy from a branch other than `main`, use the `--branch/-b` option. + +To deploy content from a subdirectory, provide the subdirectory +using the `--subdirectory/-d` option. The specified directory +must contain the `manifest.json` file. + +``` +rsconnect deploy git -r https://my.repository.server/repository -b my-branch -d path/within/repo +``` + +These commands create a new git-backed deployment within Posit Connect, +which will periodically check for new commits to your repository/branch +and deploy updates automatically. Do not run the +`deploy git` command again for the same source +unless you want to create a second, separate deployment for it. + ### Options for All Types of Deployments These options apply to any type of content deployment. @@ -430,7 +459,7 @@ filename referenced in the manifest. ### 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 +1014,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 +1027,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 648b2f5db4507b536d4c55a7652e6b607984694c Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Tue, 17 Oct 2023 10:22:39 -0400 Subject: [PATCH 5/7] add support for title and env_vars --- rsconnect/api.py | 25 ++++++++++++++++++++++--- rsconnect/main.py | 13 +++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index ce13e156..58c11914 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -233,7 +233,7 @@ def task_get(self, task_id, first_status=None): self._server.handle_bad_response(response) return response - def deploy_git(self, app_name, repository, branch, subdirectory): + def deploy_git(self, app_name, repository, branch, subdirectory, app_title, env_vars): app = self.app_create(app_name) self._server.handle_bad_response(app) @@ -243,6 +243,15 @@ def deploy_git(self, app_name, repository, branch, subdirectory): ) self._server.handle_bad_response(resp) + if app_title: + resp = self.app_update(app["guid"], {"title": app_title}) + self._server.handle_bad_response(resp) + app["title"] = app_title + + if env_vars: + result = self.app_add_environment_vars(app["guid"], list(env_vars.items())) + self._server.handle_bad_response(result) + task = self.app_deploy(app["guid"]) self._server.handle_bad_response(task) @@ -762,13 +771,23 @@ def deploy_bundle( return self @cls_logged("Deploying git repository ...") - def deploy_git(self, app_name: str = None, repository: str = None, branch: str = None, subdirectory: str = None): + def deploy_git( + self, + app_name: str = None, + title: str = None, + repository: str = None, + branch: str = None, + subdirectory: str = None, + env_vars: typing.Dict[str, str] = None, + ): app_name = app_name or self.get("app_name") repository = repository or self.get("repository") branch = branch or self.get("branch") subdirectory = subdirectory or self.get("subdirectory") + title = title or self.get("title") + env_vars = env_vars or self.get("env_vars") - result = self.client.deploy_git(app_name, repository, branch, subdirectory) + result = self.client.deploy_git(app_name, repository, branch, subdirectory, title, env_vars) self.remote_server.handle_bad_response(result) self.state["deployed_info"] = result return self diff --git a/rsconnect/main.py b/rsconnect/main.py index bef08a10..f4e1fe66 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1446,6 +1446,17 @@ def deploy_help(): @click.option("--repository", "-r", required=True) @click.option("--branch", "-b", default="main") @click.option("--subdirectory", "-d", default="/") +@click.option("--title", "-t", help="Title of the content (default is the same as the filename).") +@click.option( + "--environment", + "-E", + "env_vars", + multiple=True, + callback=validate_env_vars, + help="Set an environment variable. Specify a value with NAME=VALUE, " + "or just NAME to use the value from the local environment. " + "May be specified multiple times. [v1.8.6+]", +) @cli_exception_handler def deploy_git( name: str, @@ -1458,6 +1469,8 @@ def deploy_git( repository: str, branch: str, subdirectory: str, + title: str, + env_vars: typing.Dict[str, str], ): subdirectory = subdirectory.strip("/") kwargs = locals() From 81824f88ea393fea9c96fd2ea9b9a05fe46c74f2 Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Fri, 20 Oct 2023 14:58:08 -0400 Subject: [PATCH 6/7] add help for CLI options --- rsconnect/main.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index b3f94b85..3a6e57b2 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1408,9 +1408,24 @@ def deploy_help(): ) @server_args @click.option("--app_name", "-a") -@click.option("--repository", "-r", required=True) -@click.option("--branch", "-b", default="main") -@click.option("--subdirectory", "-d", default="/") +@click.option( + "--repository", + "-r", + required=True, + help="Repository URL to deploy, e.g. https://github.com/username/repository. Only https URLs are supported.", +) +@click.option( + "--branch", + "-b", + default="main", + help="Name of the branch to deploy. Connect will automatically deploy updates when commits are pushed to the branch.", +) +@click.option( + "--subdirectory", + "-d", + default="/", + help="Directory within the repository to deploy. The directory must contain a manifest.json file.", +) @click.option("--title", "-t", help="Title of the content (default is the same as the filename).") @click.option( "--environment", From 85d17b468e54911d7bff0769e64881612c76492b Mon Sep 17 00:00:00 2001 From: Michael Marchetti Date: Fri, 20 Oct 2023 15:33:30 -0400 Subject: [PATCH 7/7] formatting --- rsconnect/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 3a6e57b2..79152ae8 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1418,7 +1418,8 @@ def deploy_help(): "--branch", "-b", default="main", - help="Name of the branch to deploy. Connect will automatically deploy updates when commits are pushed to the branch.", + help=("Name of the branch to deploy. Connect will automatically " + + "deploy updates when commits are pushed to the branch."), ) @click.option( "--subdirectory",