diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a66cc122..5063736f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `rsconnect deploy git` command to create a [git-backed deployment](https://docs.posit.co/connect/user/git-backed/). + Use `--branch` to specify a branch (default: main) and `--subdirectory` to deploy content from a subdirectory. - `rsconnect content get-lockfile` command allows fetching a lockfile with the dependencies installed by connect to run the deployed content - `rsconnect content venv` command recreates a local python environment @@ -22,7 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or override detected values. Use `--no-metadata` to disable automatic detection. (#736) supply an explicit requirements file instead of detecting the environment. - ## [1.28.2] - 2025-12-05 ### Fixed diff --git a/rsconnect/api.py b/rsconnect/api.py index 8753012c..36c32a32 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -592,6 +592,73 @@ def content_deploy( response = self._server.handle_bad_response(response) return response + def deploy_git( + self, + app_id: Optional[str], + name: str, + repository: str, + branch: str, + subdirectory: str, + title: Optional[str], + env_vars: Optional[dict[str, str]], + ) -> RSConnectClientDeployResult: + """Deploy content from a git repository. + + Creates a git-backed content item in Posit Connect. Connect will clone + the repository and automatically redeploy when commits are pushed. + + :param app_id: Existing content ID/GUID to update, or None to create new content + :param name: Name for the content item (used if creating new) + :param repository: URL of the git repository (https:// only) + :param branch: Branch to deploy from + :param subdirectory: Subdirectory containing manifest.json + :param title: Title for the content + :param env_vars: Environment variables to set + :return: Deployment result with task_id, app info, etc. + """ + # Create or get existing content + if app_id is None: + app = self.content_create(name) + else: + try: + app = self.get_content_by_id(app_id) + except RSConnectException as e: + raise RSConnectException( + f"{e} Try setting the --new flag or omit --app-id to create new content." + ) from e + + app_guid = app["guid"] + + # Set repository info via POST to applications/{guid}/repo + # This is a Connect-specific endpoint for git-backed content + resp = self.post( + "applications/%s/repo" % app_guid, + body={"repository": repository, "branch": branch, "subdirectory": subdirectory}, + ) + self._server.handle_bad_response(resp) + + # Update title if provided (and different from current) + if title and app.get("title") != title: + self.patch("v1/content/%s" % app_guid, body={"title": title}) + + # Set environment variables + if env_vars: + result = self.add_environment_vars(app_guid, list(env_vars.items())) + self._server.handle_bad_response(result) + + # Trigger deployment (bundle_id=None uses the latest bundle from git clone) + task = self.content_deploy(app_guid, bundle_id=None) + + return RSConnectClientDeployResult( + app_id=str(app["id"]), + app_guid=app_guid, + app_url=app["content_url"], + task_id=task["task_id"], + title=title or app.get("title"), + dashboard_url=app["dashboard_url"], + draft_url=None, + ) + def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]: response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime")) response = self._server.handle_bad_response(response) @@ -784,6 +851,9 @@ def __init__( disable_env_management: Optional[bool] = None, env_vars: Optional[dict[str, str]] = None, metadata: Optional[dict[str, str]] = None, + repository: Optional[str] = None, + branch: Optional[str] = None, + subdirectory: Optional[str] = None, ) -> None: self.remote_server: TargetableServer self.client: RSConnectClient | PositClient @@ -805,6 +875,11 @@ def __init__( self.title_is_default: bool = not title self.deployment_name: str | None = None + # Git deployment parameters + self.repository: str | None = repository + self.branch: str | None = branch + self.subdirectory: str | None = subdirectory + self.bundle: IO[bytes] | None = None self.deployed_info: RSConnectClientDeployResult | None = None @@ -847,6 +922,9 @@ def fromConnectServer( disable_env_management: Optional[bool] = None, env_vars: Optional[dict[str, str]] = None, metadata: Optional[dict[str, str]] = None, + repository: Optional[str] = None, + branch: Optional[str] = None, + subdirectory: Optional[str] = None, ): return cls( ctx=ctx, @@ -870,6 +948,9 @@ def fromConnectServer( disable_env_management=disable_env_management, env_vars=env_vars, metadata=metadata, + repository=repository, + branch=branch, + subdirectory=subdirectory, ) def output_overlap_header(self, previous: bool) -> bool: @@ -1169,6 +1250,48 @@ def deploy_bundle(self, activate: bool = True): ) return self + @cls_logged("Creating git-backed deployment ...") + def deploy_git(self): + """Deploy content from a remote git repository. + + Creates a git-backed content item in Posit Connect. Connect will clone + the repository and automatically redeploy when commits are pushed. + """ + if not isinstance(self.client, RSConnectClient): + raise RSConnectException( + "Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud." + ) + + if not self.repository: + raise RSConnectException("Repository URL is required for git deployment.") + + # Generate a valid deployment name from the title + # This sanitizes characters like "/" that aren't allowed in names + force_unique_name = self.app_id is None + deployment_name = self.make_deployment_name(self.title, force_unique_name) + + try: + result = self.client.deploy_git( + app_id=self.app_id, + name=deployment_name, + repository=self.repository, + branch=self.branch or "main", + subdirectory=self.subdirectory or "", + title=self.title, + env_vars=self.env_vars, + ) + except RSConnectException as e: + # Check for 404 on /repo endpoint (git not enabled) + if "404" in str(e) and "repo" in str(e).lower(): + raise RSConnectException( + "Git-backed deployment is not enabled on this Connect server. " + "Contact your administrator to enable Git support." + ) from e + raise + + self.deployed_info = result + return self + def emit_task_log( self, log_callback: logging.Logger = connect_logger, diff --git a/rsconnect/main.py b/rsconnect/main.py index e939ab30..55a818f5 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -3,12 +3,12 @@ import functools import json import os -import sys -import textwrap -import traceback import shutil import subprocess +import sys import tempfile +import textwrap +import traceback from functools import wraps from os.path import abspath, dirname, exists, isdir, join from typing import ( @@ -91,7 +91,7 @@ write_tensorflow_manifest_json, write_voila_manifest_json, ) -from .environment import Environment, fake_module_file_from_directory +from .environment import Environment, PackageInstaller, fake_module_file_from_directory from .exception import RSConnectException from .git_metadata import detect_git_metadata from .json_web_token import ( @@ -113,7 +113,6 @@ VersionSearchFilter, VersionSearchFilterParamType, ) -from .environment import PackageInstaller from .shiny_express import escape_to_var_name, is_express_app from .utils_package import fix_starlette_requirements @@ -327,6 +326,23 @@ def prepare_deploy_metadata( return None +def _generate_git_title(repository: str, subdirectory: str) -> str: + """Generate a title from repository URL and subdirectory. + + :param repository: URL of the git repository + :param subdirectory: Subdirectory within the repository + :return: Generated title string + """ + # Extract repo name from URL (e.g., "https://github.com/user/repo" -> "repo") + repo_name = repository.rstrip("/").split("/")[-1] + if repo_name.endswith(".git"): + repo_name = repo_name[:-4] + + if subdirectory and subdirectory != "/" and subdirectory.strip("/"): + return f"{repo_name}/{subdirectory.strip('/')}" + return repo_name + + def content_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--new", @@ -1474,6 +1490,92 @@ def deploy_manifest( ce.verify_deployment() +@deploy.command( + name="git", + short_help="Deploy content from a Git repository to Posit Connect.", + help=( + "Deploy content to Posit Connect directly from a remote Git repository. " + "The repository must contain a manifest.json file (in the root or specified subdirectory). " + "Connect will periodically check for updates and redeploy automatically when commits are pushed." + "\n\n" + "This command creates a new git-backed content item. To update an existing git-backed " + "content item, use the --app-id option with the content's GUID." + ), +) +@server_args +@spcs_args +@content_args +@click.option( + "--repository", + "-r", + required=True, + help="URL of the Git repository (https:// URLs only).", +) +@click.option( + "--branch", + "-b", + default="main", + help="Branch to deploy from. Connect auto-deploys when commits are pushed. [default: main]", +) +@click.option( + "--subdirectory", + "-d", + default="", + help="Subdirectory containing manifest.json. Use path syntax (e.g., 'path/to/content').", +) +@cli_exception_handler +@click.pass_context +def deploy_git( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + new: bool, + app_id: Optional[str], + title: Optional[str], + env_vars: dict[str, str], + no_verify: bool, + draft: bool, + metadata: tuple[str, ...], + no_metadata: bool, + repository: str, + branch: str, + subdirectory: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + # Generate title if not provided + if not title: + title = _generate_git_title(repository, subdirectory) + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + server=server, + new=new, + app_id=app_id, + title=title, + env_vars=env_vars, + repository=repository, + branch=branch, + subdirectory=subdirectory.strip("/") if subdirectory else "", + ) + + ce.validate_server().deploy_git().emit_task_log() + + if not no_verify: + ce.verify_deployment() + + # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="quarto", diff --git a/tests/test_api.py b/tests/test_api.py index b42f816c..13678939 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -408,3 +408,159 @@ def test_exchange_token_empty_response(self, mock_fmt_payload, mock_token_endpoi RSConnectException, match="Failed to exchange Snowflake token: Token exchange returned empty response" ): server.exchange_token() + + +class RSConnectClientDeployGitTestCase(TestCase): + """Tests for RSConnectClient.deploy_git() method.""" + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_git_creates_new_content(self): + """Test that deploy_git creates new content when app_id is None.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + # Mock content creation + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content", + body=json.dumps({ + "id": 123, + "guid": "abc-123", + "name": "test-app", + "title": None, + "content_url": "http://test-server/content/abc-123/", + "dashboard_url": "http://test-server/connect/#/apps/abc-123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock repository configuration + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/applications/abc-123/repo", + body=json.dumps({"message": "Repository configured"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock title update + httpretty.register_uri( + httpretty.PATCH, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({"title": "Test App"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock content deploy + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content/abc-123/deploy", + body=json.dumps({"task_id": "task-456"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.deploy_git( + app_id=None, + name="test-app", + repository="https://github.com/user/repo", + branch="main", + subdirectory="", + title="Test App", + env_vars=None, + ) + + self.assertEqual(result["app_id"], "123") + self.assertEqual(result["app_guid"], "abc-123") + self.assertEqual(result["task_id"], "task-456") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_git_updates_existing_content(self): + """Test that deploy_git updates existing content when app_id is provided.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + # Mock get existing content + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({ + "id": 123, + "guid": "abc-123", + "name": "existing-app", + "title": "Old Title", + "content_url": "http://test-server/content/abc-123/", + "dashboard_url": "http://test-server/connect/#/apps/abc-123", + }), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock repository configuration + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/applications/abc-123/repo", + body=json.dumps({"message": "Repository configured"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock title update + httpretty.register_uri( + httpretty.PATCH, + "http://test-server/__api__/v1/content/abc-123", + body=json.dumps({"title": "New Title"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + # Mock content deploy + httpretty.register_uri( + httpretty.POST, + "http://test-server/__api__/v1/content/abc-123/deploy", + body=json.dumps({"task_id": "task-789"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + result = client.deploy_git( + app_id="abc-123", + name="existing-app", + repository="https://github.com/user/repo", + branch="feature", + subdirectory="app", + title="New Title", + env_vars=None, + ) + + self.assertEqual(result["app_id"], "123") + self.assertEqual(result["app_guid"], "abc-123") + self.assertEqual(result["task_id"], "task-789") + + +class RSConnectExecutorDeployGitTestCase(TestCase): + """Tests for RSConnectExecutor.deploy_git() method.""" + + def test_deploy_git_rejects_non_connect_server(self): + """Test that deploy_git raises error for non-Connect servers.""" + # Create an executor with a PositClient (shinyapps.io) + executor = Mock() + executor.client = Mock(spec=PositClient) + executor.repository = "https://github.com/user/repo" + executor.logger = None # Needed for @cls_logged decorator + + # Call the real deploy_git method + with pytest.raises(RSConnectException, match="only supported for Posit Connect"): + RSConnectExecutor.deploy_git(executor) + + def test_deploy_git_requires_repository(self): + """Test that deploy_git raises error when repository is not set.""" + executor = Mock() + executor.client = Mock(spec=RSConnectClient) + executor.repository = None + executor.logger = None # Needed for @cls_logged decorator + + with pytest.raises(RSConnectException, match="Repository URL is required"): + RSConnectExecutor.deploy_git(executor) diff --git a/tests/test_main.py b/tests/test_main.py index dd3410c1..b8c478a8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1052,3 +1052,64 @@ def test_boostrap_raw_output_nonsuccess(self): self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(result.output.find("Error:"), -1) + + +class TestDeployGit(TestCase): + """Tests for deploy git CLI command.""" + + def test_deploy_git_help(self): + """Test that deploy git --help works.""" + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "git", "--help"]) + assert result.exit_code == 0, result.output + assert "Deploy content to Posit Connect directly from a remote Git repository" in result.output + assert "--repository" in result.output + assert "--branch" in result.output + assert "--subdirectory" in result.output + + def test_deploy_git_requires_repository(self): + """Test that --repository is required.""" + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "git", "-s", "http://example.com", "-k", "key"]) + assert result.exit_code != 0 + assert "Missing option" in result.output or "required" in result.output.lower() + + +class TestGenerateGitTitle: + """Tests for _generate_git_title helper function.""" + + def test_simple_repo_url(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "") + assert result == "myrepo" + + def test_repo_url_with_git_suffix(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo.git", "") + assert result == "myrepo" + + def test_repo_with_subdirectory(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "apps/dashboard") + assert result == "myrepo/apps/dashboard" + + def test_repo_with_subdirectory_leading_slash(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "/apps/dashboard/") + assert result == "myrepo/apps/dashboard" + + def test_repo_with_root_subdirectory(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo", "/") + assert result == "myrepo" + + def test_repo_url_with_trailing_slash(self): + from rsconnect.main import _generate_git_title + + result = _generate_git_title("https://github.com/user/myrepo/", "subdir") + assert result == "myrepo/subdir"