From e101baab64f9452948d47837e35a9ef5f20cde0f Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Wed, 12 Aug 2026 08:46:28 -0400 Subject: [PATCH 1/2] feat: add support for deploying to Posit Connect Cloud Adds Posit Connect Cloud as a deployment target alongside Posit Connect and shinyapps.io, mirroring the R rsconnect package's support: - Select the target with --connect-cloud or -s connect.posit.cloud. - Authenticate with an interactive OAuth device-code login or a service account client ID/secret (client credentials grant), with automatic token refresh and write-back to the credential store. - Register credentials with `rsconnect add`, verifying the account exists and grants the content:create permission before storing. - Deploy through the Connect Cloud revision model: create or update content, upload the bundle to a presigned URL, publish, poll the revision, and print the publish log from the logs service on failure. - Record deployments locally before publishing, since Connect Cloud cannot look content up by name. - Support the production, staging, and development environments via CONNECT_CLOUD_ENVIRONMENT, pinned to the saved server's URL. Fixes #817 --- README.md | 22 +- docs/CHANGELOG.md | 37 + rsconnect/actions.py | 19 +- rsconnect/api.py | 1153 +++++++++++++- rsconnect/certificates.py | 33 +- rsconnect/connect_cloud.py | 232 +++ rsconnect/http_support.py | 129 +- rsconnect/json_web_token.py | 4 +- rsconnect/main.py | 322 +++- rsconnect/metadata.py | 178 ++- rsconnect/models.py | 34 + rsconnect/oauth.py | 112 +- rsconnect/shiny_express.py | 9 + rsconnect/validation.py | 202 ++- tests/test_actions.py | 23 +- tests/test_api.py | 2 + tests/test_certificates.py | 27 +- tests/test_connect_cloud.py | 2817 +++++++++++++++++++++++++++++++++++ tests/test_http_support.py | 184 +++ tests/test_main.py | 6 +- tests/test_metadata.py | 20 + tests/test_oauth.py | 32 + 22 files changed, 5463 insertions(+), 134 deletions(-) create mode 100644 rsconnect/connect_cloud.py create mode 100644 tests/test_connect_cloud.py diff --git a/README.md b/README.md index a6b4ed637..2a4b9828b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [rsconnect-python](https://docs.posit.co/rsconnect-python) -The [Posit Connect](https://docs.posit.co/connect/) command-line interface. +The command-line interface for [Posit Connect](https://docs.posit.co/connect/) and [Posit Connect Cloud](https://connect.posit.cloud). ## Installation @@ -22,7 +22,7 @@ pipx install rsconnect-python python -m pip install rsconnect-python ``` -## Usage +## Usage with Posit Connect [Get an API key from your Posit Connect server](https://docs.posit.co/connect/user/api-keys/) with at least publisher privileges: @@ -40,6 +40,24 @@ rsconnect deploy shiny app.py --title "my shiny app" [Read more about publisher and admin capabilities on the docs site.](https://docs.posit.co/rsconnect-python) +## Usage with Posit Connect Cloud + +Store your credentials, logging in to [Posit Connect Cloud](https://connect.posit.cloud) through your browser: + +```bash +rsconnect add --connect-cloud --account --name cloud +``` + +Deploy your application: + +```bash +rsconnect deploy shiny app.py --name cloud --title "my shiny app" +``` + +For non-interactive use such as CI, pass a service account credential with +`--client-id` and `--client-secret`, or the `CONNECT_CLOUD_CLIENT_ID` and +`CONNECT_CLOUD_CLIENT_SECRET` environment variables. + ## Contributing [Contributing docs](./CONTRIBUTING.md) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 63f3e6b04..352bf10f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Posit Connect Cloud is now a supported deployment target, alongside Posit + Connect and shinyapps.io, mirroring the R rsconnect package's support. + Register an account with `rsconnect add -n --connect-cloud + -A `, which verifies the account exists and that you can publish to + it. Authentication is an interactive browser login by default; for CI and + other non-interactive use, pass a service account credential with + `--client-id`/`--client-secret` (or the `CONNECT_CLOUD_CLIENT_ID` and + `CONNECT_CLOUD_CLIENT_SECRET` environment variables). Tokens are refreshed + automatically. Deploy with any `rsconnect deploy` subcommand by passing + `--connect-cloud` (or `-s connect.posit.cloud`) with `-A ` (or the + `CONNECT_CLOUD_ACCOUNT` environment variable; a `SHINYAPPS_ACCOUNT` variable + exported for shinyapps.io is ignored here), or `-n ` for a saved + account. Supported content types: Shiny (Python + and R), Streamlit, Dash, Bokeh, Jupyter notebooks, Quarto, R Markdown, and + static content. +- `rsconnect add` now reports invalid option combinations and unreadable + certificate files as plain error messages. Previously these surfaced as raw + Python tracebacks. +- Deploying with a nickname (`-n`) no longer fails when `SHINYAPPS_ACCOUNT`, + `SHINYAPPS_TOKEN`, or `SHINYAPPS_SECRET` happen to be set in the environment. + Those values are ignored for nickname deploys — the saved credential is what + the nickname means. Shinyapps options typed on the command line still + conflict with `-n`. +- Credentials are now redacted from verbose (`-v`) log output and from error + messages that quote a request URL. This covers authorization and signing + headers, OAuth tokens and client secrets in request and response bodies, the + query-string credentials of presigned bundle-upload URLs, and the values of + environment variables passed with `-E` (names are still logged). +- `rsconnect deploy manifest` and `rsconnect deploy bundle` no longer overwrite + the title of existing content when `-t/--title` is not given. Previously a + redeploy reset the server-side title to one derived from the manifest or + bundle; now, as with the other deploy commands, a redeploy without `-t` + leaves the existing title unchanged. +- `rsconnect deploy pyproject` no longer uses the server nickname (`-n`) as a + title override. The title now comes from `-t/--title` or the pyproject + metadata, so redeploying with a nickname no longer renames existing content + after the nickname. - `rsconnect deploy` subcommands now accept `--quiet`, which suppresses the step-by-step progress lines and the streamed server build log, printing only the deployed content URL to stdout so it can be captured with diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 46e2d155e..84eeb656f 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -13,7 +13,8 @@ import sys import traceback import typing -from os.path import basename, exists, join, relpath +from os.path import basename, exists, isdir, join, relpath +from pathlib import PurePath from typing import Optional, Sequence, cast from warnings import warn @@ -263,10 +264,15 @@ class QuartoInspectResultConfig(TypedDict): project: QuartoInspectResultConfigProject +class QuartoInspectResultFiles(TypedDict): + input: list[str] + + class QuartoInspectResult(TypedDict): quarto: QuartoInspectResultQuarto engines: list[str] config: NotRequired[QuartoInspectResultConfig] + files: NotRequired[QuartoInspectResultFiles] def quarto_inspect( @@ -302,6 +308,17 @@ def validate_quarto_engines(inspect: QuartoInspectResult): return engines +def quarto_inputs_from_inspect(file_or_directory: str, inspect: QuartoInspectResult) -> list[str]: + """The project's render inputs relative to its root, in Quarto's render order. + + Empty for a standalone document; its inspect output has no files section. + """ + if not isdir(file_or_directory): + return [] + inputs = inspect.get("files", {}).get("input", []) + return [PurePath(relpath(each, file_or_directory)).as_posix() for each in inputs] + + # =============================================================================== # START: Compatibility entry point used by the vetiver-python package. # vetiver's `deploy_connect` calls `deploy_python_fastapi` (below), which routes diff --git a/rsconnect/api.py b/rsconnect/api.py index 29b0f2381..99e7f0c2a 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -13,6 +13,7 @@ import os import re import sys +import tarfile import time import typing import webbrowser @@ -47,12 +48,13 @@ # they should both come from the same typing module. # https://peps.python.org/pep-0655/#usage-in-python-3-11 if sys.version_info >= (3, 11): - from typing import TypedDict + from typing import NotRequired, TypedDict else: - from typing_extensions import TypedDict + from typing_extensions import NotRequired, TypedDict -from . import validation -from .bundle import _default_title +from . import connect_cloud, validation +from .bundle import _default_title, _find_manifest_member +from .shiny_express import unescape_from_var_name from .certificates import read_certificate_file from .environment import fake_module_file_from_directory from .exception import DeploymentFailedException, RSConnectException @@ -61,11 +63,12 @@ HTTPResponse, HTTPServer, JsonData, + _redacted_uri_for_log, append_to_path, create_multipart_form_data, ) from .log import cls_logged, connect_logger, console_logger, logger -from .metadata import AppStore, ServerData, ServerStore +from .metadata import SHINYAPPS_API_URL, SHINYAPPS_SERVER_NAME, AppStore, ServerData, ServerStore from .models import ( AppMode, AppModes, @@ -148,9 +151,13 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool """ if isinstance(response, HTTPResponse): + # Presigned upload URLs carry their credentials in the query string, + # so any URI quoted in an error must be redacted like the debug log. + safe_uri = _redacted_uri_for_log(response.full_uri) if response.exception: raise RSConnectException( - "Could not connect to %s - %s" % (self.url, response.exception), cause=response.exception + "Could not connect to %s - %s" % (_redacted_uri_for_log(self.url), response.exception), + cause=response.exception, ) # Sometimes an ISP will respond to an unknown server name by returning a friendly # search page so trap that since we know we're expecting JSON from Connect. This @@ -164,7 +171,7 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool ): error = "%s reported an error (calling %s): %s" % ( self.remote_name, - response.full_uri, + safe_uri, response.json_data["error"], ) raise RSConnectException(error, status=response.status) @@ -173,7 +180,7 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool "Received an unexpected response from %s (calling %s): %s %s" % ( self.remote_name, - response.full_uri, + safe_uri, response.status, response.reason, ), @@ -188,7 +195,7 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool "Received an unexpected response from %s (calling %s): %s %s" % ( self.remote_name, - response.full_uri, + safe_uri, response.status, response.reason, ) @@ -216,11 +223,58 @@ class ShinyappsServer(PositServer): def __init__(self, url: str, account_name: str, token: str, secret: str): remote_name = "shinyapps.io" - if url == "shinyapps.io" or url is None: - url = "https://api.shinyapps.io" + if url == SHINYAPPS_SERVER_NAME or url is None: + url = SHINYAPPS_API_URL super().__init__(remote_name=remote_name, url=url, account_name=account_name, token=token, secret=secret) +class ConnectCloudServer(AbstractRemoteServer): + """ + A class to encapsulate the information needed to interact with Posit + Connect Cloud. + + Deliberately not a PositServer: that base class carries the HMAC-signed + token and secret used by shinyapps.io, whereas Connect Cloud authenticates + with an OAuth bearer token. + """ + + def __init__( + self, + account_name: str, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + url: Optional[str] = None, + server_name: Optional[str] = None, + account_id: Optional[str] = None, + ): + # Accepts the bare "connect.posit.cloud" a user types for --server, the + # same way ShinyappsServer accepts "shinyapps.io". + super().__init__(connect_cloud.resolve_url(url), "Posit Connect Cloud") + self.account_name = account_name + # The account's id, when it is known without asking the server. Only valid + # for account_name: the two must be set together. + self.account_id = account_id + self.access_token = access_token + self.refresh_token = refresh_token + # Retained so a new access token can be minted non-interactively when + # the current one expires. Only set for client credentials logins. + self.client_id = client_id + self.client_secret = client_secret + # The nickname this server is saved under, so refreshed tokens can be + # written back to the store. + self.server_name = server_name + # Derived from the URL rather than read from the environment on each use, + # so a server saved against staging keeps using staging's auth, UI, and + # logs hosts no matter what CONNECT_CLOUD_ENVIRONMENT says later. + self.environment = connect_cloud.environment_for_url(self.url) + + def urls(self) -> connect_cloud.ConnectCloudUrls: + """The hosts making up this server's environment.""" + return connect_cloud.urls(self.environment) + + class RSConnectServer(AbstractRemoteServer): """ A simple class to encapsulate the information needed to interact with an @@ -341,7 +395,12 @@ def exchange_token(self) -> str: # borrowed from AbstractRemoteServer.handle_bad_response # since we don't want to pick up its json decoding assumptions - if response.status < 200 or response.status > 299: + if response.exception is not None: + raise RSConnectException( + "Could not connect to %s - %s" % (self.token_endpoint(), response.exception), + cause=response.exception, + ) + if response.status is None or response.status < 200 or response.status > 299: raise RSConnectException( "Received an unexpected response from %s (calling %s): %s %s" % ( @@ -379,7 +438,7 @@ def exchange_token(self) -> str: raise RSConnectException(f"Failed to exchange Snowflake token: {str(e)}") from e -TargetableServer = typing.Union[ShinyappsServer, RSConnectServer, SPCSConnectServer] +TargetableServer = typing.Union[ShinyappsServer, RSConnectServer, SPCSConnectServer, ConnectCloudServer] class S3Server(AbstractRemoteServer): @@ -639,7 +698,7 @@ def add_environment_vars(self, content_guid: str, env_vars: list[tuple[str, str] return self.patch(f"v1/content/{content_guid}/environment", body=env_body) def is_failed_response(self, response: HTTPResponse | JsonData) -> bool: - return isinstance(response, HTTPResponse) and response.status >= 500 + return isinstance(response, HTTPResponse) and response.status is not None and response.status >= 500 def access_content(self, content_guid: str, bundle_id: Optional[str] = None) -> None: method = "GET" @@ -1261,6 +1320,9 @@ def __init__( timeout: int = 30, logger: Optional[logging.Logger] = console_logger, *, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + use_connect_cloud: bool = False, path: Optional[str] = None, server: Optional[str] = None, exclude: Optional[tuple[str, ...]] = None, @@ -1275,20 +1337,29 @@ def __init__( branch: Optional[str] = None, subdirectory: Optional[str] = None, polling: bool = True, + quarto_inputs: Optional[List[str]] = None, ) -> None: self.remote_server: TargetableServer - self.client: RSConnectClient | PositClient + self.client: RSConnectClient | PositClient | ConnectCloudClient self.path = path or os.getcwd() self.server = server self.exclude = exclude self.new = new self.app_id = app_id + # Whether the id was supplied by the caller (--app-id) rather than + # backfilled from the local deployment record in validate_app_mode. An + # explicit id that cannot be honored is an error; only a stale record + # falls back to creating new content. + self.app_id_is_explicit: bool = app_id is not None self.title = title or _default_title(self.path) self.visibility = visibility self.disable_env_management = disable_env_management self.env_vars = env_vars self.metadata = metadata + # Render inputs from `quarto inspect`, relative paths in render order; + # None outside the Quarto deploy commands. + self.quarto_inputs = quarto_inputs self.app_mode: AppMode | None = None self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path)) self.app_store_version: int | None = None @@ -1320,6 +1391,9 @@ def __init__( account_name=account, token=token, secret=secret, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=use_connect_cloud, ) self.setup_client(cookies) @@ -1416,8 +1490,40 @@ def setup_remote_server( account_name: Optional[str] = None, token: Optional[str] = None, secret: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + use_connect_cloud: bool = False, ): store = ServerStore() + # --connect-cloud takes precedence over CONNECT_SERVER whatever that + # variable holds, including a Connect Cloud URL for another environment. + # Only an explicitly *typed* Connect Cloud API URL survives the flag, + # because it pins the environment (staging vs production). The saved + # credential check has to see the same target validation is about to judge. + explicit_cloud_url = connect_cloud.is_connect_cloud_url(url) and ( + validation.get_parameter_source_name_from_ctx("server", ctx) != "ENVIRONMENT" + ) + target_url = url + if use_connect_cloud and not explicit_cloud_url: + target_url = connect_cloud.SERVER_NAME + # A nickname names a saved credential, so environment-sourced shinyapps + # values (SHINYAPPS_ACCOUNT/TOKEN/SECRET, exported for CI elsewhere) + # neither conflict with it (validation ignores them) nor merge into the + # resolved entry -- dropped here, before validation and resolution, so + # an exported account cannot retarget a Connect Cloud nickname either. + if name: + if validation.get_parameter_source_name_from_ctx("account", ctx) == "ENVIRONMENT": + account_name = None + if validation.get_parameter_source_name_from_ctx("token", ctx) == "ENVIRONMENT": + token = None + if validation.get_parameter_source_name_from_ctx("secret", ctx) == "ENVIRONMENT": + secret = None + # Normalized before validation so the required-account check and the + # store lookup below both judge the corrected value. Nickname and + # default-server deploys cannot reach here with an environment-sourced + # -A: it is dropped above (nickname) or rejected by validation (default). + if use_connect_cloud or connect_cloud.is_connect_cloud_url(target_url): + account_name = validation.effective_connect_cloud_account(ctx, account_name) validation.validate_connection_options( ctx=ctx, url=url, @@ -1430,21 +1536,57 @@ def setup_remote_server( secret=secret, name=name, has_default_server=store.get_default() is not None, + client_id=client_id, + client_secret=client_secret, + connect_cloud=use_connect_cloud, + has_saved_connect_cloud_account=store.has_connect_cloud_account(target_url), ) + # Resolve --connect-cloud into the server it stands for, before anything + # below discriminates on the URL. An explicitly typed Connect Cloud API + # URL is kept, the same as in `rsconnect add`; anything else came from + # CONNECT_SERVER (validation already rejected a conflicting explicit + # --server) and the flag takes precedence over it. + if use_connect_cloud and not explicit_cloud_url: + url = connect_cloud.SERVER_NAME # The validation.validate_connection_options() function ensures that certain # combinations of arguments are present; the cast() calls inside of the # if-statements below merely reflect these validations. header_output = False - if cacert and not ca_data: - ca_data = read_certificate_file(cacert) + # The Connect Cloud credentials as supplied on this run (CLI or environment), + # before the saved entry backfills them; needed to tell a credential the + # user brought along from one the entry already owns. + supplied_client_id = client_id + supplied_client_secret = client_secret + # Captured before the store merge so the deferred certificate read below + # keeps the original precedence: supplied ca_data, then the --cacert + # file, then the saved entry's certificate. + supplied_ca_data = ca_data + # Also captured pre-merge: the deferred shinyapps all-or-nothing check + # must judge what the user supplied, not what a default entry backfills. + # Otherwise a lone -A would borrow the default entry's token and secret + # and deploy to a different account with them. + supplied_account_name = account_name + supplied_token = token + supplied_secret = secret # Skip default-server resolution when shinyapps credentials are explicitly # provided — the user is targeting shinyapps.io, not a stored Connect server. if token and secret and account_name and not name and not url: server_data = ServerData(None, None, False) else: - server_data = store.resolve(name, url) + try: + server_data = store.resolve(name, url, account_name) + except RSConnectException: + # Several saved Connect Cloud entries can make the lookup ambiguous, + # but a complete supplied credential pair with an account is its own + # identity (the one-shot/CI shape) and needs nothing from the store. + # A matching single entry still resolves above and keeps its + # token-reuse and write-back behavior. + if supplied_client_id and supplied_client_secret and account_name and not name: + server_data = ServerData(None, None, False) + else: + raise if server_data.from_store: url = server_data.url if self.logger: @@ -1472,10 +1614,105 @@ def setup_remote_server( account_name = account_name or server_data.account_name token = token or server_data.token secret = secret or server_data.secret + # Unlike the fields above, client_id and client_secret are not merged + # individually: combining a supplied client_id with a stored + # client_secret (or vice versa) produces a credential that was never + # issued, and the mistake only shows up later as an unexplained 401 + # on token refresh. So the supplied values are used only when both + # are given; otherwise the stored pair is used and a lone supplied + # value is ignored with a warning. + if not (supplied_client_id and supplied_client_secret): + if (supplied_client_id or supplied_client_secret) and server_data.connect_cloud_account_name: + logger.warning( + "Ignoring %s: --client-id and --client-secret must be provided together. " + "Using the saved credential instead." + % ("--client-id/CONNECT_CLOUD_CLIENT_ID" if supplied_client_id else "--client-secret") + ) + client_id = server_data.connect_cloud_client_id + client_secret = server_data.connect_cloud_client_secret self.is_server_from_store = server_data.from_store - if snowflake_connection_name: + # Connect Cloud is recognized either by a stored account name or by the + # user naming it on the command line. It is tested first because its + # credentials are distinct from every other target's, so a match here is + # unambiguous. The account is normalized again because a default server + # resolves to a Connect Cloud URL only here: -A applies as typed, but an + # environment-sourced SHINYAPPS_ACCOUNT must not (CONNECT_CLOUD_ACCOUNT + # does). For targets named upfront this is a no-op repeat of the + # normalization before validation. + connect_cloud_account = ( + validation.effective_connect_cloud_account(ctx, account_name) + if connect_cloud.is_connect_cloud_url(url) + else None + ) + connect_cloud_account = connect_cloud_account or server_data.connect_cloud_account_name + + if not connect_cloud_account: + # Deferred from validate_connection_options: a nickname or default + # server is only known not to be Connect Cloud here, after resolution. + validation.validate_connect_cloud_credential_options(ctx, supplied_client_id, supplied_client_secret) + # Also deferred: a lone -A was allowed through when a default server + # might have resolved to Connect Cloud. It did not, so the shinyapps + # all-or-nothing rule applies after all -- judged on the pre-merge + # values, or a default shinyapps entry's token and secret would + # satisfy it and deploy the typed account with borrowed credentials. + if supplied_account_name and not (supplied_token and supplied_secret): + raise RSConnectException( + "-A/--account, -T/--token, and -S/--secret must all be provided \ +for shinyapps.io. See command help for further details." + ) + # Certificate data is only meaningful for the non-Cloud targets + # below; read it here so a CONNECT_CA_CERTIFICATE exported for a + # Connect server cannot fail a Connect Cloud deploy. Keyed off the + # pre-merge value so a --cacert file still overrides a saved + # entry's certificate, as it did when the read preceded the merge. + if cacert and not supplied_ca_data: + ca_data = read_certificate_file(cacert) + + if connect_cloud_account: + # A saved nickname or default server is only known to be Connect Cloud + # here, after resolution, so options that validation could not judge + # earlier are re-checked now rather than silently ignored. + validation.validate_connect_cloud_incompatible_options( + ctx, + api_key=api_key, + insecure=insecure, + cacert=cacert, + snowflake_connection_name=snowflake_connection_name, + ) + # Credentials supplied on this run that differ from the saved entry's + # are a different identity: the entry's tokens belong to whoever + # created it, so they are not attached (a fresh token is minted from + # the supplied credentials on first use), and nothing is written back + # to the entry afterwards. + credential_override = bool( + supplied_client_id + and supplied_client_secret + and ( + supplied_client_id != server_data.connect_cloud_client_id + or supplied_client_secret != server_data.connect_cloud_client_secret + ) + ) + # The saved id belongs to the saved account, so it only applies when this + # deploy is targeting that same account. An explicit --account naming a + # different one has to be resolved against the server. + connect_cloud_account_id = ( + server_data.connect_cloud_account_id + if connect_cloud_account == server_data.connect_cloud_account_name + else None + ) + self.remote_server = ConnectCloudServer( + account_name=connect_cloud_account, + access_token=None if credential_override else server_data.connect_cloud_access_token, + refresh_token=None if credential_override else server_data.connect_cloud_refresh_token, + client_id=client_id, + client_secret=client_secret, + url=url, + server_name=None if credential_override else (name or server_data.name), + account_id=connect_cloud_account_id, + ) + elif snowflake_connection_name: url = cast(str, url) self.remote_server = SPCSConnectServer(url, api_key, snowflake_connection_name, insecure, ca_data) elif api_key: @@ -1508,6 +1745,8 @@ def setup_client(self, cookies: Optional[CookieJar] = None): self.client = RSConnectClient(self.remote_server, cookies) elif isinstance(self.remote_server, SPCSConnectServer): self.client = RSConnectClient(self.remote_server) + elif isinstance(self.remote_server, ConnectCloudServer): + self.client = ConnectCloudClient(self.remote_server) elif isinstance(self.remote_server, PositServer): self.client = PositClient(self.remote_server) else: @@ -1525,7 +1764,8 @@ def validate_server(self): self.validate_spcs_server() elif isinstance(self.remote_server, RSConnectServer): self.validate_connect_server() - + elif isinstance(self.remote_server, ConnectCloudServer): + self.validate_connect_cloud_server() elif isinstance(self.remote_server, PositServer): self.validate_posit_server() else: @@ -1533,6 +1773,35 @@ def validate_server(self): return self + def validate_connect_cloud_server(self): + if not isinstance(self.remote_server, ConnectCloudServer): + raise RSConnectException("remote_server must be a Posit Connect Cloud server.") + if not isinstance(self.client, ConnectCloudClient): + raise RSConnectException("client must be a ConnectCloudClient.") + if self.visibility is not None: + # Connect Cloud has no equivalent setting. + raise RSConnectException( + "-V/--visibility is not supported by Posit Connect Cloud. " + "Manage content access from the Connect Cloud interface." + ) + if not self.remote_server.access_token and not ( + self.remote_server.client_id and self.remote_server.client_secret + ): + raise RSConnectException( + "No Posit Connect Cloud credentials found. Run `rsconnect add -n " + "-s connect.posit.cloud -A ` to log in." + ) + with self.client: + self.client.get_current_user() + if not self.remote_server.account_id and self.remote_server.account_name: + # Deployment records are keyed by the account id (record_server_key), + # which a server saved before ids were recorded, or a divergent -A, + # does not have yet. Resolve it before validate_app_mode reads the + # records, so existing content is still found after an account rename. + account = self.client.get_account_by_name(self.remote_server.account_name) + self.remote_server.account_id = account["id"] + return self + def validate_connect_server(self): if not isinstance(self.remote_server, RSConnectServer): raise RSConnectException("remote_server must be a Connect server.") @@ -1642,6 +1911,105 @@ def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle ) upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True) + def primary_file_for_connect_cloud(self) -> str: + """The entrypoint to report to Connect Cloud, read from the built bundle. + + Connect Cloud needs a `primary_file` and uses it to decide, for example, + whether Shiny content is R or Python. Every deploy path writes the + entrypoint into the bundle's manifest, so reading it back from there + avoids having to thread it separately through each command. + """ + if self.bundle is None: + raise RSConnectException("A bundle must be created before determining the primary file.") + + position = self.bundle.tell() + try: + self.bundle.seek(0) + with tarfile.open(mode="r:gz", fileobj=self.bundle) as tar: + # Downloaded bundles may store manifest.json under a single + # nested directory; use the same locator as read_bundle_manifest. + member = _find_manifest_member(tar) + extracted = tar.extractfile(member) if member is not None else None + if extracted is None: + raise RSConnectException("The bundle does not contain a manifest.json.") + manifest = json.loads(extracted.read().decode("utf-8")) + except (tarfile.TarError, KeyError, ValueError) as exc: + raise RSConnectException("Could not read the bundle manifest: %s" % exc) from exc + finally: + self.bundle.seek(position) + + metadata = manifest.get("metadata") or {} + primary_file = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html") + files = manifest.get("files") or {} + + if not primary_file: + # R Shiny and Quarto manifests record no entrypoint; Connect infers + # conventional file names, so do the same here. + appmode = str(metadata.get("appmode") or "") + by_lower = {name.lower(): name for name in files} + if appmode.startswith("quarto"): + # Quarto renders more than .qmd; .md is left out of the + # single-input scan because bundles routinely carry a README.md. + quarto_inputs = [name for name in files if name.lower().endswith((".qmd", ".ipynb", ".rmd"))] + # A standalone document deploy knows its own file: self.path is + # that document. Directory projects fall through to convention. + deployed_file = os.path.basename(self.path or "").lower() + for candidate in (deployed_file, "index.qmd", "index.ipynb", "index.rmd", "index.md"): + if candidate and candidate in by_lower: + primary_file = by_lower[candidate] + break + else: + if len(quarto_inputs) == 1: + primary_file = quarto_inputs[0] + elif not quarto_inputs: + # Quarto renders plain .md too. A README is assumed to be + # documentation about the project, not the document itself. + markdowns = [ + name + for name in files + if name.lower().endswith(".md") and not os.path.basename(name).lower().startswith("readme") + ] + if len(markdowns) == 1: + primary_file = markdowns[0] + if not primary_file: + # Several inputs and no conventional index: fall back to + # `quarto inspect`'s render order, kept from deploy time. + for name in self.quarto_inputs or []: + if name in files: + primary_file = name + break + else: + for candidate in ("app.r", "server.r"): + if candidate in by_lower: + primary_file = by_lower[candidate] + break + if not primary_file: + raise RSConnectException( + "Could not determine the primary file for this content, which Posit Connect Cloud requires." + ) + primary_file = cast(str, primary_file) + if primary_file in files: + return primary_file + + # Shiny Express manifests wrap the source file in a synthetic entrypoint, + # "shiny.express.app:"; decode it back + # to the bundled file (see shiny_express.escape_to_var_name). + express_prefix = "shiny.express.app:" + if primary_file.startswith(express_prefix): + decoded = unescape_from_var_name(primary_file[len(express_prefix) :]) + if decoded in files: + return decoded + + # Python app manifests record the entrypoint as "module" or "module:object" + # (see bundle.validate_entry_point), but Connect Cloud wants the file itself + # and fails the publish with "primary file not found" for a module name. + module = primary_file.split(":")[0] + for candidate in (module, module + ".py", module.replace(".", "/") + ".py"): + if candidate in files: + return candidate + + return primary_file + @cls_logged("Deploying bundle ...") def deploy_bundle(self, activate: bool = True): if self.deployment_name is None: @@ -1663,6 +2031,52 @@ def deploy_bundle(self, activate: bool = True): metadata=self.metadata, ) self.deployed_info = result + return self + elif isinstance(self.remote_server, ConnectCloudServer): + if not isinstance(self.client, ConnectCloudClient): + raise RSConnectException("client must be a ConnectCloudClient.") + if self.app_mode is None: + raise RSConnectException("An app mode must be determined before deploying a bundle.") + + contents = self.bundle.read() + service = ConnectCloudService(self.client, self.remote_server) + + with self.client: + prepared = service.prepare_deploy( + app_id=self.app_id, + app_name=self.deployment_name, + title=self.title, + app_mode=self.app_mode, + primary_file=self.primary_file_for_connect_cloud(), + env_vars=self.env_vars, + update_title=not self.title_is_default, + app_id_is_explicit=self.app_id_is_explicit, + ) + self.deployed_info = RSConnectClientDeployResult( + app_url=prepared.app_url, + app_id=prepared.content_id, + app_guid=None, + task_id=None, + draft_url=None, + bundle_id=None, + title=prepared.title, + ) + # Save the content id before uploading and publishing. Connect Cloud + # cannot look content up by name, so if publishing fails with no local + # record the next attempt would create a second content item and orphan + # this one. + self.write_deployed_info() + + service.upload_bundle(prepared, contents) + service.do_deploy(prepared, timeout=get_task_timeout()) + + if not logger.quiet: + if prepared.app_url: + print(f"Content successfully deployed to {prepared.app_url}") + webbrowser.open_new(prepared.app_url) + else: + print("Content successfully deployed (the content URL could not be determined).") + return self else: contents = self.bundle.read() @@ -1708,7 +2122,7 @@ def deploy_git(self, activate: bool = True): """ if not isinstance(self.client, RSConnectClient): raise RSConnectException( - "Git deployment is only supported for Posit Connect servers, not shinyapps.io or Posit Cloud." + "Git deployment is only supported for Posit Connect servers, not Posit Connect Cloud or shinyapps.io." ) if not self.repository: @@ -1795,13 +2209,46 @@ def emit_task_log( @cls_logged("Saving deployed information...") def save_deployed_info(self): - app_store = self.app_store - path = self.path + self.write_deployed_info() + return self + + def record_server_key(self) -> str: + """The key deployment records are stored under for this server. + + Every Posit Connect Cloud server shares one API URL, so the account is + folded in: without it, deploying the same directory to a second account + would find the first account's record and silently update that account's + content instead. The account id is preferred over the name because + names can be changed in Connect Cloud; the name is used only when no id + is known (a divergent -A, or a server saved before ids were recorded + see record_server_key_fallback for reading those). + """ + if isinstance(self.remote_server, ConnectCloudServer): + account = self.remote_server.account_id or self.remote_server.account_name + return "%s#%s" % (self.remote_server.url, account) + return self.remote_server.url + + def record_server_key_fallback(self) -> Optional[str]: + """The name-keyed record location, for records written before an account + id was known. Consulted on reads when the id-keyed lookup finds nothing; + writes always use record_server_key, which migrates the record.""" + if isinstance(self.remote_server, ConnectCloudServer) and self.remote_server.account_id: + return "%s#%s" % (self.remote_server.url, self.remote_server.account_name) + return None + + def write_deployed_info(self): + """Write the local deployment record without the progress message. + + Deploys that record the content id before the deployment completes call this + directly so the step is not announced twice. + """ deployed_info = self.deployed_info + if deployed_info is None: + raise RSConnectException("There is no deployment information to save.") - app_store.set( - self.remote_server.url, - abspath(path), + self.app_store.set( + self.record_server_key(), + abspath(self.path), deployed_info["app_url"], deployed_info["app_id"], deployed_info["app_guid"], @@ -1809,8 +2256,6 @@ def save_deployed_info(self): self.app_mode, ) - return self - @property def supports_verify_before_activate(self) -> bool: """Whether the target server supports deploying a bundle as a draft and @@ -1904,6 +2349,14 @@ def validate_app_mode(self, app_mode: AppMode): if new and app_id: raise RSConnectException("Specify either a new deploy or an app ID but not both.") + if isinstance(self.remote_server, ConnectCloudServer) and not AppModes.supported_by_connect_cloud(app_mode): + # Fail here rather than at deploy time: Connect Cloud rejects an + # unknown content type with a 422. + raise RSConnectException( + "Posit Connect Cloud does not support %s content. Supported types are: %s." + % (app_mode.desc(), ", ".join(sorted(set(AppModes._connect_cloud_content_types.values())))) + ) + existing_app_mode = None app_store_version = 0 if not new: @@ -1911,8 +2364,12 @@ def validate_app_mode(self, app_mode: AppMode): # Possible redeployment - check for saved metadata. # Use the saved app information unless overridden by the user. app_id, existing_app_mode, app_store_version = app_store.resolve( - self.remote_server.url, app_id, app_mode + self.record_server_key(), app_id, app_mode ) + if app_id is None: + fallback_key = self.record_server_key_fallback() + if fallback_key: + app_id, existing_app_mode, app_store_version = app_store.resolve(fallback_key, None, app_mode) self.app_store_version = app_store_version logger.debug("Using app mode from app %s: %s" % (app_id, app_mode)) @@ -1928,6 +2385,11 @@ def validate_app_mode(self, app_mode: AppMode): raise RSConnectException( f"{e} Try setting the --new flag to overwrite the previous deployment." ) from e + elif isinstance(self.remote_server, ConnectCloudServer): + # Connect Cloud derives the app mode from the content type and + # primary file rather than storing rsconnect's, so there is + # nothing to compare against; the check below is skipped. + existing_app_mode = None elif isinstance(self.remote_server, PositServer): try: app = get_posit_app_info(self.remote_server, app_id) @@ -2382,6 +2844,388 @@ def get_applications_like_name(self, name: str) -> list[str]: return [app["name"] for app in applications] +class ConnectCloudAccount(TypedDict): + id: str + name: str + permissions: NotRequired[list[str]] + + +class ConnectCloudAccountSearchResults(TypedDict): + data: list[ConnectCloudAccount] + total: NotRequired[int] + + +class ConnectCloudRevision(TypedDict): + id: str + content_id: NotRequired[str] + status: NotRequired[str] + source_bundle_upload_url: NotRequired[str] + publish_result: NotRequired[Optional[str]] + publish_error_details: NotRequired[Optional[str]] + publish_log_channel: NotRequired[Optional[str]] + + +class ConnectCloudContent(TypedDict): + id: str + account_id: NotRequired[str] + title: NotRequired[str] + state: NotRequired[str] + next_revision: NotRequired[Optional[ConnectCloudRevision]] + current_revision: NotRequired[Optional[ConnectCloudRevision]] + + +class ConnectCloudLogEntry(TypedDict): + timestamp: NotRequired[int] + level: NotRequired[str] + message: NotRequired[str] + + +class ConnectCloudLogs(TypedDict): + data: list[ConnectCloudLogEntry] + + +# How many accounts to request per page from GET /accounts. +_CONNECT_CLOUD_ACCOUNT_PAGE_SIZE = 100 + +# Guards against a server that keeps reporting a total it never reaches. +_CONNECT_CLOUD_MAX_ACCOUNT_PAGES = 100 + +# The permission GET /accounts reports for an account the caller may publish to. +# Matches filterPublishableAccounts() in the R client's accounts.R. +_CONNECT_CLOUD_PUBLISH_PERMISSION = "content:create" + + +class ConnectCloudClient(HTTPServer): + """ + An HTTP client to call the Posit Connect Cloud API. + + Every request carries an OAuth bearer token. When one expires, the client + mints a new one and retries the request a single time. + """ + + def __init__(self, server: ConnectCloudServer): + self._server = server + super().__init__(server.url) + self._apply_authorization() + + def _apply_authorization(self) -> None: + if self._server.access_token: + self.authorization(f"Bearer {self._server.access_token}") + + def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse: + return ( + response.json_data + if ( + response.status and response.status >= 200 and response.status <= 299 and response.json_data is not None + ) + else response + ) + + def request( + self, + method: str, + path: str, + query_params: Optional[Mapping[str, JsonData]] = None, + body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, + maximum_redirects: int = 5, + decode_response: bool = True, + headers: Optional[Mapping[str, str]] = None, + ) -> JsonData | HTTPResponse: + response = super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType] + if isinstance(response, HTTPResponse) and response.status == 401: + if self._attempt_token_refresh(): + return super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType] + return response + + def _attempt_token_refresh(self) -> bool: + """Mint a new access token and apply it to this client. + + Uses the client credentials grant when a service account credential is + stored, and the refresh token otherwise. Returns whether a new token was + obtained. + """ + from .metadata import ServerStore + + server = self._server + try: + if server.client_id and server.client_secret: + tokens = connect_cloud.login_client_credentials( + server.client_id, server.client_secret, server.environment + ) + elif server.refresh_token: + tokens = connect_cloud.refresh(server.refresh_token, server.environment) + else: + return False + except RSConnectException as exc: + logger.debug("Posit Connect Cloud token refresh failed: %s" % exc) + return False + + access_token = tokens.get("access_token") + if not access_token: + return False + + server.access_token = access_token + # The client credentials grant issues no refresh token (RFC 6749 4.4.3), + # so keep the existing one rather than clearing it. + server.refresh_token = tokens.get("refresh_token") or server.refresh_token + self._apply_authorization() + + if server.server_name: + store = ServerStore() + entry = store.get_by_name(server.server_name) + if entry: + # A refresh persists the new tokens and nothing else. Every other field + # is taken from the saved entry alone — never from this run, which may + # be publishing to a different account on the same login, or carrying + # service-account credentials from the environment that must not be + # grafted onto an interactively created entry. `rsconnect add` is what + # changes those. This is how the R client's withTokenRefreshRetry() + # writes back too. ServerStore.set writes the file itself. + store.set( + server.server_name, + server.url, + connect_cloud_account_name=entry.get("connect_cloud_account_name") or server.account_name, + connect_cloud_account_id=entry.get("connect_cloud_account_id"), + connect_cloud_client_id=entry.get("connect_cloud_client_id"), + connect_cloud_client_secret=entry.get("connect_cloud_client_secret"), + connect_cloud_access_token=server.access_token, + connect_cloud_refresh_token=server.refresh_token, + ) + + return True + + def get_current_user(self) -> JsonData: + response = self.get("/users/me") + return self._server.handle_bad_response(response) + + def get_accounts(self) -> list[ConnectCloudAccount]: + """Every account the caller has a role on, following pagination. + + The walk ends on an empty page. `total` is optional in the response, so it can + only end the walk early and never be the thing that ends it: treating a missing + total as "no more pages" returned just the first page, which made callers report + an account that exists as missing. + """ + accounts: list[ConnectCloudAccount] = [] + offset = 0 + for _ in range(_CONNECT_CLOUD_MAX_ACCOUNT_PAGES): + response = cast( + Union[ConnectCloudAccountSearchResults, HTTPResponse], + self.get( + "/accounts", + query_params={ + "has_user_role": "true", + "include_total": "true", + "limit": _CONNECT_CLOUD_ACCOUNT_PAGE_SIZE, + "offset": offset, + }, + ), + ) + page = self._server.handle_bad_response(response) + data = page.get("data") or [] + accounts.extend(data) + if not data: + break + total = page.get("total") + if total is not None and len(accounts) >= total: + break + offset += len(data) + else: + # Hitting the cap means the list is incomplete, which reads downstream as + # an account not existing. Do not let that pass silently. + logger.warning( + "Stopped after %d pages of Posit Connect Cloud accounts; the list may be incomplete." + % _CONNECT_CLOUD_MAX_ACCOUNT_PAGES + ) + return accounts + + def get_account_by_name(self, account_name: str) -> ConnectCloudAccount: + """Look up an account to publish to, by name. + + An account the caller can see but not publish to is reported separately from + one that does not exist, so the two have different remedies: ask for the + publisher role, versus fix the name. + """ + accounts = self.get_accounts() + + for account in accounts: + if account.get("name") == account_name: + if account.get("permissions") is None: + # Says so rather than staying quiet, since this is the case where + # the check below cannot do anything. + logger.debug( + "Posit Connect Cloud reported no permissions for account %s; " + "skipping the publish permission check." % account_name + ) + if not self._can_publish(account): + raise RSConnectException( + 'You have access to the Posit Connect Cloud account "%s" but do not have ' + "permission to publish to it. Ask an account administrator for the publisher role." + % account_name + ) + return account + + names = sorted(a["name"] for a in accounts if a.get("name") and self._can_publish(a)) + if names: + available = "You can publish to: %s." % ", ".join(names) + else: + available = "You do not have publish access to any Posit Connect Cloud accounts." + raise RSConnectException('No Posit Connect Cloud account named "%s". %s' % (account_name, available)) + + @staticmethod + def _can_publish(account: ConnectCloudAccount) -> bool: + """Whether an account grants publish permission. + + Treats a response without a permissions field as publishable: the server + enforces the permission on POST /contents either way, so refusing here on + missing data would block publishes the server would accept. + """ + permissions = account.get("permissions") + if permissions is None: + return True + return _CONNECT_CLOUD_PUBLISH_PERMISSION in permissions + + def get_content(self, content_id: str) -> ConnectCloudContent: + response = cast(Union[ConnectCloudContent, HTTPResponse], self.get(f"/contents/{content_id}")) + content = self._server.handle_bad_response(response) + if content.get("state") == "deleted": + # The API reports deleted content as a normal 200. Surface it the way + # a missing item would be, so callers take the same recreate path. + raise RSConnectException( + "Posit Connect Cloud content %s has been deleted." % content_id, + status=404, + ) + return content + + def create_content( + self, + account_id: str, + title: str, + content_type: str, + app_mode: str, + primary_file: str, + secrets: Optional[list[dict[str, str]]] = None, + ) -> ConnectCloudContent: + body: dict[str, Any] = { + "account_id": account_id, + "title": title, + "next_revision": { + "source_type": "bundle", + "content_type": content_type, + "app_mode": app_mode, + "primary_file": primary_file, + }, + "secrets": secrets or [], + } + response = cast(Union[ConnectCloudContent, HTTPResponse], self.post("/contents", body=body)) + return self._server.handle_bad_response(response) + + def update_content( + self, + content_id: str, + primary_file: str, + app_mode: str, + content_type: str, + secrets: Optional[list[dict[str, str]]] = None, + new_bundle: bool = True, + title: Optional[str] = None, + ) -> ConnectCloudContent: + """Update content, optionally minting a fresh revision to upload into. + + `content_type` and `primary_file` are always sent alongside `app_mode`: + the API only recomputes `app_mode` when one of them is present in the + override set, and the stored content type would otherwise survive a + redeploy that changes what kind of content this is (--app-id pointing at + content of another type). + + `secrets` replaces the content's whole set, and `title` overwrites the + stored one, so both are omitted from the request entirely when None: a + deploy without -E/-t must leave the existing values alone. + """ + body: dict[str, Any] = { + "revision_overrides": { + "primary_file": primary_file, + "app_mode": app_mode, + "content_type": content_type, + }, + } + if secrets is not None: + body["secrets"] = secrets + if title is not None: + body["title"] = title + query_params: dict[str, JsonData] = {"new_bundle": "true"} if new_bundle else {} + response = cast( + Union[ConnectCloudContent, HTTPResponse], + self.patch(f"/contents/{content_id}", query_params=query_params, body=body), + ) + return self._server.handle_bad_response(response) + + def publish(self, content_id: str) -> HTTPResponse: + response = cast(HTTPResponse, self.post(f"/contents/{content_id}/publish", body={})) + return self._server.handle_bad_response(response, is_httpresponse=True) + + def get_revision(self, revision_id: str) -> ConnectCloudRevision: + response = cast(Union[ConnectCloudRevision, HTTPResponse], self.get(f"/revisions/{revision_id}")) + return self._server.handle_bad_response(response) + + def authorize_log_channel(self, channel: str) -> str: + """Get a scoped token for reading a revision's publish log.""" + body = { + "resource_type": "log_channel", + "resource_id": channel, + "permission": "revision.logs:read", + } + response = self.post("/authorization", body=body) + data = cast(dict[str, Any], self._server.handle_bad_response(response)) + token = data.get("token") + if not token: + raise RSConnectException("Posit Connect Cloud did not return a log authorization token.") + return str(token) + + def get_publish_logs(self, channel: str) -> list[ConnectCloudLogEntry]: + """Read a revision's publish log from the separate logs host. + + Authorized with a scoped channel token rather than the account token. + """ + token = self.authorize_log_channel(channel) + logs_server = HTTPServer(self._server.urls().logs) + logs_server.authorization(f"Bearer {token}") + with logs_server: + response = logs_server.get( + f"/v1/logs/{channel}", + query_params={"traversal_direction": "backward", "limit": 1500}, + ) + # A plain HTTPServer leaves the body as an HTTPResponse rather than + # unwrapping it, so check the status and then read the JSON. + checked = self._server.handle_bad_response(cast(HTTPResponse, response), is_httpresponse=True) + data = cast(ConnectCloudLogs, checked.json_data or {}) + return data.get("data") or [] + + def upload_bundle(self, upload_url: str, contents: bytes) -> None: + """POST a bundle to the presigned URL from a revision. + + The URL carries its own credentials, so this request must not include + the account's bearer token. + """ + parsed = parse.urlparse(upload_url) + base = f"{parsed.scheme}://{parsed.netloc}" + path = parsed.path + if parsed.query: + path = f"{path}?{parsed.query}" + + upload_server = HTTPServer(base) + with upload_server: + response = upload_server.request( + "POST", + path, + body=contents, + headers={"Content-Type": "application/gzip"}, + decode_response=False, + ) + + S3Server(upload_url).handle_bad_response(cast(HTTPResponse, response), is_httpresponse=True) + + class ShinyappsService: """ Encapsulates operations involving multiple API calls to shinyapps.io. @@ -2450,6 +3294,252 @@ def do_deploy(self, bundle_id: str, app_id: str): raise e +class ConnectCloudDeployResult: + def __init__(self, content_id: str, revision_id: str, upload_url: Optional[str], app_url: str, title: str): + self.content_id = content_id + self.revision_id = revision_id + self.upload_url = upload_url + self.app_url = app_url + self.title = title + + +# Progress labels for the revision states Connect Cloud reports while publishing. +# Unrecognized states are echoed as-is rather than treated as an error, since the +# server can add states without us knowing about them. +_CONNECT_CLOUD_STATE_MESSAGES = { + "publish_deferred": "Waiting to publish", + "publish_requested": "Publish requested", + "publish_started": "Publishing started", + "fetching": "Fetching source", + "building": "Building", + "rendering": "Rendering", + "publishing": "Publishing", + "published": "Published", +} + +_CONNECT_CLOUD_POLL_INTERVAL_SECONDS = 1.0 + + +class ConnectCloudService: + """ + Operations involving multiple Posit Connect Cloud API calls. + """ + + def __init__(self, client: ConnectCloudClient, server: ConnectCloudServer): + self._client = client + self._server = server + + def prepare_deploy( + self, + app_id: Optional[str], + app_name: str, + title: str, + app_mode: AppMode, + primary_file: str, + env_vars: Optional[dict[str, str]] = None, + upload: bool = True, + update_title: bool = False, + app_id_is_explicit: bool = False, + ) -> ConnectCloudDeployResult: + """Create or fetch the content item and get a revision to upload into. + + `update_title` is set when the user typed an explicit --title: only then + does a redeploy overwrite the title on existing content. + + `app_id_is_explicit` distinguishes an --app-id the user typed from one + read out of the local deployment record. An explicit id names content + the user expects to replace, so failing to find it is an error; only a + stale local record falls back to creating new content. + """ + content_type = AppModes.get_connect_cloud_content_type(app_mode) + if content_type is None: + raise RSConnectException( + "Posit Connect Cloud does not support %s content." % app_mode.desc(), + ) + + # None (no -E given) means "leave the server's secrets alone"; it reaches + # update_content as None and is omitted from the PATCH. A non-empty -E set + # replaces the whole collection, matching the R client. + secrets = [{"name": name, "value": value} for name, value in env_vars.items()] if env_vars else None + + content: Optional[ConnectCloudContent] = None + if app_id: + try: + content = self._client.get_content(app_id) + except RSConnectException as exc: + if exc.status != 404: + raise + if app_id_is_explicit: + raise RSConnectException( + "Content %s does not exist in Posit Connect Cloud (it may have been deleted). " + "Remove --app-id to create new content, or pass the id of existing content." % app_id + ) from exc + logger.warning( + "Content %s no longer exists in Posit Connect Cloud; creating new content." % app_id, + ) + content = None + + # One token can publish to several accounts, so a stale or copied record + # can point at content owned by an account other than the one being + # published to. Updating it would silently ignore the requested account. + if content is not None and content.get("account_id") and content["account_id"] != self.account_id(): + if app_id_is_explicit: + raise RSConnectException( + 'Content %s belongs to a different Posit Connect Cloud account than "%s". ' + "Pass -A/--account with the owning account, or remove --app-id to create " + 'new content in "%s".' % (app_id, self._server.account_name, self._server.account_name) + ) + logger.warning( + 'Content %s belongs to a different Posit Connect Cloud account than "%s"; ' + "creating new content in that account." % (app_id, self._server.account_name) + ) + content = None + + if content is None: + content = self._client.create_content( + account_id=self.account_id(), + title=title or app_name, + content_type=content_type, + app_mode=app_mode.name(), + primary_file=primary_file, + secrets=secrets, + ) + else: + # Existing content: push secrets and entrypoint, and ask for a new + # revision to upload into. Only freshly *created* content (above) can + # skip this. In particular content whose first publish failed has no + # current_revision but still needs the PATCH: reusing its stale + # next_revision would replay the old primary_file and secrets. + content = self._client.update_content( + content["id"], + primary_file=primary_file, + app_mode=app_mode.name(), + content_type=content_type, + secrets=secrets, + new_bundle=upload, + title=title if update_title and title else None, + ) + + next_revision = content.get("next_revision") + if not next_revision or not next_revision.get("id"): + raise RSConnectException("Posit Connect Cloud did not return a revision to publish.") + + return ConnectCloudDeployResult( + content_id=content["id"], + revision_id=next_revision["id"], + upload_url=next_revision.get("source_bundle_upload_url"), + app_url=self.content_url(content["id"], content.get("account_id")), + title=content.get("title") or title or app_name, + ) + + def account_id(self) -> str: + """The id of the account being published to. + + Saved by `rsconnect add`, so the usual deploy sends it without a lookup and + keeps working if the account is renamed. Falls back to resolving the name for + servers saved before the id was recorded, and for an account named on the + command line that is not the saved one. + """ + if self._server.account_id: + return self._server.account_id + account = self._client.get_account_by_name(self._server.account_name) + return account["id"] + + def content_url(self, content_id: str, account_id: Optional[str]) -> str: + """Build the browsable URL for a content item. + + The API returns only an account id, so the owning account has to be + looked up by name. That account can be a team rather than the + authenticated user, so this cannot assume the configured account. + """ + try: + # Resolved by id even for the account published to: the saved name can + # be stale after a rename, and account ids are what survive one. + account_name = self._server.account_name + if account_id: + for account in self._client.get_accounts(): + if account.get("id") == account_id: + account_name = account["name"] + break + return self._server.urls().content_url(account_name, content_id) + except RSConnectException as exc: + # A URL we cannot build must not mask an otherwise successful deploy. + logger.warning("Could not determine the content URL: %s" % exc) + return "" + + def upload_bundle(self, result: ConnectCloudDeployResult, contents: bytes) -> None: + if not result.upload_url: + raise RSConnectException("Posit Connect Cloud did not provide a bundle upload URL.") + self._client.upload_bundle(result.upload_url, contents) + + def do_deploy(self, result: ConnectCloudDeployResult, timeout: Optional[int] = None) -> None: + """Publish the uploaded revision and wait for it to finish.""" + self._client.publish(result.content_id) + self.wait_for_publish(result.revision_id, timeout=timeout) + + def wait_for_publish(self, revision_id: str, timeout: Optional[int] = None) -> ConnectCloudRevision: + """Poll a revision until it reports a publish result. + + Unlike the R client this has a timeout, and tolerates states it does not + recognize rather than failing on them. + """ + deadline = None if timeout is None else time.time() + timeout + last_status: Optional[str] = None + + while True: + revision = self._client.get_revision(revision_id) + + status = revision.get("status") + if status and status != last_status: + last_status = status + logger.info(_CONNECT_CLOUD_STATE_MESSAGES.get(status, status)) + + publish_result = revision.get("publish_result") + if publish_result: + if publish_result == "success": + return revision + self._report_publish_failure(revision) + raise DeploymentFailedException( + "Posit Connect Cloud failed to publish the content: %s" + % (revision.get("publish_error_details") or publish_result) + ) + + if deadline is not None and time.time() >= deadline: + raise RSConnectException( + "Timed out after %d seconds waiting for Posit Connect Cloud to publish the content. " + "The deployment may still finish; check %s." % (timeout, self._server.urls().ui) + ) + + time.sleep(_CONNECT_CLOUD_POLL_INTERVAL_SECONDS) + + def _report_publish_failure(self, revision: ConnectCloudRevision) -> None: + """Print the publish log for a failed revision, if one is available.""" + channel = revision.get("publish_log_channel") + if not channel: + return + try: + entries = self._client.get_publish_logs(channel) + except RSConnectException as exc: + # Losing the logs should not replace the underlying publish failure. + logger.warning("Could not retrieve the publishing log: %s" % exc) + return + + if not entries: + return + + logger.error("---- Begin Publishing Log ----") + for entry in entries: + message = entry.get("message", "") + timestamp = entry.get("timestamp") + if timestamp: + # Connect Cloud reports microseconds since the epoch. + stamp = datetime.datetime.fromtimestamp(timestamp / 1e6, datetime.timezone.utc) + logger.error("%s %s" % (stamp.isoformat(), message)) + else: + logger.error(message) + logger.error("---- End Publishing Log ----") + + def verify_server(connect_server: RSConnectServer): """ Verify that the given server information represents a Connect instance that is @@ -2620,5 +3710,8 @@ def find_unique_name(remote_server: TargetableServer, name: str): return name else: - # non-unique names are permitted in cloud + # Posit Connect Cloud permits duplicate names, and its API cannot filter + # content by name, so there is nothing to check against. Losing the local + # deployment record therefore creates a second content item rather than + # updating the first. return name diff --git a/rsconnect/certificates.py b/rsconnect/certificates.py index 1639b1497..95b98714e 100644 --- a/rsconnect/certificates.py +++ b/rsconnect/certificates.py @@ -2,6 +2,8 @@ from pathlib import Path +from .exception import RSConnectException + BINARY_ENCODED_FILETYPES = [".cer", ".der"] TEXT_ENCODED_FILETYPES = [".ca-bundle", ".crt", ".key", ".pem"] @@ -20,20 +22,33 @@ def read_certificate_file(location: str) -> str | bytes: """ path = Path(location) - suffix = path.suffix - - if suffix in BINARY_ENCODED_FILETYPES: - with open(path, "rb") as bFile: - return bFile.read() - if suffix in TEXT_ENCODED_FILETYPES: - with open(path, "r") as tFile: - return tFile.read() + # Readability is judged before the suffix so a bad path reports as the real + # problem: the file type of a file that does not exist is beside the point. + # Both are operational errors: the CLI no longer checks existence at parse + # time (the certificate only applies once the target is known), so this is + # where a bad path surfaces. is_file() sits inside the handler because it + # raises OSError itself when the path's metadata cannot be read, e.g. + # through a permission-denied directory. + try: + if not path.is_file(): + raise RSConnectException("The certificate file %s could not be read: no such file." % location) + + suffix = path.suffix + if suffix in BINARY_ENCODED_FILETYPES: + with open(path, "rb") as bFile: + return bFile.read() + + if suffix in TEXT_ENCODED_FILETYPES: + with open(path, "r") as tFile: + return tFile.read() + except OSError as exc: + raise RSConnectException("The certificate file %s could not be read: %s" % (location, exc)) from exc types = BINARY_ENCODED_FILETYPES + TEXT_ENCODED_FILETYPES types = sorted(types) types = [f"'{_}'" for _ in types] human_readable_string = ", ".join(types[:-1]) + ", or " + types[-1] - raise RuntimeError( + raise RSConnectException( f"The certificate file type is not recognized. Expected {human_readable_string}. Found '{suffix}'." ) diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py new file mode 100644 index 000000000..100e14a59 --- /dev/null +++ b/rsconnect/connect_cloud.py @@ -0,0 +1,232 @@ +"""Posit Connect Cloud environment configuration and authentication. + +Connect Cloud is a distinct deployment target from Posit Connect and +shinyapps.io. It authenticates with OAuth 2.0 against ``login.posit.cloud``, +using either the device code flow (interactive) or the client credentials +grant (non-interactive, for CI). +""" + +from __future__ import annotations + +import os +from typing import Any, NamedTuple, Optional +from urllib.parse import urlparse + +from .exception import RSConnectException +from .oauth import login_with_device_code, refresh_access_token, request_client_credentials_token + +# The OAuth scope Connect Cloud issues tokens for. "vivid" is the internal name +# of the Connect Cloud API. +SCOPE = "vivid" + +# Environment selection. Connect Cloud has production, staging, and development +# deployments +ENVIRONMENT_ENV_VAR = "CONNECT_CLOUD_ENVIRONMENT" +# Overrides the OAuth client this CLI identifies itself as. Distinct from +# CONNECT_CLOUD_CLIENT_ID, which is a user's service account credential. +OAUTH_CLIENT_ID_ENV_VAR = "CONNECT_CLOUD_OAUTH_CLIENT_ID" +DEFAULT_ENVIRONMENT = "production" + + +class ConnectCloudUrls(NamedTuple): + """The set of hosts that make up one Connect Cloud environment.""" + + api: str + ui: str + auth: str + logs: str + + @property + def device_authorization_endpoint(self) -> str: + return self.auth + "/oauth/device/authorize" + + @property + def token_endpoint(self) -> str: + return self.auth + "/oauth/token" + + def oauth_metadata(self) -> dict[str, Any]: + """Shape these URLs like the OIDC discovery document ``oauth.py`` expects. + + Connect Cloud does not publish a discovery document, so we synthesize the + two fields the device code flow needs. + """ + return { + "device_authorization_endpoint": self.device_authorization_endpoint, + "token_endpoint": self.token_endpoint, + } + + def content_url(self, account_name: str, content_id: str) -> str: + """Build the browsable URL for a content item. + + The API never returns one: responses carry only ``account_id``, so the + caller has to resolve the owning account name first. + """ + return "%s/%s/content/%s" % (self.ui, account_name, content_id) + + +_ENVIRONMENTS: dict[str, ConnectCloudUrls] = { + "production": ConnectCloudUrls( + api="https://api.connect.posit.cloud/v1", + ui="https://connect.posit.cloud", + auth="https://login.posit.cloud", + logs="https://logs.connect.posit.cloud", + ), + "staging": ConnectCloudUrls( + api="https://api.staging.connect.posit.cloud/v1", + ui="https://staging.connect.posit.cloud", + auth="https://login.staging.posit.cloud", + logs="https://logs.staging.connect.posit.cloud", + ), + "development": ConnectCloudUrls( + api="https://api.dev.connect.posit.cloud/v1", + ui="https://dev.connect.posit.cloud", + # Development shares staging's auth service. + auth="https://login.staging.posit.cloud", + logs="https://logs.dev.connect.posit.cloud", + ), +} + +# The OAuth client registered for this CLI, per environment. These are public +# clients: the device code flow uses no client secret. +_CLIENT_IDS: dict[str, str] = { + "production": "rsconnect-python", + "staging": "rsconnect-python-staging", + "development": "rsconnect-python-development", +} + + +def environment_name() -> str: + """The selected Connect Cloud environment name.""" + name = os.environ.get(ENVIRONMENT_ENV_VAR) or DEFAULT_ENVIRONMENT + if name not in _ENVIRONMENTS: + raise RSConnectException( + "Unknown Connect Cloud environment %r (from %s). Expected one of: %s." + % (name, ENVIRONMENT_ENV_VAR, ", ".join(sorted(_ENVIRONMENTS))) + ) + return name + + +def urls(environment: Optional[str] = None) -> ConnectCloudUrls: + """The URLs for the given (or currently selected) Connect Cloud environment.""" + return _ENVIRONMENTS[environment or environment_name()] + + +def client_id(environment: Optional[str] = None) -> str: + """The OAuth client ID to authenticate this CLI with.""" + override = os.environ.get(OAUTH_CLIENT_ID_ENV_VAR) + if override: + return override + return _CLIENT_IDS[environment or environment_name()] + + +# What a user types for --server to mean Connect Cloud, mirroring how +# "shinyapps.io" is accepted in place of https://api.shinyapps.io. +SERVER_NAME = "connect.posit.cloud" + + +def _canonical_api_url(url: str) -> Optional[str]: + """The environment API base URL that `url` refers to, or None. + + Tolerates only scheme/host case and a trailing slash; the path, port, and + query must match exactly, keeping the no-substring rule from + is_connect_cloud_url. + """ + parsed = urlparse(url) + key = (parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), parsed.query, parsed.fragment) + for env in _ENVIRONMENTS.values(): + api = urlparse(env.api) + if key == (api.scheme, api.netloc, api.path, api.query, api.fragment): + return env.api + return None + + +def is_connect_cloud_url(url: Optional[str]) -> bool: + """Whether a --server value or stored URL refers to Connect Cloud. + + Matches the pseudo-server name and the API base URL of every environment, + tolerating host case and a trailing slash but nothing looser. This is + deliberately not a substring test: the removed Posit Cloud support matched + "posit.cloud" anywhere in the URL, in four separate places, which would + also match an unrelated host such as connect.posit.cloud.example.com. + """ + if not url: + return False + if url.rstrip("/").lower() == SERVER_NAME: + return True + return _canonical_api_url(url) is not None + + +def resolve_url(url: Optional[str]) -> str: + """Turn a --server value into the API base URL for the selected environment. + + Recognized API URLs are canonicalized (case, trailing slash), so the stored + URL always matches environment_for_url and joins cleanly with request paths. + """ + if not url or url.rstrip("/").lower() == SERVER_NAME: + return urls().api + return _canonical_api_url(url) or url + + +def environment_for_url(url: Optional[str]) -> str: + """Which environment an API base URL belongs to. + + A saved server records only its API URL, so this is how everything else about + that environment — the auth, UI, and logs hosts — is recovered. Without it, a + server saved against staging would have its tokens refreshed against + production and its content URLs built from the production UI host. + + Falls back to the selected environment for a URL we do not recognize. + """ + canonical = _canonical_api_url(url) if url else None + for name, env in _ENVIRONMENTS.items(): + if canonical == env.api: + return name + return environment_name() + + +def login_interactive(environment: Optional[str] = None) -> dict[str, Any]: + """Authenticate with the OAuth device code flow. + + Prints a verification URL and user code, then polls until the user + authorizes. Returns the token response, which includes ``access_token`` and + ``refresh_token``. + """ + env = environment or environment_name() + env_urls = urls(env) + return login_with_device_code( + url=env_urls.auth, + client_id=client_id(env), + metadata=env_urls.oauth_metadata(), + scope=SCOPE, + ) + + +def login_client_credentials( + client_id_value: str, + client_secret: str, + environment: Optional[str] = None, +) -> dict[str, Any]: + """Authenticate with the OAuth client credentials grant, for CI. + + Credentials are minted at https://login.posit.cloud/identity/credentials. + The response carries no refresh token, so the credentials themselves are + stored and used to mint a new access token when the current one expires. + """ + env_urls = urls(environment) + return request_client_credentials_token( + token_endpoint=env_urls.token_endpoint, + client_id=client_id_value, + client_secret=client_secret, + scope=SCOPE, + ) + + +def refresh(refresh_token: str, environment: Optional[str] = None) -> dict[str, Any]: + """Mint a new access token from a refresh token.""" + env = environment or environment_name() + return refresh_access_token( + metadata=urls(env).oauth_metadata(), + client_id=client_id(env), + refresh_token=refresh_token, + scope=SCOPE, + ) diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 707eb0ead..aced96619 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -7,6 +7,7 @@ import base64 import json import os +import re import socket import ssl from http import client as http @@ -33,6 +34,111 @@ _user_agent = f"RSConnectPython/{VERSION}" +# Credential material must not reach the debug log, which otherwise prints +# requests and responses verbatim. Headers are matched by name; body and query +# fields are matched in both form-encoded and JSON shapes. "value" covers Posit +# Connect Cloud secret payloads ([{"name": ..., "value": ...}]); the X-Amz-*, +# signature, and sig (Azure SAS) names cover presigned upload URLs, whose query +# string carries its own credentials. +_SENSITIVE_HEADERS = { + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + # shinyapps.io request signing and the SPCS API-key header. + "x-auth-token", + "x-auth-signature", + "x-rsc-authorization", +} +_SENSITIVE_FIELDS = ( + "client_secret", + "refresh_token", + "access_token", + "device_code", + "token", + "secret", + "password", + "value", + "signature", + "sig", + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "awsaccesskeyid", + # OIDC token exchange (RFC 8693); "token" alone does not match it because + # the underscore is a word character, so \btoken= never fires inside it. + "subject_token", + # PKCE (RFC 7636). + "code_verifier", + # The bootstrap response body carries a freshly minted admin API key. + "api_key", + "id_token", +) +_SENSITIVE_FIELD_SET = frozenset(_SENSITIVE_FIELDS) +# The OAuth authorization code is only ever form-encoded (the token endpoint +# POST), so "code" is redacted there but deliberately left out of the JSON +# redaction: in JSON bodies a bare "code" key is an error code (Connect, +# shinyapps.io), which the debug log must keep readable. +_SENSITIVE_FORM_ONLY_FIELDS = _SENSITIVE_FIELDS + ("code",) +_SENSITIVE_FORM_FIELD = re.compile(r"\b(%s)=[^&\s'\"]*" % "|".join(_SENSITIVE_FORM_ONLY_FIELDS), re.IGNORECASE) +_SENSITIVE_JSON_FIELD = re.compile(r'"(%s)"\s*:\s*"[^"]*"' % "|".join(_SENSITIVE_FIELDS), re.IGNORECASE) + + +def _redacted_header_for_log(key: str, value: str) -> str: + if key.lower() not in _SENSITIVE_HEADERS: + return value + # Keep only a leading scheme word ("Bearer", "Key"): in X-Auth-Signature the + # first token is itself the credential ("; version=1"). + scheme, _, rest = value.partition(" ") + return f"{scheme} " if rest and scheme.isalpha() else "" + + +def _redacted_uri_for_log(uri: str) -> str: + """Redact credential-bearing query parameters, e.g. a presigned upload URL's.""" + return _SENSITIVE_FORM_FIELD.sub(r"\1=", uri) + + +def _redact_json_value(value: JsonData) -> JsonData: + if isinstance(value, dict): + return { + key: ("" if key.lower() in _SENSITIVE_FIELD_SET else _redact_json_value(item)) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_redact_json_value(item) for item in value] + if isinstance(value, str): + # A string under an innocent key can itself carry credentials, e.g. a + # presigned upload URL's query string in source_bundle_upload_url. + return _redacted_uri_for_log(value) + return value + + +def _redacted_body_for_log(body: object) -> object: + """Redact known credential fields from a request or response body. + + Only affects what is logged; the body itself is sent untouched. Streams and + other non-text bodies are logged as their repr, which carries no content. + JSON bodies are parsed and redacted structurally, since a secret containing + an escaped quote would leak past a regex; everything else falls back to the + form-encoded pattern. + """ + if isinstance(body, bytes): + text = body.decode("utf-8", errors="replace") + elif isinstance(body, str): + text = body + else: + return body + + stripped = text.lstrip() + if stripped.startswith("{") or stripped.startswith("["): + try: + return json.dumps(_redact_json_value(json.loads(text))) + except (json.JSONDecodeError, ValueError): + pass + + text = _SENSITIVE_FORM_FIELD.sub(r"\1=", text) + return _SENSITIVE_JSON_FIELD.sub(r'"\1": ""', text) + # noinspection PyUnusedLocal,PyUnresolvedReferences def _create_plain_connection( @@ -235,6 +341,10 @@ def __init__( self.content_type: str | None = None self.json_data: JsonData = None self.response_body = body + # None when the request failed before a response arrived (exception set), + # so status checks on a connection failure do not raise AttributeError. + self.status: int | None = None + self.reason: str | None = None if response is not None: self.status = response.status @@ -436,12 +546,12 @@ def _do_request( try: if logger.is_debugging(): - logger.debug(f"Request: {method} {full_uri}") + logger.debug(f"Request: {method} {_redacted_uri_for_log(full_uri)}") logger.debug("Headers:") for key, value in headers.items(): - logger.debug(f"--> {key}: {value}") + logger.debug(f"--> {key}: {_redacted_header_for_log(key, value)}") logger.debug("Body:") - logger.debug(f"--> {body if body is not None else ''}") + logger.debug(f"--> {_redacted_body_for_log(body) if body is not None else ''}") # if we weren't called under a `with` statement, we'll need to manage the # connection here. @@ -464,13 +574,13 @@ def _do_request( logger.debug(f"Response: {response.status} {response.reason}") logger.debug("Headers:") for key, value in response.getheaders(): - logger.debug(f"--> {key}: {value}") + logger.debug(f"--> {key}: {_redacted_header_for_log(key, value)}") logger.debug("Body:") if response.getheader("Content-Type", "").startswith("application/json"): # Only print JSON responses. # Otherwise we end up dumping entire web pages to the log. try: - logger.debug(f"--> {response_body}") + logger.debug(f"--> {_redacted_body_for_log(response_body)}") except json.JSONDecodeError: logger.debug("--> ") else: @@ -499,7 +609,7 @@ def _do_request( else: next_url = location - logger.debug(f"--> Redirected to: {urljoin(self._url.geturl(), location)}") + logger.debug(f"--> Redirected to: {_redacted_uri_for_log(urljoin(self._url.geturl(), location))}") redirect_extra_headers = self.get_extra_headers(next_url, "GET", body) return self._do_request( @@ -573,13 +683,14 @@ def store_cookies(self, response: http.HTTPResponse): if morsel.key not in self._keys: self._keys.append(morsel.key) self._content[morsel.key] = morsel.value - logger.debug(f"--> Set cookie {morsel.key}: {morsel.value}") + # Cookies are session credentials; names only, like the header log. + logger.debug(f"--> Set cookie {morsel.key}: ") - logger.debug(f"CookieJar contents: {self._keys}\n{self._content}") + logger.debug(f"CookieJar contents: {self._keys}") def get_cookie_header_value(self): result = "; ".join([f"{key}={self._reference.value_encode(self._content[key])[1]}" for key in self._keys]) - logger.debug(f"Cookie: {result}") + logger.debug(f"Cookie: {'; '.join(f'{key}=' for key in self._keys)}") return result def as_dict(self): diff --git a/rsconnect/json_web_token.py b/rsconnect/json_web_token.py index cfb564b78..6b9e3bdc8 100644 --- a/rsconnect/json_web_token.py +++ b/rsconnect/json_web_token.py @@ -82,9 +82,7 @@ def parse_client_response(response: BootstrapOutputDTO | HTTPResponse) -> tuple[ if hasattr(response, "exception") and response.exception is not None: raise RSConnectException(str(response.exception)) - status = 500 - if hasattr(response, "status"): - status = response.status + status = response.status if response.status is not None else 500 json_data: JsonData = {} if hasattr(response, "json_data"): diff --git a/rsconnect/main.py b/rsconnect/main.py index 2202872fe..c9faa9753 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -38,11 +38,13 @@ from rsconnect.certificates import read_certificate_file from . import VERSION, api, validation +from . import connect_cloud as connect_cloud_auth # aliased: `connect_cloud` is a CLI flag name from .version_check import BackgroundVersionCheck from .actions import ( cli_feedback, create_quarto_deployment_bundle, describe_manifest, + quarto_inputs_from_inspect, quarto_inspect, set_verbosity, test_api_key, @@ -179,6 +181,19 @@ def failed(err: str) -> Never: return wrapper +# Parameters whose values are credentials and must not reach the verbose log. +# Both spellings are listed because callers pass click's underscored parameter +# names, while some pass the dashed option name. +_MASKED_PARAMS = { + "api_key", + "api-key", + "token", + "secret", + "client_secret", + "client-secret", +} + + def output_params( ctx: click.Context, vars: ItemsView[str, object], @@ -189,9 +204,12 @@ def output_params( if k in {"ctx", "verbose", "quiet", "kwargs"}: continue if v is not None: - val = v - if k in {"api_key", "api-key"}: + val: object = v + if k in _MASKED_PARAMS: val = "**********" + elif k == "env_vars" and isinstance(v, dict): + # The values are sent to the server as secrets; log names only. + val = "%s (values hidden)" % sorted(cast("dict[str, str]", v)) sourceName = validation.get_parameter_source_name_from_ctx(k, ctx) logger.log(VERBOSE, " %-18s%s (from %s)", (k + ":"), val, sourceName) @@ -223,7 +241,11 @@ def server_args(func: Callable[P, T]) -> Callable[P, T]: "--cacert", "-c", envvar="CONNECT_CA_CERTIFICATE", - type=click.Path(exists=True, file_okay=True, dir_okay=False), + # No exists=True: the certificate only applies once the target is known, + # and a stale CONNECT_CA_CERTIFICATE must not fail a Posit Connect Cloud + # deploy at parse time. Non-Cloud targets report an unreadable path when + # the file is read. + type=click.Path(file_okay=True, dir_okay=False), help="The path to trusted TLS CA certificates. (Also settable via \ CONNECT_CA_CERTIFICATE environment variable.)", ) @@ -263,8 +285,9 @@ def cloud_shinyapps_args(func: Callable[P, T]) -> Callable[P, T]: "--account", "-A", envvar=["SHINYAPPS_ACCOUNT"], - help="The shinyapps.io account name. (Also settable via \ -SHINYAPPS_ACCOUNT environment variable.)", + help="The shinyapps.io or Posit Connect Cloud account name. (Also settable via the \ +SHINYAPPS_ACCOUNT environment variable for shinyapps.io, or CONNECT_CLOUD_ACCOUNT for \ +Posit Connect Cloud; each applies only to its own target.)", ) @click.option( "--token", @@ -287,6 +310,63 @@ def wrapper(*args: P.args, **kwargs: P.kwargs): return wrapper +def connect_cloud_args(func: Callable[P, T]) -> Callable[P, T]: + """Options for Posit Connect Cloud. + + The target is selected either with `--server connect.posit.cloud`, mirroring + how shinyapps.io is selected with `--server shinyapps.io`, or with the + equivalent `--connect-cloud` shorthand. The remaining options supply the + service account credential used for non-interactive login. + """ + + @click.option( + "--connect-cloud", + is_flag=True, + default=False, + help="Target Posit Connect Cloud. Equivalent to `--server connect.posit.cloud`, \ +and takes precedence over a CONNECT_SERVER environment variable.", + ) + @click.option( + "--client-id", + envvar="CONNECT_CLOUD_CLIENT_ID", + help="The Posit Connect Cloud service account client ID, for non-interactive \ +authentication. (Also settable via CONNECT_CLOUD_CLIENT_ID environment variable.)", + ) + @click.option( + "--client-secret", + envvar="CONNECT_CLOUD_CLIENT_SECRET", + help="The Posit Connect Cloud service account client secret, for non-interactive \ +authentication. (Also settable via CONNECT_CLOUD_CLIENT_SECRET environment variable.)", + ) + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs): + return func(*args, **kwargs) + + return wrapper + + +def connect_cloud_account_arg(func: Callable[P, T]) -> Callable[P, T]: + """-A/--account for commands that support Posit Connect Cloud but not shinyapps.io. + + Commands supporting both get -A from cloud_shinyapps_args instead; this one + exists because notebook and quarto cannot take the shinyapps options (-S is + already `deploy notebook --static`) but still need an account for Connect + Cloud one-shot deploys. + """ + + @click.option( + "--account", + "-A", + help="The Posit Connect Cloud account to deploy to. (Also settable via the \ +CONNECT_CLOUD_ACCOUNT environment variable.)", + ) + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs): + return func(*args, **kwargs) + + return wrapper + + def shinyapps_deploy_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--visibility", @@ -571,7 +651,7 @@ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> def cli(future: bool): """ This command line tool may be used to deploy various types of content to Posit - Connect and shinyapps.io. + Connect, Posit Connect Cloud, and shinyapps.io. The tool supports the notion of a simple nickname that represents the information needed to interact with a deployment target. Use the add, list and @@ -585,6 +665,10 @@ def cli(future: bool): For shinyapps.io, the auth token, auth secret, server ('shinyapps.io'), and account are needed. + + For Posit Connect Cloud, the server ('connect.posit.cloud') and account are needed. + Credentials come from an interactive browser login, or from a service account + client ID and secret for non-interactive use. """ global future_enabled future_enabled = future @@ -634,6 +718,51 @@ def _test_spcs_creds(server: SPCSConnectServer): test_spcs_server(server) +def _connect_cloud_login( + account: str, + client_id: Optional[str], + client_secret: Optional[str], + url: Optional[str] = None, +) -> api.ConnectCloudServer: + """Authenticate against Posit Connect Cloud and return the resulting server. + + Uses the OAuth client credentials grant when a service account credential is + supplied, and the interactive device code flow otherwise. The server is built + before logging in because its URL decides which environment's auth host to + log in against: an explicitly typed staging API URL must not authenticate + against production just because CONNECT_CLOUD_ENVIRONMENT is unset. + """ + server = api.ConnectCloudServer( + account_name=account, + client_id=client_id, + client_secret=client_secret, + url=url, + ) + + if client_id or client_secret: + if not (client_id and client_secret): + raise RSConnectException( + "Both --client-id and --client-secret are required to authenticate with a " + "Posit Connect Cloud service account. Omit both to log in interactively." + ) + tokens = connect_cloud_auth.login_client_credentials(client_id, client_secret, server.environment) + else: + tokens = connect_cloud_auth.login_interactive(server.environment) + + server.access_token = tokens.get("access_token") + server.refresh_token = tokens.get("refresh_token") + + # A token proves the credentials are good but says nothing about the account + # name, so check it here rather than letting a typo surface at deploy time. + with cli_feedback("Checking {} account".format(server.remote_name)): + with api.ConnectCloudClient(server) as client: + # Keep the id: deploys address the account by id, so they neither pay for + # this lookup again nor break if the account is renamed. + server.account_id = client.get_account_by_name(account)["id"] + + return server + + @cli.command( short_help="Create an initial admin user to bootstrap a Connect instance.", help="Creates an initial admin user to bootstrap a Connect instance. Returns the provisioned API key.", @@ -714,7 +843,7 @@ def bootstrap( # noinspection SpellCheckingInspection @cli.command( - short_help="Define a nickname for a Posit Connect or shinyapps.io server and credential.", + short_help="Define a nickname for a Posit Connect, Posit Connect Cloud, or shinyapps.io credential.", help=( "Associate a simple nickname with the information needed to interact with a deployment target. " "Specifying an existing nickname will cause its stored information to be replaced by what is given " @@ -722,9 +851,11 @@ def bootstrap( ), no_args_is_help=True, ) +@cli_exception_handler @server_args @spcs_args @cloud_shinyapps_args +@connect_cloud_args @click.option( "--set-default", is_flag=True, @@ -743,16 +874,25 @@ def add( account: Optional[str], token: Optional[str], secret: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, set_default: bool, verbose: int, ): set_verbosity(verbose) output_params(ctx, locals().items()) - if not server and not any([token, secret, account]): + is_connect_cloud = connect_cloud or connect_cloud_auth.is_connect_cloud_url(server) + if is_connect_cloud: + # An exported SHINYAPPS_ACCOUNT must not become the Connect Cloud + # account to register; only a typed -A or CONNECT_CLOUD_ACCOUNT counts. + account = validation.effective_connect_cloud_account(ctx, account) + + if not server and not connect_cloud and not any([token, secret, account]): raise RSConnectException( - "`rsconnect add` requires -s/--server (for Posit Connect) or -A/--account, -T/--token, " - "and -S/--secret (for shinyapps.io)." + "`rsconnect add` requires -s/--server (for Posit Connect or Posit Connect Cloud), " + "--connect-cloud, or -A/--account, -T/--token, and -S/--secret (for shinyapps.io)." ) validation.validate_connection_options( @@ -765,14 +905,48 @@ def add( token=token, secret=secret, snowflake_connection_name=snowflake_connection_name, + client_id=client_id, + client_secret=client_secret, + connect_cloud=connect_cloud, ) # The validation.validate_connection_options() function ensures that certain # combinations of arguments are present; the cast() calls inside of the # if-statements below merely reflect these validations. + # --connect-cloud is shorthand for --server connect.posit.cloud. Resolve it now + # that validation has had a chance to see the original --server. An explicitly + # typed Connect Cloud API URL is kept, because it pins the environment (staging + # vs production); anything else — the pseudo-name, no server at all, or any + # CONNECT_SERVER value when the flag is given, even a Connect Cloud URL for + # another environment — follows CONNECT_CLOUD_ENVIRONMENT. + if is_connect_cloud: + keep_url = connect_cloud_auth.is_connect_cloud_url(server) and not ( + connect_cloud and validation.get_parameter_source_name_from_ctx("server", ctx) == "ENVIRONMENT" + ) + if not keep_url: + server = connect_cloud_auth.SERVER_NAME + server = connect_cloud_auth.resolve_url(server) + old_server = server_store.get_by_name(name) - if token: + if is_connect_cloud: + account = cast(str, account) + cloud_server = _connect_cloud_login(account, client_id, client_secret, url=server) + + server_store.set( + name, + cloud_server.url, + connect_cloud_account_name=cloud_server.account_name, + connect_cloud_account_id=cloud_server.account_id, + connect_cloud_client_id=cloud_server.client_id, + connect_cloud_client_secret=cloud_server.client_secret, + connect_cloud_access_token=cloud_server.access_token, + connect_cloud_refresh_token=cloud_server.refresh_token, + set_as_default=set_default, + ) + verb = "Updated" if old_server else "Added" + click.echo('{} {} credential "{}" for account "{}".'.format(verb, cloud_server.remote_name, name, account)) + elif token: server = cast(str, server) account = cast(str, account) secret = cast(str, secret) @@ -859,6 +1033,12 @@ def list_servers(verbose: int): default_marker = " [default]" if server.get("default") else "" click.echo('Nickname: "%s"%s' % (server["name"], default_marker)) click.echo(" URL: %s" % server["url"]) + if server.get("connect_cloud_account_name"): + click.echo(" Posit Connect Cloud account: %s" % server["connect_cloud_account_name"]) + if server.get("connect_cloud_client_id"): + click.echo(" Service account client ID: %s" % server["connect_cloud_client_id"]) + if server.get("connect_cloud_access_token"): + click.echo(" Credentials are saved") if server.get("api_key"): click.echo(" API key is saved") if server.get("oauth_client_id"): @@ -1229,7 +1409,9 @@ def login( def _do_login(cid: str) -> dict[str, Any]: if use_device_code: - return _login_device(server, cid, metadata, insecure, ca_data) + # Asking for the device flow against Connect usually means there is no + # usable browser, so do not try to open one. + return _login_device(server, cid, metadata, insecure, ca_data, open_browser=False) else: return login_with_browser(server, cid, metadata, insecure, ca_data) @@ -1450,7 +1632,7 @@ def quickstart(app_type: str, name: str, python_version: Optional[str]): run_quickstart(app_type=app_type, name=name, python_version=python_version) -@cli.group(no_args_is_help=True, help="Deploy content to Posit Connect or shinyapps.io.") +@cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Connect Cloud, or shinyapps.io.") @click.pass_context def deploy(ctx: click.Context): checker = BackgroundVersionCheck() @@ -1494,17 +1676,19 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="notebook", - short_help="Deploy Jupyter notebook to Posit Connect [v1.7.0+].", + short_help="Deploy Jupyter notebook to Posit Connect [v1.7.0+] or Posit Connect Cloud.", help=( - "Deploy a Jupyter notebook to Posit Connect. This may be done by source or as a static HTML " - "page. If the notebook is deployed as a static HTML page (--static), it cannot be scheduled or " - "rerun on the Connect server." + "Deploy a Jupyter notebook to Posit Connect or Posit Connect Cloud. This may be done by " + "source or as a static HTML page. If the notebook is deployed as a static HTML page " + "(--static), it cannot be scheduled or rerun on the Connect server." ), no_args_is_help=True, ) @server_args @spcs_args @content_args +@connect_cloud_args +@connect_cloud_account_arg @runtime_environment_args @click.option( "--static", @@ -1575,6 +1759,10 @@ def deploy_notebook( snowflake_connection_name: Optional[str], insecure: bool, cacert: Optional[str], + account: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, static: bool, new: bool, app_id: Optional[str], @@ -1627,6 +1815,10 @@ def deploy_notebook( snowflake_connection_name=snowflake_connection_name, insecure=insecure, cacert=cacert, + account=account, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=file, server=server, new=new, @@ -1852,9 +2044,9 @@ def deploy_voila( # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="manifest", - short_help="Deploy content to Posit Connect or shinyapps.io by manifest.", + short_help="Deploy content to Posit Connect, Posit Connect Cloud, or shinyapps.io by manifest.", help=( - "Deploy content to Posit Connect or shinyapps.io using an existing manifest.json " + "Deploy content to Posit Connect, Posit Connect Cloud, or shinyapps.io using an existing manifest.json " 'file. The specified file must either be named "manifest.json" or ' 'refer to a directory that contains a file named "manifest.json".' ), @@ -1864,6 +2056,7 @@ def deploy_voila( @spcs_args @content_args @cloud_shinyapps_args +@connect_cloud_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @shinyapps_deploy_args @quiet_arg @@ -1881,6 +2074,9 @@ def deploy_manifest( account: Optional[str], token: Optional[str], secret: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, new: bool, app_id: Optional[str], title: Optional[str], @@ -1898,6 +2094,9 @@ def deploy_manifest( file_name = validate_manifest_file(file) app_mode = read_manifest_app_mode(file_name) + # Remember whether --title was typed: a defaulted title must not + # overwrite existing Connect Cloud content's title on redeploy. + title_is_default = not title title = title or default_title_from_manifest(file) ce = RSConnectExecutor( @@ -1910,6 +2109,9 @@ def deploy_manifest( account=account, token=token, secret=secret, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=file, server=server, new=new, @@ -1918,6 +2120,7 @@ def deploy_manifest( visibility=visibility, env_vars=env_vars, ) + ce.title_is_default = title_is_default # Prepare metadata for upload server_version = None @@ -1948,7 +2151,7 @@ def deploy_manifest( @deploy.command( name="bundle", - short_help="Deploy a previously downloaded bundle to Posit Connect or shinyapps.io.", + short_help="Deploy a previously downloaded bundle to Posit Connect, Posit Connect Cloud, or shinyapps.io.", help=( "Deploy a content bundle (a .tar.gz file, such as one downloaded from a Connect server) " "directly to a server. The bundle is uploaded as-is; its existing manifest.json determines " @@ -1961,6 +2164,7 @@ def deploy_manifest( @spcs_args @content_args @cloud_shinyapps_args +@connect_cloud_args @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @shinyapps_deploy_args @quiet_arg @@ -1978,6 +2182,9 @@ def deploy_bundle( account: Optional[str], token: Optional[str], secret: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, new: bool, app_id: Optional[str], title: Optional[str], @@ -1994,6 +2201,9 @@ def deploy_bundle( output_params(ctx, locals().items()) app_mode = read_bundle_app_mode(file) + # Remember whether --title was typed: a defaulted title must not + # overwrite existing Connect Cloud content's title on redeploy. + title_is_default = not title title = title or default_title_from_bundle(file) ce = RSConnectExecutor( @@ -2006,6 +2216,9 @@ def deploy_bundle( account=account, token=token, secret=secret, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=file, server=server, new=new, @@ -2014,6 +2227,7 @@ def deploy_bundle( visibility=visibility, env_vars=env_vars, ) + ce.title_is_default = title_is_default # Prepare metadata for upload. Passing directory=None skips git auto-detection: # the bundle's location on disk is unrelated to the content's source, so only @@ -2044,7 +2258,7 @@ def deploy_bundle( @deploy.command( name="pyproject", - short_help="Deploy content to Posit Connect or shinyapps.io by pyproject.", + short_help="Deploy content to Posit Connect, Posit Connect Cloud, or shinyapps.io by pyproject.", help=( "Deploy content described by a project's pyproject.toml. The given directory must contain " "a pyproject.toml with a [tool.rsconnect] table specifying app_mode and entrypoint. " @@ -2056,6 +2270,7 @@ def deploy_bundle( @spcs_args @content_args @cloud_shinyapps_args +@connect_cloud_args @click.option( "--requirements-file", "-r", @@ -2093,6 +2308,9 @@ def deploy_pyproject( account: Optional[str], token: Optional[str], secret: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, new: bool, app_id: Optional[str], title: Optional[str], @@ -2115,8 +2333,11 @@ def quickstart_hint() -> str: pyproject_path = Path(directory) / "pyproject.toml" try: + # Only -t/--title overrides the pyproject title. The server nickname used + # to be a fallback here, which made redeployments rename existing Connect + # Cloud content after the nickname. target = resolve_pyproject_deploy_target( - pyproject_path, requirements_file=requirements_file, title_override=title or name + pyproject_path, requirements_file=requirements_file, title_override=title ) except UnsupportedAppModeError as err: raise RSConnectException(str(err)) from err @@ -2136,6 +2357,7 @@ def quickstart_hint() -> str: bundle_builder: Callable[..., Any] bundle_args: tuple[Any, ...] bundle_kwargs: dict[str, Any] = {} + quarto_inputs: Optional[list[str]] = None path = directory # renv.lock detection mirrors the dedicated deploy commands; --exclude-renv @@ -2196,6 +2418,7 @@ def quickstart_hint() -> str: logger.debug("Quarto: %s" % quarto) inspect = quarto_inspect(quarto, path) engines = validate_quarto_engines(inspect) + quarto_inputs = quarto_inputs_from_inspect(path, inspect) environment = None if "jupyter" in engines: @@ -2226,6 +2449,9 @@ def quickstart_hint() -> str: account=account, token=token, secret=secret, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=path, server=server, new=new, @@ -2233,6 +2459,7 @@ def quickstart_hint() -> str: title=effective_title, visibility=visibility, env_vars=env_vars, + quarto_inputs=quarto_inputs, ) server_version = None @@ -2356,11 +2583,11 @@ def deploy_git( # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="quarto", - short_help="Deploy Quarto content to Posit Connect.", + short_help="Deploy Quarto content to Posit Connect or Posit Connect Cloud.", help=( - "Deploy a Quarto document or project to Posit Connect. 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." + "Deploy a Quarto document or project to Posit Connect or Posit Connect 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." "\n\n" "FILE_OR_DIRECTORY is the path to a single-file Quarto document or the directory containing a Quarto project." ), @@ -2369,6 +2596,8 @@ def deploy_git( @server_args @spcs_args @content_args +@connect_cloud_args +@connect_cloud_account_arg @runtime_environment_args @click.option( "--exclude", @@ -2441,6 +2670,10 @@ def deploy_quarto( snowflake_connection_name: Optional[str], insecure: bool, cacert: Optional[str], + account: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, new: bool, app_id: Optional[str], title: Optional[str], @@ -2502,6 +2735,10 @@ def deploy_quarto( snowflake_connection_name=snowflake_connection_name, insecure=insecure, cacert=cacert, + account=account, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=file_or_directory, server=server, exclude=exclude, @@ -2510,6 +2747,7 @@ def deploy_quarto( title=title, disable_env_management=disable_env_management, env_vars=env_vars, + quarto_inputs=quarto_inputs_from_inspect(file_or_directory, inspect), ) # Prepare metadata for upload @@ -2662,14 +2900,18 @@ def deploy_tensorflow( # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="html", - short_help="Deploy html content to Posit Connect.", - help=("Deploy an html file, or directory of html files with entrypoint, to Posit Connect."), + short_help="Deploy html content to Posit Connect, Posit Connect Cloud, or shinyapps.io.", + help=( + "Deploy an html file, or directory of html files with entrypoint, to Posit Connect, " + "Posit Connect Cloud, or shinyapps.io." + ), no_args_is_help=True, ) @server_args @spcs_args @content_args @cloud_shinyapps_args +@connect_cloud_args @click.option( "--entrypoint", "-e", @@ -2715,6 +2957,9 @@ def deploy_html( account: Optional[str], token: Optional[str], secret: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, no_verify: bool, draft: bool, connect_server: Optional[api.RSConnectServer] = None, @@ -2750,6 +2995,9 @@ def deploy_html( account=account, token=token, secret=secret, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=path, server=server, exclude=exclude, @@ -2820,23 +3068,29 @@ def generate_deploy_python( # Only surface a minimum Connect version indicator for recent (2024+) versions. version_note = " [v{version}+]".format(version=min_version) if min_version else "" + # Only advertise Connect Cloud for content types it accepts; FastAPI, + # Gradio, Panel, and plain APIs are rejected at validation time. + cloud_note = ", Posit Connect Cloud," if AppModes.supported_by_connect_cloud(app_mode) else "" + # noinspection SpellCheckingInspection @deploy.command( name=alias, - short_help="Deploy a {desc} to Posit Connect{version_note} or shinyapps.io.".format( + short_help="Deploy a {desc} to Posit Connect{version_note}{cloud_note} or shinyapps.io.".format( desc=desc, version_note=version_note, + cloud_note=cloud_note, ), help=( - "Deploy a {desc} module to Posit Connect or shinyapps.io (if supported by the platform). " + "Deploy a {desc} module to Posit Connect{cloud_note} or shinyapps.io (if supported by the platform). " 'The "directory" argument must refer to an existing directory that contains the application code.' - ).format(desc=desc), + ).format(desc=desc, cloud_note=cloud_note), no_args_is_help=True, ) @server_args @spcs_args @content_args @cloud_shinyapps_args + @connect_cloud_args @runtime_environment_args @click.option( "--entrypoint", @@ -2934,6 +3188,9 @@ def deploy_app( account: Optional[str], token: Optional[str], secret: Optional[str], + client_id: Optional[str], + client_secret: Optional[str], + connect_cloud: bool, no_verify: bool, draft: bool, package_installer: Optional[PackageInstaller], @@ -2969,6 +3226,9 @@ def deploy_app( account=account, token=token, secret=secret, + client_id=client_id, + client_secret=client_secret, + use_connect_cloud=connect_cloud, path=directory, server=server, exclude=exclude, diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index c3b67ec62..c54e1869b 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -39,12 +39,34 @@ if TYPE_CHECKING: from .api import RSConnectServer, SPCSConnectServer +from . import connect_cloud from .exception import RSConnectException from .log import logger from .models import AppMode, AppModes, ContentItemV1, TaskStatusResult, TaskStatusV1 T = TypeVar("T", bound=Mapping[str, object]) +# What a user may type for --server in place of shinyapps.io's API URL, mirroring +# connect_cloud.SERVER_NAME. Used by api.ShinyappsServer as well. +SHINYAPPS_SERVER_NAME = "shinyapps.io" +SHINYAPPS_API_URL = "https://api.shinyapps.io" + + +def resolve_server_alias(url: str) -> str: + """Translate a --server value into the URL a saved server is stored under. + + Connect Cloud and shinyapps.io both accept a short name in place of their API + URL, but `rsconnect add` stores the API URL, so a lookup by URL has to + translate first or it can never match. Any Connect Cloud URL variant that + is_connect_cloud_url accepts (host case, trailing slash) canonicalizes the + same way, or those variants could never find a saved credential either. + """ + if connect_cloud.is_connect_cloud_url(url): + return connect_cloud.resolve_url(url) + if url == SHINYAPPS_SERVER_NAME: + return SHINYAPPS_API_URL + return url + def config_dirname(platform: str = sys.platform, env: Mapping[str, str] = os.environ): """Get the user's configuration directory path for this platform.""" @@ -263,6 +285,16 @@ class ServerDataDict(TypedDict): oauth_access_token: NotRequired[str] oauth_refresh_token: NotRequired[str] oauth_token_expiry: NotRequired[float] + # Posit Connect Cloud. These names are prefixed on purpose: `account_name` + # already means the shinyapps.io account, and `oauth_*` already means the + # Connect OAuth login. Server type is inferred from which of these key sets + # is present (see ServerStore.set), so the names must not collide. + connect_cloud_account_name: NotRequired[str] + connect_cloud_account_id: NotRequired[str] + connect_cloud_client_id: NotRequired[str] + connect_cloud_client_secret: NotRequired[str] + connect_cloud_access_token: NotRequired[str] + connect_cloud_refresh_token: NotRequired[str] default: NotRequired[bool] @@ -288,6 +320,12 @@ def __init__( oauth_access_token: Optional[str] = None, oauth_refresh_token: Optional[str] = None, oauth_token_expiry: Optional[float] = None, + connect_cloud_account_name: Optional[str] = None, + connect_cloud_account_id: Optional[str] = None, + connect_cloud_client_id: Optional[str] = None, + connect_cloud_client_secret: Optional[str] = None, + connect_cloud_access_token: Optional[str] = None, + connect_cloud_refresh_token: Optional[str] = None, ): self.name = name self.url = url @@ -303,6 +341,12 @@ def __init__( self.oauth_access_token = oauth_access_token self.oauth_refresh_token = oauth_refresh_token self.oauth_token_expiry = oauth_token_expiry + self.connect_cloud_account_name = connect_cloud_account_name + self.connect_cloud_account_id = connect_cloud_account_id + self.connect_cloud_client_id = connect_cloud_client_id + self.connect_cloud_client_secret = connect_cloud_client_secret + self.connect_cloud_access_token = connect_cloud_access_token + self.connect_cloud_refresh_token = connect_cloud_refresh_token class ServerStore(DataStore[ServerDataDict]): @@ -314,7 +358,9 @@ class ServerStore(DataStore[ServerDataDict]): """ def __init__(self, base_dir: str = config_dirname()): - super(ServerStore, self).__init__(join(base_dir, "servers.json"), chmod=True) + # Zero-arg super() resolves via __class__ rather than the module global, + # so the class stays constructible when the name is patched out. + super().__init__(join(base_dir, "servers.json"), chmod=True) def get_by_name(self, name: str): """ @@ -324,13 +370,84 @@ def get_by_name(self, name: str): """ return self._get_by_key(name) - def get_by_url(self, url: str): + def get_by_url(self, url: str, account_name: Optional[str] = None): """ Get the server information for the given URL.. - :param url: the Connect URL of the server to get information for. - """ - return self._get_by_value_attr("url", url) + :param url: the Connect URL of the server to get information for. A short + name such as "connect.posit.cloud" is translated to the API URL entries are + stored under. + :param account_name: the Posit Connect Cloud account to select, for the case + where one URL covers several saved servers. + :raises RSConnectException: if several Posit Connect Cloud servers share the + URL and the account does not single one out. + """ + target = resolve_server_alias(url) + if connect_cloud.is_connect_cloud_url(target): + return self._get_connect_cloud_server(target, account_name) + return self._get_by_value_attr("url", target) + + def has_connect_cloud_account(self, url: Optional[str]) -> bool: + """Whether any Posit Connect Cloud credential is saved for this URL. + + Callers use this to decide whether the account has to be supplied on the + command line or can come from a saved server. + """ + if not url: + return False + return bool(self._connect_cloud_servers(resolve_server_alias(url))) + + def _connect_cloud_servers(self, url: str) -> list[ServerDataDict]: + """The saved Posit Connect Cloud servers for one API URL, by nickname.""" + return sorted( + ( + entry + for entry in self._data.values() + if entry.get("url") == url and entry.get("connect_cloud_account_name") + ), + key=lambda entry: entry.get("name") or "", + ) + + def _get_connect_cloud_server(self, url: str, account_name: Optional[str]): + """Pick one of the Posit Connect Cloud servers saved for an API URL. + + Every Connect Cloud entry records the same API URL, so unlike Posit Connect + the URL does not identify one server; the account does. With a single saved + login an explicit account still selects what to publish to rather than which + credential to use, since that login can publish to every account it has + access to. Anything more ambiguous is reported rather than guessed at. + """ + candidates = self._connect_cloud_servers(url) + if account_name: + matches = [e for e in candidates if e.get("connect_cloud_account_name") == account_name] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + # The entries may hold different credentials (interactive vs + # service account); picking one silently would decide which gets + # used and refreshed. + nicknames = ", ".join('"%s"' % e.get("name") for e in matches) + raise RSConnectException( + 'Several saved Posit Connect Cloud credentials are for account "%s": %s. ' + "Use -n/--name to pick one." % (account_name, nicknames) + ) + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + + saved = ", ".join( + '%s (nickname "%s")' % (entry.get("connect_cloud_account_name"), entry.get("name")) for entry in candidates + ) + if account_name: + raise RSConnectException( + 'No saved Posit Connect Cloud credential is for account "%s", and there are several to choose ' + "from: %s. Use -n/--name to choose one, or run `rsconnect add` for that account." + % (account_name, saved) + ) + raise RSConnectException( + "Several Posit Connect Cloud accounts are saved: %s. Use -A/--account or -n/--name to pick one." % saved + ) def get_all_servers(self): """ @@ -376,6 +493,12 @@ def set( oauth_access_token: Optional[str] = None, oauth_refresh_token: Optional[str] = None, oauth_token_expiry: Optional[float] = None, + connect_cloud_account_name: Optional[str] = None, + connect_cloud_account_id: Optional[str] = None, + connect_cloud_client_id: Optional[str] = None, + connect_cloud_client_secret: Optional[str] = None, + connect_cloud_access_token: Optional[str] = None, + connect_cloud_refresh_token: Optional[str] = None, set_as_default: bool = False, ): """ @@ -406,8 +529,30 @@ def set( "name": name, "url": url, } + # Server type is not stored explicitly; it is inferred from which set of + # credential keys is present, here and in RSConnectExecutor.setup_remote_server. + # Each branch must therefore key off a field unique to its target, so that + # exactly one branch can match. In particular Connect Cloud is tested by its + # own prefixed field rather than `account_name`, which shinyapps.io uses. if snowflake_connection_name: target_data = dict(snowflake_connection_name=snowflake_connection_name, api_key=api_key) + elif connect_cloud_account_name: + target_data = dict(connect_cloud_account_name=connect_cloud_account_name) + # Saved so a deploy can address the account directly. The name can be + # changed in Connect Cloud, and resolving it costs a paginated request. + if connect_cloud_account_id: + target_data["connect_cloud_account_id"] = connect_cloud_account_id + if connect_cloud_client_id: + target_data["connect_cloud_client_id"] = connect_cloud_client_id + if connect_cloud_client_secret: + target_data["connect_cloud_client_secret"] = connect_cloud_client_secret + # Tokens live in this file, which is written 0600. The system keyring is + # only used by `rsconnect login` for Connect, not here -- the same as + # shinyapps.io's token and secret, and as the R client's account DCF. + if connect_cloud_access_token: + target_data["connect_cloud_access_token"] = connect_cloud_access_token + if connect_cloud_refresh_token: + target_data["connect_cloud_refresh_token"] = connect_cloud_refresh_token elif api_key: target_data = dict(api_key=api_key, insecure=insecure, ca_cert=ca_data) elif oauth_client_id: @@ -440,9 +585,18 @@ def remove_by_url(self, url: str): """ Remove the server information for the given URL.. + Goes through the same lookup as reads, so a Connect Cloud URL shared by + several entries is rejected as ambiguous rather than removing an + arbitrary one. + :param url: the Connect URL of the server to remove. + :raises RSConnectException: if several Posit Connect Cloud entries share + the URL. """ - return self._remove_by_value_attr("name", "url", url) + entry = self.get_by_url(url) + if entry is None: + return False + return self._remove_by_key(entry["name"]) def update_oauth_tokens( self, @@ -466,7 +620,7 @@ def update_oauth_tokens( updated.pop("oauth_token_expiry", None) # type: ignore[misc] self._set(name, updated) - def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: + def resolve(self, name: Optional[str], url: Optional[str], account_name: Optional[str] = None) -> ServerData: """ This function will resolve the given inputs into a set of server information. It assumes that either `name` or `url` is provided. @@ -484,6 +638,8 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: :param name: the nickname to look for. :param url: the Connect server URL to look for. + :param account_name: the Posit Connect Cloud account to look for, which is + what identifies one of several servers sharing the Connect Cloud API URL. :return: the information needed to interact with the resolved server and whether it came from the store or the arguments. """ @@ -492,7 +648,7 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: if not entry: raise RSConnectException('The nickname, "%s", does not exist.' % name) elif url: - entry = self.get_by_url(url) + entry = self.get_by_url(url, account_name) else: entry = self.get_default() if entry is None and self.count() == 1: @@ -514,6 +670,12 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: oauth_access_token=entry.get("oauth_access_token"), oauth_refresh_token=entry.get("oauth_refresh_token"), oauth_token_expiry=entry.get("oauth_token_expiry"), + connect_cloud_account_name=entry.get("connect_cloud_account_name"), + connect_cloud_account_id=entry.get("connect_cloud_account_id"), + connect_cloud_client_id=entry.get("connect_cloud_client_id"), + connect_cloud_client_secret=entry.get("connect_cloud_client_secret"), + connect_cloud_access_token=entry.get("connect_cloud_access_token"), + connect_cloud_refresh_token=entry.get("connect_cloud_refresh_token"), ) else: return ServerData( diff --git a/rsconnect/models.py b/rsconnect/models.py index 3d7ba06f5..85be23868 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -172,6 +172,28 @@ class AppModes: "bokeh": BOKEH_APP, } + # Posit Connect Cloud content types. This is the API's full vocabulary -- + # eight values, enforced with a 422 -- so any app mode absent here cannot be + # deployed there at all. + # + # Connect Cloud derives the app mode itself from the content type and the + # primary file, so several modes deliberately collapse onto one type: the + # static and Shiny variants of Quarto and R Markdown, and R vs Python Shiny + # (told apart by whether the primary file ends in ``.R``). + _connect_cloud_content_types: dict[AppMode, str] = { + JUPYTER_NOTEBOOK: "jupyter", + BOKEH_APP: "bokeh", + DASH_APP: "dash", + SHINY: "shiny", + PYTHON_SHINY: "shiny", + STREAMLIT_APP: "streamlit", + STATIC_QUARTO: "quarto", + SHINY_QUARTO: "quarto", + RMD: "rmarkdown", + SHINY_RMD: "rmarkdown", + STATIC: "static", + } + # CLI alias vocabulary used by ``rsconnect deploy `` and # ``rsconnect quickstart ``. Many-to-one is allowed: ``api`` and # ``flask`` both resolve to ``PYTHON_API``. NB: ``shiny`` here means @@ -237,6 +259,18 @@ def get_by_extension(cls, extension: Optional[str], return_unknown: bool = False def get_by_cloud_name(cls, name: str) -> AppMode: return cls._cloud_to_connect_modes.get(name, cls.UNKNOWN) + @classmethod + def get_connect_cloud_content_type(cls, mode: AppMode) -> Optional[str]: + """The Posit Connect Cloud content type for an app mode, or None. + + None means Connect Cloud cannot host that kind of content. + """ + return cls._connect_cloud_content_types.get(mode) + + @classmethod + def supported_by_connect_cloud(cls, mode: AppMode) -> bool: + return mode in cls._connect_cloud_content_types + @classmethod def get_by_cli_alias(cls, alias: str) -> AppMode: """Resolve a CLI alias to its canonical :class:`AppMode`. diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index a2f44d979..bf03b8c7f 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -291,11 +291,20 @@ def login_with_device_code( metadata: dict[str, Any], insecure: bool = False, ca_data: Optional[str | bytes] = None, + scope: Optional[str] = None, + open_browser: bool = True, ) -> dict[str, Any]: """Perform OAuth Device Code flow. Displays a URL and user code for the user to enter in a browser, then polls for token completion. + + :param scope: OAuth scope to request. Connect does not require one; Posit + Connect Cloud requires "vivid". + :param open_browser: whether to also try opening the verification URL. Off + for `rsconnect login --use-device-code`, where asking for the device flow + usually means there is no usable browser. The URL and code are printed + either way. """ device_endpoint = str(metadata.get("device_authorization_endpoint", "")) if not device_endpoint: @@ -309,7 +318,10 @@ def login_with_device_code( base = f"{parsed.scheme}://{parsed.netloc}" path = parsed.path - body = urlencode({"client_id": client_id}).encode("utf-8") + params = {"client_id": client_id} + if scope: + params["scope"] = scope + body = urlencode(params).encode("utf-8") server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) with server: @@ -329,11 +341,17 @@ def login_with_device_code( verification_uri_complete = str(resp.get("verification_uri_complete", "")) or verification_uri + # Printed whether or not a browser opens: the user needs the code to confirm, + # and a browser may open somewhere they cannot see it. click.echo(f"\nOpen this URL in your browser:\n\n {verification_uri_complete}\n") click.echo(f"Enter the code: {user_code}\n") - click.echo("Waiting for authorization...") - return _poll_for_device_token(metadata, client_id, device_code, interval, expires_in, insecure, ca_data) + if open_browser and webbrowser.open(verification_uri_complete): + click.echo("Opened browser for authorization. Waiting...") + else: + click.echo("Waiting for authorization...") + + return _poll_for_device_token(metadata, client_id, device_code, interval, expires_in, insecure, ca_data, scope) def _poll_for_device_token( @@ -344,6 +362,7 @@ def _poll_for_device_token( expires_in: int, insecure: bool = False, ca_data: Optional[str | bytes] = None, + scope: Optional[str] = None, ) -> dict[str, Any]: """Poll the token endpoint for device code completion.""" token_endpoint = str(metadata["token_endpoint"]) @@ -357,13 +376,14 @@ def _poll_for_device_token( while time.time() < deadline: time.sleep(poll_interval) - body = urlencode( - { - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - "client_id": client_id, - "device_code": device_code, - } - ).encode("utf-8") + params = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": client_id, + "device_code": device_code, + } + if scope: + params["scope"] = scope + body = urlencode(params).encode("utf-8") server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) with server: @@ -417,39 +437,77 @@ def refresh_access_token( refresh_token: str, insecure: bool = False, ca_data: Optional[str | bytes] = None, + scope: Optional[str] = None, ) -> dict[str, Any]: """Refresh an OAuth access token using a refresh token. Returns the new token response dict. Raises InvalidClientError if the client_id has been deleted server-side. """ - token_endpoint = str(metadata["token_endpoint"]) + params = { + "grant_type": "refresh_token", + "client_id": client_id, + "refresh_token": refresh_token, + } + if scope: + params["scope"] = scope + + data = _post_token_request(str(metadata["token_endpoint"]), params, insecure, ca_data) + if "access_token" not in data: + raise RSConnectException("Token refresh returned an unexpected response.") + + return data + + +def request_client_credentials_token( + token_endpoint: str, + client_id: str, + client_secret: str, + scope: Optional[str] = None, + insecure: bool = False, + ca_data: Optional[str | bytes] = None, +) -> dict[str, Any]: + """Request an access token using the OAuth client credentials grant (RFC 6749 4.4). + + Used for non-interactive/CI authentication. Per RFC 6749 4.4.3 the response + is not expected to include a refresh token; callers should keep the client + credentials so a new access token can be minted when this one expires. + """ + params = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + if scope: + params["scope"] = scope + + data = _post_token_request(token_endpoint, params, insecure, ca_data) + if "access_token" not in data: + raise RSConnectException("Client credentials request returned an unexpected response.") + + return data + + +def _post_token_request( + token_endpoint: str, + params: dict[str, str], + insecure: bool = False, + ca_data: Optional[str | bytes] = None, +) -> dict[str, Any]: + """POST a form-encoded request to an OAuth token endpoint and return the JSON body.""" parsed = urlparse(token_endpoint) base = f"{parsed.scheme}://{parsed.netloc}" - path = parsed.path - - body = urlencode( - { - "grant_type": "refresh_token", - "client_id": client_id, - "refresh_token": refresh_token, - } - ).encode("utf-8") server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) with server: response = server.request( "POST", - path, - body=body, + parsed.path, + body=urlencode(params).encode("utf-8"), headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - data = _unwrap_json_response(response) - if "access_token" not in data: - raise RSConnectException("Token refresh returned an unexpected response.") - - return data + return _unwrap_json_response(response) _TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange" diff --git a/rsconnect/shiny_express.py b/rsconnect/shiny_express.py index 01ee3ab94..c4e0b3ffa 100644 --- a/rsconnect/shiny_express.py +++ b/rsconnect/shiny_express.py @@ -134,3 +134,12 @@ def escape_to_var_name(x: str) -> str: is_first = False return encoded + + +def unescape_from_var_name(x: str) -> str: + """ + Reverse escape_to_var_name: decode each __ sequence back to its + character. Literal underscores in the original string are themselves + escaped (to _5f_), so every underscore-delimited hex run is an escape. + """ + return re.sub(r"_([0-9a-fA-F]+?)_", lambda m: chr(int(m.group(1), 16)), x) diff --git a/rsconnect/validation.py b/rsconnect/validation.py index 12d03275b..bab2aabf7 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os from typing import Any, Optional import click +from rsconnect.connect_cloud import is_connect_cloud_url from rsconnect.exception import RSConnectException @@ -19,22 +21,105 @@ def get_parameter_source_name_from_ctx( return "" +def effective_connect_cloud_account(ctx: Optional[click.Context], account_name: Optional[str]) -> Optional[str]: + """The -A value to use when the target is Posit Connect Cloud. + + -A/--account is shared with shinyapps.io, whose environment variable is + SHINYAPPS_ACCOUNT; a value exported for shinyapps.io CI must not retarget a + Connect Cloud deploy. A typed -A (or one passed programmatically, where no + click context exists) applies as-is; otherwise the account comes from + CONNECT_CLOUD_ACCOUNT, or is left unset for a saved server to supply. + """ + if account_name and get_parameter_source_name_from_ctx("account", ctx) != "ENVIRONMENT": + return account_name + return os.environ.get("CONNECT_CLOUD_ACCOUNT") or None + + def _get_present_options( options: dict[str, Optional[Any]], ctx: Optional[click.Context], + ignore_sources: tuple[str, ...] = (), ) -> list[str]: + """The options that have a value, labelled with where each came from. + + :param ignore_sources: parameter sources to leave out, named as click's + ParameterSource members are ("ENVIRONMENT", "COMMANDLINE", ...). Only applies + when a context is available to ask; without one every value counts. + """ result: list[str] = [] for k, v in options.items(): if v: parts = k.split("--") if ctx and len(parts) == 2: sourceName = get_parameter_source_name_from_ctx(parts[1], ctx) + if sourceName in ignore_sources: + continue result.append(f"{k} (from {sourceName})") else: result.append(f"{k}") return result +def validate_connect_cloud_incompatible_options( + ctx: Optional[click.Context], + api_key: Optional[str], + insecure: bool, + cacert: Optional[str], + snowflake_connection_name: Optional[str], +): + """Reject options that have no meaning on Posit Connect Cloud. + + validate_connection_options performs the same checks, but only when Connect + Cloud is selected by flag or URL; a saved nickname or default server is only + identified as Connect Cloud after store resolution, so the executor calls + this afterwards. Environment-sourced values are ignored for the same reason + as elsewhere: a CONNECT_API_KEY exported for another target is just the + CI environment, not a request to use it here. + """ + present_connect_options = _get_present_options( + {"-k/--api-key": api_key, "-i/--insecure": insecure, "-c/--cacert": cacert}, + ctx, + ignore_sources=("ENVIRONMENT",), + ) + if present_connect_options: + raise RSConnectException( + f"Posit Connect options ({', '.join(present_connect_options)}) may not be passed \ +alongside Posit Connect Cloud. See command help for further details." + ) + present_spcs_options = _get_present_options( + {"--snowflake-connection-name": snowflake_connection_name}, ctx, ignore_sources=("ENVIRONMENT",) + ) + if present_spcs_options: + raise RSConnectException( + f"SPCS options ({', '.join(present_spcs_options)}) may not be passed \ +alongside Posit Connect Cloud. See command help for further details." + ) + + +def validate_connect_cloud_credential_options( + ctx: Optional[click.Context], + client_id: Optional[str], + client_secret: Optional[str], +): + """Reject typed Connect Cloud credentials when the target is not Connect Cloud. + + validate_connection_options calls this when the target is already decided by + the command line; the executor calls it again after a nickname or default + server resolves to a non-Cloud target, which validation cannot see. Only a + credential the user actually typed conflicts: an exported + CONNECT_CLOUD_CLIENT_ID/SECRET -- which is how CI is meant to supply them -- + must not block a deploy elsewhere. They are unused then. + """ + typed_connect_cloud_options = _get_present_options( + {"--client-id": client_id, "--client-secret": client_secret}, ctx, ignore_sources=("ENVIRONMENT",) + ) + if typed_connect_cloud_options: + raise RSConnectException( + f"Posit Connect Cloud options ({', '.join(typed_connect_cloud_options)}) require \ +--connect-cloud or -s/--server connect.posit.cloud. See command help for further details." + ) + + def validate_connection_options( ctx: Optional[click.Context], url: Optional[str], @@ -47,6 +132,10 @@ def validate_connection_options( name: Optional[str] = None, snowflake_connection_name: Optional[str] = None, has_default_server: bool = False, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + connect_cloud: bool = False, + has_saved_connect_cloud_account: bool = False, ): """ Validates provided Connect or shinyapps.io connection options and returns which target to use given the provided @@ -89,8 +178,21 @@ def validate_connection_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} spcs_options = {"--snowflake-connection-name": snowflake_connection_name} - options_mutually_exclusive_with_name = {"-s/--server": url, **shinyapps_options} - present_options_mutually_exclusive_with_name = _get_present_options(options_mutually_exclusive_with_name, ctx) + # --connect-cloud names a target just like -s/--server does, so combining it + # with a nickname is the same contradiction. Without this, `-n + # --connect-cloud` silently deployed to the named server and dropped the flag. + # `rsconnect add` is unaffected: there -n names the entry being created, and + # add does not pass it to this function. + # + # Typed shinyapps options contradict a nickname the same way, but + # environment-sourced ones (SHINYAPPS_ACCOUNT/TOKEN/SECRET exported for CI + # elsewhere) are just the environment and must not block a nickname deploy; + # the executor drops them before resolution so they cannot merge into the + # entry either. + options_mutually_exclusive_with_name = {"-s/--server": url, "--connect-cloud": connect_cloud} + present_options_mutually_exclusive_with_name = _get_present_options( + options_mutually_exclusive_with_name, ctx + ) + _get_present_options(shinyapps_options, ctx, ignore_sources=("ENVIRONMENT",)) if name and present_options_mutually_exclusive_with_name: name_source = get_parameter_source_name_from_ctx("name", ctx) @@ -99,9 +201,11 @@ def validate_connection_options( {', '.join(present_options_mutually_exclusive_with_name)}. See command help for further details." ) - if not name and not url and not any(shinyapps_options.values()) and not has_default_server: + # --connect-cloud names the target on its own, so it satisfies this the same + # way -s/--server does. + if not name and not url and not connect_cloud and not any(shinyapps_options.values()) and not has_default_server: raise RSConnectException( - "You must specify one of -n/--name OR -s/--server OR -T/--token, -S/--secret, \ + "You must specify one of -n/--name OR -s/--server OR --connect-cloud OR -T/--token, -S/--secret, \ either via command options or environment variables. See command help for further details." ) @@ -109,9 +213,93 @@ def validate_connection_options( present_shinyapps_options = _get_present_options(shinyapps_options, ctx) present_spcs_options = _get_present_options(spcs_options, ctx) - if present_connect_options and present_shinyapps_options: + connect_cloud_options = {"--client-id": client_id, "--client-secret": client_secret} + present_connect_cloud_options = _get_present_options(connect_cloud_options, ctx) + + if connect_cloud and url and not is_connect_cloud_url(url): + # A CONNECT_SERVER environment variable left over from another target + # should not override the flag, so a server URL is only a conflict when + # it was passed explicitly on the command line. + if get_parameter_source_name_from_ctx("server", ctx) != "ENVIRONMENT": + raise RSConnectException( + "--connect-cloud cannot be combined with -s/--server %s. " + "--connect-cloud already selects Posit Connect Cloud." % url + ) + + # Checked before the generic conflict rules below, so that a Connect Cloud + # mistake is reported in terms of Connect Cloud. -A/--account is shared with + # shinyapps.io, so those rules would otherwise blame the wrong target. + if connect_cloud or is_connect_cloud_url(url): + # A saved server already names an account, so the account is only required + # when there is nothing saved to take it from. `rsconnect add` leaves + # has_saved_connect_cloud_account false: it registers a named account. + if not account_name and not has_saved_connect_cloud_account: + raise RSConnectException( + "-A/--account is required for Posit Connect Cloud. \ +See command help for further details." + ) + # Same rule as validate_connect_cloud_incompatible_options: values that + # come from environment variables (a CONNECT_API_KEY or SHINYAPPS_TOKEN + # exported for another target) are ignored; only options passed + # explicitly on the command line are treated as conflicts. + typed_shinyapps_credentials = _get_present_options( + {"-T/--token": token, "-S/--secret": secret}, ctx, ignore_sources=("ENVIRONMENT",) + ) + if typed_shinyapps_credentials: + raise RSConnectException( + "-T/--token and -S/--secret are shinyapps.io options and may not be passed \ +alongside Posit Connect Cloud. See command help for further details." + ) + typed_connect_options = _get_present_options(connect_options, ctx, ignore_sources=("ENVIRONMENT",)) + if typed_connect_options: + raise RSConnectException( + f"Posit Connect options ({', '.join(typed_connect_options)}) may not be passed \ +alongside Posit Connect Cloud. See command help for further details." + ) + typed_spcs_options = _get_present_options(spcs_options, ctx, ignore_sources=("ENVIRONMENT",)) + if typed_spcs_options: + raise RSConnectException( + f"SPCS options ({', '.join(typed_spcs_options)}) may not be passed \ +alongside Posit Connect Cloud. See command help for further details." + ) + if len(present_connect_cloud_options) == 1: + raise RSConnectException( + "--client-id and --client-secret must be provided together for Posit Connect Cloud. \ +Omit both to log in interactively. See command help for further details." + ) + # -A/--account is shared with shinyapps.io, so return before the + # all-or-nothing check below, which would demand a token and secret. + return + + # A nickname, or the default server when no target is named, may yet resolve + # to Connect Cloud; only the store lookup can tell, so the executor re-checks + # after resolution (validate_connect_cloud_credential_options). An explicit + # non-Cloud --server or a shinyapps credential set is already decided. + if not name and not (has_default_server and not url): + validate_connect_cloud_credential_options(ctx, client_id, client_secret) + + # A lone -A alongside a default server cannot be judged yet: the default may + # resolve to Connect Cloud, where -A selects the account to publish to. The + # conflict and all-or-nothing rules below are deferred for this case; the + # executor re-raises the all-or-nothing error after resolution when the + # target turns out not to be Connect Cloud. A token or secret is unambiguous + # shinyapps intent, so those still fail fast here. + lone_account_with_default = bool( + account_name and not token and not secret and has_default_server and not name and not url + ) + + # In the deferred case only *typed* Connect options conflict: an exported + # CONNECT_API_KEY or CONNECT_CA_CERTIFICATE is just the environment, and the + # default may not even be a Connect server. Cloud targets re-check typed + # options after resolution (validate_connect_cloud_incompatible_options). + connect_conflicts = ( + _get_present_options(connect_options, ctx, ignore_sources=("ENVIRONMENT",)) + if lone_account_with_default + else present_connect_options + ) + if connect_conflicts and present_shinyapps_options: raise RSConnectException( - f"Connect options ({', '.join(present_connect_options)}) may not be passed \ + f"Connect options ({', '.join(connect_conflicts)}) may not be passed \ alongside shinyapps.io options ({', '.join(present_shinyapps_options)}). \ See command help for further details." ) @@ -130,7 +318,7 @@ def validate_connection_options( ) if present_shinyapps_options: - if len(present_shinyapps_options) != len(shinyapps_options): + if len(present_shinyapps_options) != len(shinyapps_options) and not lone_account_with_default: raise RSConnectException( "-A/--account, -T/--token, and -S/--secret must all be provided \ for shinyapps.io. See command help for further details." diff --git a/tests/test_actions.py b/tests/test_actions.py index 91212b6c4..170680389 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -4,7 +4,7 @@ import pytest -from rsconnect.actions import _verify_server, cli_feedback, set_verbosity +from rsconnect.actions import _verify_server, cli_feedback, quarto_inputs_from_inspect, set_verbosity from rsconnect.api import RSConnectServer from rsconnect.exception import RSConnectException from rsconnect.log import console_logger, logger, warn_user @@ -71,3 +71,24 @@ def test_set_verbosity_resets_quiet_state(quiet_mode): set_verbosity(0) assert not logger.quiet assert console_logger.level == logging.DEBUG + + +def test_quarto_inputs_from_inspect_relativizes_in_render_order(tmp_path): + inspect = { + "quarto": {"version": "1.4.0"}, + "engines": ["markdown"], + "files": { + "input": [ + str(tmp_path / "zebra.qmd"), + str(tmp_path / "docs" / "about.qmd"), + ], + }, + } + assert quarto_inputs_from_inspect(str(tmp_path), inspect) == ["zebra.qmd", "docs/about.qmd"] + + +def test_quarto_inputs_from_inspect_is_empty_for_a_standalone_document(tmp_path): + doc = tmp_path / "report.qmd" + doc.write_text("# hi") + inspect = {"quarto": {"version": "1.4.0"}, "engines": ["markdown"]} + assert quarto_inputs_from_inspect(str(doc), inspect) == [] diff --git a/tests/test_api.py b/tests/test_api.py index 15a0fa52a..97cbed9d6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -472,6 +472,7 @@ def test_exchange_token_success(self, mock_fmt_payload, mock_token_endpoint, moc mock_server_instance = mock_http_server.return_value mock_response = Mock() mock_response.status = 200 + mock_response.exception = None mock_response.response_body = "token_data" mock_server_instance.request.return_value = mock_response @@ -532,6 +533,7 @@ def test_exchange_token_empty_response(self, mock_fmt_payload, mock_token_endpoi mock_server_instance = mock_http_server.return_value mock_response = Mock() mock_response.status = 200 + mock_response.exception = None mock_response.response_body = None mock_server_instance.request.return_value = mock_response diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 737c7f2b2..0d0d7759d 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -1,7 +1,9 @@ +from pathlib import Path from tempfile import NamedTemporaryFile -from unittest import TestCase +from unittest import TestCase, mock from rsconnect.certificates import read_certificate_file +from rsconnect.exception import RSConnectException class ParseCertificateFileTestCase(TestCase): @@ -30,10 +32,29 @@ def test_parse_certificate_file_pem(self): self.assertTrue(res) def test_parse_certificate_file_csr(self): - with self.assertRaises(RuntimeError): + with self.assertRaises(RSConnectException) as context: read_certificate_file("tests/testdata/certificates/localhost.csr") + self.assertIn("not recognized", str(context.exception)) def test_parse_certificate_file_invalid(self): with NamedTemporaryFile() as tmpfile: - with self.assertRaises(RuntimeError): + with self.assertRaises(RSConnectException) as context: read_certificate_file(tmpfile.name) + self.assertIn("not recognized", str(context.exception)) + + def test_parse_certificate_file_missing(self): + # A path that does not exist reports unreadability, not its suffix: + # the file type of a missing file is beside the point. + with self.assertRaises(RSConnectException) as context: + read_certificate_file("/nonexistent/ca") + self.assertIn("could not be read", str(context.exception)) + + def test_parse_certificate_file_unreadable_metadata(self): + # is_file() itself raises OSError when the path's metadata cannot be + # read, e.g. through a permission-denied directory; that too must + # report as an operational error. + with mock.patch.object(Path, "is_file", side_effect=PermissionError(13, "Permission denied")): + with self.assertRaises(RSConnectException) as context: + read_certificate_file("tests/testdata/certificates/localhost.pem") + self.assertIn("could not be read", str(context.exception)) + self.assertIn("Permission denied", str(context.exception)) diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py new file mode 100644 index 000000000..b2f459ea9 --- /dev/null +++ b/tests/test_connect_cloud.py @@ -0,0 +1,2817 @@ +import contextlib +import io +import json +import os +import tarfile +import tempfile +import unittest +from typing import Any, Dict, Optional +from unittest import mock + +import click +import httpretty +from click.core import ParameterSource +from click.testing import CliRunner + +from rsconnect import api, connect_cloud +from rsconnect.api import ( + ConnectCloudClient, + ConnectCloudServer, + ConnectCloudService, + RSConnectExecutor, +) +from rsconnect.exception import DeploymentFailedException, RSConnectException +from rsconnect.log import VERBOSE +from rsconnect.main import cli +from rsconnect.metadata import AppStore, ServerData, ServerStore +from rsconnect.models import AppModes +from rsconnect.validation import validate_connection_options + +ENV = ParameterSource.ENVIRONMENT +TYPED = ParameterSource.COMMANDLINE + + +class TestConnectCloudEnvironments(unittest.TestCase): + def test_default_environment_is_production(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(connect_cloud.environment_name(), "production") + self.assertEqual(connect_cloud.urls().api, "https://api.connect.posit.cloud/v1") + self.assertEqual(connect_cloud.urls().ui, "https://connect.posit.cloud") + self.assertEqual(connect_cloud.urls().auth, "https://login.posit.cloud") + self.assertEqual(connect_cloud.urls().logs, "https://logs.connect.posit.cloud") + + def test_environment_selected_by_env_var(self): + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "staging"}, clear=True): + self.assertEqual(connect_cloud.environment_name(), "staging") + self.assertEqual(connect_cloud.urls().api, "https://api.staging.connect.posit.cloud/v1") + + def test_development_shares_staging_auth_host(self): + # Not a copy/paste slip: development has no auth service of its own. + self.assertEqual( + connect_cloud.urls("development").auth, + connect_cloud.urls("staging").auth, + ) + + def test_unknown_environment_is_rejected(self): + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "nope"}, clear=True): + with self.assertRaises(RSConnectException) as context: + connect_cloud.environment_name() + message = str(context.exception) + self.assertIn("nope", message) + self.assertIn("production", message) + + def test_every_environment_is_fully_populated(self): + for name in ("production", "staging", "development"): + urls = connect_cloud.urls(name) + for field in urls._fields: + value = getattr(urls, field) + self.assertTrue(value.startswith("https://"), f"{name}.{field} = {value!r}") + + def test_client_id_per_environment(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(connect_cloud.client_id("production"), "rsconnect-python") + self.assertEqual(connect_cloud.client_id("staging"), "rsconnect-python-staging") + + def test_oauth_client_id_env_var_overrides(self): + with mock.patch.dict(os.environ, {connect_cloud.OAUTH_CLIENT_ID_ENV_VAR: "custom-client"}, clear=True): + self.assertEqual(connect_cloud.client_id("production"), "custom-client") + + def test_oauth_client_id_var_is_distinct_from_service_account_var(self): + # CONNECT_CLOUD_CLIENT_ID is a user's service account credential, passed + # with --client-id. It must not change which OAuth client the CLI is. + with mock.patch.dict(os.environ, {"CONNECT_CLOUD_CLIENT_ID": "service-account"}, clear=True): + self.assertEqual(connect_cloud.client_id("production"), "rsconnect-python") + + +class TestConnectCloudUrls(unittest.TestCase): + def test_oauth_metadata_shape(self): + metadata = connect_cloud.urls("production").oauth_metadata() + self.assertEqual( + metadata, + { + "device_authorization_endpoint": "https://login.posit.cloud/oauth/device/authorize", + "token_endpoint": "https://login.posit.cloud/oauth/token", + }, + ) + + def test_content_url(self): + url = connect_cloud.urls("production").content_url("acme-analytics", "8f3c1e2a") + self.assertEqual(url, "https://connect.posit.cloud/acme-analytics/content/8f3c1e2a") + + def test_is_connect_cloud_url(self): + self.assertTrue(connect_cloud.is_connect_cloud_url("connect.posit.cloud")) + self.assertTrue(connect_cloud.is_connect_cloud_url("https://api.connect.posit.cloud/v1")) + self.assertTrue(connect_cloud.is_connect_cloud_url("https://api.staging.connect.posit.cloud/v1")) + self.assertFalse(connect_cloud.is_connect_cloud_url("https://connect.example.com")) + self.assertFalse(connect_cloud.is_connect_cloud_url(None)) + + def test_is_connect_cloud_url_tolerates_trailing_slash_and_case(self): + self.assertTrue(connect_cloud.is_connect_cloud_url("https://api.connect.posit.cloud/v1/")) + self.assertTrue(connect_cloud.is_connect_cloud_url("HTTPS://API.CONNECT.POSIT.CLOUD/v1")) + self.assertTrue(connect_cloud.is_connect_cloud_url("connect.posit.cloud/")) + self.assertTrue(connect_cloud.is_connect_cloud_url("Connect.Posit.Cloud")) + # The path itself is still case-sensitive and must match exactly. + self.assertFalse(connect_cloud.is_connect_cloud_url("https://api.connect.posit.cloud/V1")) + self.assertFalse(connect_cloud.is_connect_cloud_url("https://api.connect.posit.cloud/v1/extra")) + self.assertFalse(connect_cloud.is_connect_cloud_url("https://api.connect.posit.cloud/v1?x=1")) + + def test_resolve_url_canonicalizes_recognized_variants(self): + # A trailing-slash variant must be stored as the exact API URL, or the + # saved server would not map back to its environment. + self.assertEqual( + connect_cloud.resolve_url("https://api.staging.connect.posit.cloud/v1/"), + "https://api.staging.connect.posit.cloud/v1", + ) + self.assertEqual(connect_cloud.resolve_url("connect.posit.cloud/"), "https://api.connect.posit.cloud/v1") + self.assertEqual(connect_cloud.resolve_url("https://connect.example.com/"), "https://connect.example.com/") + + def test_is_connect_cloud_url_matches_exactly_not_by_substring(self): + # The removed Posit Cloud support matched "posit.cloud" anywhere in the + # URL, which would also match a lookalike host. + self.assertFalse(connect_cloud.is_connect_cloud_url("https://connect.posit.cloud.example.com")) + self.assertFalse(connect_cloud.is_connect_cloud_url("https://evil-connect.posit.cloud.attacker.test")) + self.assertFalse(connect_cloud.is_connect_cloud_url("connect.posit.cloud.example.com")) + + def test_resolve_url(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(connect_cloud.resolve_url("connect.posit.cloud"), "https://api.connect.posit.cloud/v1") + self.assertEqual(connect_cloud.resolve_url(None), "https://api.connect.posit.cloud/v1") + # An explicit URL is passed through unchanged. + self.assertEqual( + connect_cloud.resolve_url("https://api.dev.connect.posit.cloud/v1"), + "https://api.dev.connect.posit.cloud/v1", + ) + + def test_resolve_url_follows_selected_environment(self): + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "staging"}, clear=True): + self.assertEqual( + connect_cloud.resolve_url("connect.posit.cloud"), + "https://api.staging.connect.posit.cloud/v1", + ) + + +class TestConnectCloudAuth(unittest.TestCase): + def test_device_login_requests_vivid_scope(self): + with mock.patch("rsconnect.connect_cloud.login_with_device_code") as login: + login.return_value = {"access_token": "at", "refresh_token": "rt"} + result = connect_cloud.login_interactive("production") + + self.assertEqual(result, {"access_token": "at", "refresh_token": "rt"}) + kwargs = login.call_args.kwargs + self.assertEqual(kwargs["scope"], "vivid") + self.assertEqual(kwargs["client_id"], "rsconnect-python") + self.assertEqual( + kwargs["metadata"]["device_authorization_endpoint"], + "https://login.posit.cloud/oauth/device/authorize", + ) + + def test_client_credentials_login_requests_vivid_scope(self): + with mock.patch("rsconnect.connect_cloud.request_client_credentials_token") as request: + request.return_value = {"access_token": "at"} + result = connect_cloud.login_client_credentials("cid", "csecret", "production") + + self.assertEqual(result, {"access_token": "at"}) + kwargs = request.call_args.kwargs + self.assertEqual(kwargs["scope"], "vivid") + self.assertEqual(kwargs["client_id"], "cid") + self.assertEqual(kwargs["client_secret"], "csecret") + self.assertEqual(kwargs["token_endpoint"], "https://login.posit.cloud/oauth/token") + + def test_device_login_opens_a_browser_by_default(self): + # Matches `rsconnect login` for Connect, and the browser we already open + # after a successful deploy. + with mock.patch("rsconnect.connect_cloud.login_with_device_code") as login: + login.return_value = {"access_token": "at"} + connect_cloud.login_interactive("production") + self.assertNotIn("open_browser", login.call_args.kwargs) + + def test_refresh_requests_vivid_scope(self): + with mock.patch("rsconnect.connect_cloud.refresh_access_token") as refresh: + refresh.return_value = {"access_token": "new"} + connect_cloud.refresh("rt", "production") + + kwargs = refresh.call_args.kwargs + self.assertEqual(kwargs["scope"], "vivid") + self.assertEqual(kwargs["refresh_token"], "rt") + + +class TestConnectCloudServer(unittest.TestCase): + def test_defaults_url_to_selected_environment(self): + with mock.patch.dict(os.environ, {}, clear=True): + server = ConnectCloudServer("acme", access_token="at") + self.assertEqual(server.url, "https://api.connect.posit.cloud/v1") + self.assertEqual(server.remote_name, "Posit Connect Cloud") + self.assertEqual(server.account_name, "acme") + self.assertEqual(server.access_token, "at") + self.assertIsNone(server.client_secret) + + def test_explicit_url_wins(self): + server = ConnectCloudServer("acme", access_token="at", url="https://api.dev.connect.posit.cloud/v1") + self.assertEqual(server.url, "https://api.dev.connect.posit.cloud/v1") + + def test_normalizes_pseudo_server_name(self): + # Mirrors ShinyappsServer accepting "shinyapps.io". + with mock.patch.dict(os.environ, {}, clear=True): + server = ConnectCloudServer("acme", url="connect.posit.cloud") + self.assertEqual(server.url, "https://api.connect.posit.cloud/v1") + + def test_carries_client_credentials(self): + server = ConnectCloudServer("acme", client_id="cid", client_secret="csecret") + self.assertEqual(server.client_id, "cid") + self.assertEqual(server.client_secret, "csecret") + + def test_is_not_a_posit_server(self): + # ShinyappsServer/PositServer means HMAC token+secret auth. Connect Cloud + # uses OAuth, and the isinstance dispatch in RSConnectExecutor relies on + # these being unrelated. + from rsconnect.api import PositServer + + self.assertNotIsInstance(ConnectCloudServer("acme"), PositServer) + + +class TestConnectCloudServerStore(unittest.TestCase): + def setUp(self): + self.store = ServerStore(base_dir=tempfile.mkdtemp()) + + def test_stores_prefixed_fields(self): + self.store.set( + "cloud", + "https://api.connect.posit.cloud/v1", + connect_cloud_account_name="acme", + connect_cloud_client_id="cid", + connect_cloud_client_secret="csecret", + ) + self.assertEqual( + self.store.get_by_name("cloud"), + { + "name": "cloud", + "url": "https://api.connect.posit.cloud/v1", + "connect_cloud_account_name": "acme", + "connect_cloud_client_id": "cid", + "connect_cloud_client_secret": "csecret", + }, + ) + + def test_omits_absent_optional_fields(self): + self.store.set( + "cloud", + "https://api.connect.posit.cloud/v1", + connect_cloud_account_name="acme", + ) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertNotIn("connect_cloud_client_secret", entry) + self.assertNotIn("connect_cloud_access_token", entry) + + def test_not_confused_with_shinyapps(self): + # The shinyapps.io branch keys off `account_name`; Connect Cloud must not + # fall into it, which is why its field is prefixed. + self.store.set( + "cloud", + "https://api.connect.posit.cloud/v1", + connect_cloud_account_name="acme", + connect_cloud_access_token="at", + ) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertNotIn("account_name", entry) + self.assertNotIn("token", entry) + self.assertNotIn("secret", entry) + + def test_shinyapps_entry_is_unaffected(self): + self.store.set( + "sa", + "https://api.shinyapps.io", + account_name="me", + token="tok", + secret="sec", + ) + self.assertEqual( + self.store.get_by_name("sa"), + { + "name": "sa", + "url": "https://api.shinyapps.io", + "account_name": "me", + "token": "tok", + "secret": "sec", + }, + ) + + def test_resolve_round_trips_connect_cloud_fields(self): + self.store.set( + "cloud", + "https://api.connect.posit.cloud/v1", + connect_cloud_account_name="acme", + connect_cloud_client_id="cid", + connect_cloud_client_secret="csecret", + connect_cloud_access_token="at", + connect_cloud_refresh_token="rt", + ) + data = self.store.resolve("cloud", None) + self.assertTrue(data.from_store) + self.assertEqual(data.connect_cloud_account_name, "acme") + self.assertEqual(data.connect_cloud_client_id, "cid") + self.assertEqual(data.connect_cloud_client_secret, "csecret") + self.assertEqual(data.connect_cloud_access_token, "at") + self.assertEqual(data.connect_cloud_refresh_token, "rt") + + def test_resolve_leaves_connect_cloud_fields_unset_for_other_targets(self): + self.store.set("prod", "https://connect.example.com", api_key="key") + data = self.store.resolve("prod", None) + self.assertIsNone(data.connect_cloud_account_name) + self.assertIsNone(data.connect_cloud_access_token) + + +API = "https://api.connect.posit.cloud/v1" + + +def _json_body(request): + return json.loads(request.body.decode("utf-8")) + + +def _ctx(**sources: ParameterSource) -> click.Context: + """A click context recording where each named parameter's value came from.""" + ctx = click.Context(click.Command("deploy")) + for param, source in sources.items(): + ctx.set_parameter_source(param, source) # pyright: ignore[reportAttributeAccessIssue] + return ctx + + +def _json_response(payload: Any, status: int = 200) -> Any: + return httpretty.Response( + body=json.dumps(payload), adding_headers={"Content-Type": "application/json"}, status=status + ) + + +def _register_json(method: Any, url: str, payload: Any, status: int = 200) -> None: + httpretty.register_uri( + method, url, body=json.dumps(payload), adding_headers={"Content-Type": "application/json"}, status=status + ) + + +def _register_pages(url: str, *pages: Any) -> None: + httpretty.register_uri(httpretty.GET, url, responses=[_json_response(p) for p in pages]) + + +def _register_accounts(*accounts: Any) -> None: + _register_json(httpretty.GET, f"{API}/accounts", {"data": list(accounts), "total": len(accounts)}) + + +def _cloud_entry(**fields: Any) -> ServerData: + """A resolved store entry for a saved Connect Cloud server named "cloud".""" + fields.setdefault("connect_cloud_account_name", "acme") + return ServerData("cloud", API, True, **fields) + + +def _store_with_cloud_entry(**fields: Any) -> ServerStore: + """A temp-dir ServerStore holding one saved Connect Cloud server named "cloud".""" + store = ServerStore(base_dir=tempfile.mkdtemp()) + fields.setdefault("connect_cloud_account_name", "acme") + fields.setdefault("connect_cloud_access_token", "at") + store.set("cloud", API, **fields) + return store + + +def _setup_remote_server( + ctx: Optional[click.Context] = None, + store: Optional[ServerStore] = None, + resolve: Optional[ServerData] = None, + default: Optional[ServerData] = None, + has_cloud_account: Optional[bool] = None, + environ: Optional[Dict[str, str]] = None, + **kwargs: Any, +) -> RSConnectExecutor: + """Run setup_remote_server on a bare executor with the store interactions mocked. + + `store` replaces the ServerStore the executor opens; `resolve` short-circuits + the lookup with a prepared entry; `default` additionally makes that entry the + default server. `environ` replaces os.environ for the call. + """ + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.logger = None + executor.ctx = None + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(api.RSConnectExecutor, "setup_client")) + if store is not None: + stack.enter_context(mock.patch("rsconnect.api.ServerStore", return_value=store)) + if default is not None: + resolve = default + stack.enter_context(mock.patch.object(ServerStore, "get_default", return_value={"name": default.name})) + if resolve is not None: + stack.enter_context(mock.patch.object(ServerStore, "resolve", return_value=resolve)) + if has_cloud_account is not None: + stack.enter_context( + mock.patch.object(ServerStore, "has_connect_cloud_account", return_value=has_cloud_account) + ) + if environ is not None: + stack.enter_context(mock.patch.dict(os.environ, environ, clear=True)) + executor.setup_remote_server(ctx=ctx, **kwargs) + return executor + + +def _cloud_server(**kwargs: Any) -> ConnectCloudServer: + """Like _setup_remote_server, but returns the resulting ConnectCloudServer.""" + server = _setup_remote_server(**kwargs).remote_server + assert isinstance(server, ConnectCloudServer) + return server + + +def _validate_options(ctx: Optional[click.Context] = None, **overrides: Any) -> None: + """validate_connection_options with --connect-cloud set and everything else absent.""" + options: Dict[str, Any] = dict( + url=None, + api_key=None, + insecure=False, + cacert=None, + account_name=None, + token=None, + secret=None, + connect_cloud=True, + ) + options.update(overrides) + return validate_connection_options(ctx=ctx, **options) + + +def _skip_account_check(test): + """Stop `add` verifying the account name against GET /accounts. + + Used by tests concerned with storage and CLI wiring rather than with the + verification itself, which has its own tests. + """ + patch = mock.patch.object( + ConnectCloudClient, + "get_account_by_name", + return_value={"id": "acct-1", "name": "acme"}, + ) + patch.start() + test.addCleanup(patch.stop) + + +class CliTestCase(unittest.TestCase): + """A CliRunner against a temporary server store and a clean environment.""" + + skip_account_check = True + + def setUp(self): + self.runner = CliRunner() + # Commands write through the module-level store, so point it at a temp dir. + self.store = ServerStore(base_dir=tempfile.mkdtemp()) + store_patch = mock.patch("rsconnect.main.server_store", self.store) + store_patch.start() + self.addCleanup(store_patch.stop) + env_patch = mock.patch.dict(os.environ, {}, clear=True) + env_patch.start() + self.addCleanup(env_patch.stop) + if self.skip_account_check: + _skip_account_check(self) + + def _mock_device_login(self): + patch = mock.patch( + "rsconnect.connect_cloud.login_with_device_code", + return_value={"access_token": "at", "refresh_token": "rt"}, + ) + login = patch.start() + self.addCleanup(patch.stop) + return login + + +class TestConnectCloudClient(unittest.TestCase): + def setUp(self): + self.server = ConnectCloudServer("acme", access_token="at", refresh_token="rt") + self.client = ConnectCloudClient(self.server) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_sends_bearer_token(self): + _register_json(httpretty.GET, f"{API}/users/me", {"id": "u1"}) + with self.client: + self.client.get_current_user() + self.assertEqual(httpretty.last_request().headers["Authorization"], "Bearer at") + + def test_a_connection_failure_is_reported_not_crashed(self): + # A failed connection produces an HTTPResponse holding only the exception; + # the 401-refresh check must pass it through to handle_bad_response, which + # raises the "could not connect" error, rather than crash on a missing status. + from rsconnect.http_support import HTTPResponse, HTTPServer + + failure = HTTPResponse(f"{API}/users/me", exception=OSError("connection refused")) + self.assertIsNone(failure.status) + with mock.patch.object(HTTPServer, "request", return_value=failure): + with self.assertRaises(RSConnectException) as context: + self.client.get_current_user() + self.assertIn("Could not connect", str(context.exception)) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_accounts_follows_pagination(self): + _register_pages( + f"{API}/accounts", + {"data": [{"id": "1", "name": "a"}, {"id": "2", "name": "b"}], "total": 3}, + {"data": [{"id": "3", "name": "c"}], "total": 3}, + ) + with self.client: + accounts = self.client.get_accounts() + + self.assertEqual([a["name"] for a in accounts], ["a", "b", "c"]) + first, second = httpretty.latest_requests()[-2:] + self.assertEqual(first.querystring["offset"], ["0"]) + self.assertEqual(first.querystring["limit"], ["100"]) + self.assertEqual(first.querystring["has_user_role"], ["true"]) + self.assertEqual(second.querystring["offset"], ["2"]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_accounts_pages_when_total_is_omitted(self): + # `total` is optional in the response. Treating a missing one as the end of the + # list returned only the first page, and callers then reported accounts that + # exist as missing. + _register_pages( + f"{API}/accounts", + {"data": [{"id": "1", "name": "a"}, {"id": "2", "name": "b"}]}, + {"data": [{"id": "3", "name": "c"}]}, + {"data": []}, + ) + with self.client: + accounts = self.client.get_accounts() + + self.assertEqual([a["name"] for a in accounts], ["a", "b", "c"]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_account_by_name_finds_a_later_page_without_a_total(self): + _register_pages( + f"{API}/accounts", + {"data": [{"id": "1", "name": "a", "permissions": ["content:create"]}]}, + {"data": [{"id": "2", "name": "wanted", "permissions": ["content:create"]}]}, + {"data": []}, + ) + with self.client: + self.assertEqual(self.client.get_account_by_name("wanted")["id"], "2") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_accounts_stops_on_empty_page(self): + _register_json(httpretty.GET, f"{API}/accounts", {"data": [], "total": 7}) + with self.client: + self.assertEqual(self.client.get_accounts(), []) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_account_by_name_reports_missing_account(self): + _register_accounts({"id": "1", "name": "other"}) + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_account_by_name("acme") + self.assertIn("acme", str(context.exception)) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deleted_content_is_surfaced_as_404(self): + _register_json(httpretty.GET, f"{API}/contents/c1", {"id": "c1", "state": "deleted"}) + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_content("c1") + self.assertEqual(context.exception.status, 404) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_create_content_payload(self): + _register_json(httpretty.POST, f"{API}/contents", {"id": "c1", "next_revision": {"id": "r1"}}) + with self.client: + content = self.client.create_content( + account_id="acct-1", + title="My App", + content_type="shiny", + app_mode="python-shiny", + primary_file="app.py", + secrets=[{"name": "FOO", "value": "bar"}], + ) + + self.assertEqual(content["id"], "c1") + self.assertEqual( + _json_body(httpretty.last_request()), + { + "account_id": "acct-1", + "title": "My App", + "next_revision": { + "source_type": "bundle", + "content_type": "shiny", + "app_mode": "python-shiny", + "primary_file": "app.py", + }, + "secrets": [{"name": "FOO", "value": "bar"}], + }, + ) + + def _update_content(self, **kwargs): + """PATCH /contents/c1 with the always-required arguments; returns the request body.""" + _register_json(httpretty.PATCH, f"{API}/contents/c1", {"id": "c1", "next_revision": {"id": "r2"}}) + with self.client: + self.client.update_content( + "c1", primary_file="app.py", app_mode="python-shiny", content_type="shiny", **kwargs + ) + return _json_body(httpretty.last_request()) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_sends_primary_file_with_app_mode(self): + # The API only recomputes app_mode when content_type or primary_file is + # in the override set; sending app_mode alone would persist it verbatim. + body = self._update_content() + self.assertEqual( + body["revision_overrides"], + {"primary_file": "app.py", "app_mode": "python-shiny", "content_type": "shiny"}, + ) + self.assertEqual(httpretty.last_request().querystring["new_bundle"], ["true"]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_without_secrets_leaves_them_alone(self): + # The API replaces the whole secret set with whatever is sent, so a deploy + # without -E must omit the field entirely or it deletes existing secrets. + self.assertNotIn("secrets", self._update_content()) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_with_secrets_replaces_them(self): + body = self._update_content(secrets=[{"name": "FOO", "value": "bar"}]) + self.assertEqual(body["secrets"], [{"name": "FOO", "value": "bar"}]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_with_title_replaces_it(self): + self.assertEqual(self._update_content(title="New Title")["title"], "New Title") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_without_title_leaves_it_alone(self): + self.assertNotIn("title", self._update_content()) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_without_new_bundle(self): + self._update_content(new_bundle=False) + self.assertNotIn("new_bundle", httpretty.last_request().querystring) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_publish(self): + httpretty.register_uri(httpretty.POST, f"{API}/contents/c1/publish", body="", status=204) + with self.client: + self.client.publish("c1") + self.assertEqual(httpretty.last_request().path, "/v1/contents/c1/publish") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_revision(self): + _register_json(httpretty.GET, f"{API}/revisions/r1", {"id": "r1", "status": "building", "publish_result": None}) + with self.client: + revision = self.client.get_revision("r1") + self.assertEqual(revision["status"], "building") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_upload_bundle_posts_gzip_without_auth(self): + upload_url = "https://uploads.example.com/bundle?sig=abc" + httpretty.register_uri(httpretty.POST, "https://uploads.example.com/bundle", body="", status=200) + + self.client.upload_bundle(upload_url, b"tarball-bytes") + + request = httpretty.last_request() + self.assertEqual(request.method, "POST") + self.assertEqual(request.headers["Content-Type"], "application/gzip") + # The presigned URL carries its own credentials. + self.assertIsNone(request.headers.get("Authorization")) + self.assertEqual(request.querystring["sig"], ["abc"]) + self.assertEqual(request.body, b"tarball-bytes") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_upload_bundle_raises_on_failure(self): + httpretty.register_uri(httpretty.POST, "https://uploads.example.com/bundle", body="nope", status=403) + with self.assertRaises(RSConnectException): + self.client.upload_bundle("https://uploads.example.com/bundle", b"x") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_upload_failure_does_not_leak_the_sig_param(self): + httpretty.register_uri(httpretty.POST, "https://uploads.example.com/bundle", body="nope", status=403) + with self.assertRaises(RSConnectException) as context: + self.client.upload_bundle("https://uploads.example.com/bundle?sig=topsecret", b"x") + self.assertNotIn("topsecret", str(context.exception)) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_get_publish_logs_uses_scoped_channel_token(self): + _register_json(httpretty.POST, f"{API}/authorization", {"token": "channel-token"}) + _register_json( + httpretty.GET, + "https://logs.connect.posit.cloud/v1/logs/chan-1", + {"data": [{"timestamp": 1700000000000000, "level": "INFO", "message": "hi"}]}, + ) + + with self.client: + entries = self.client.get_publish_logs("chan-1") + + self.assertEqual([e["message"] for e in entries], ["hi"]) + auth_request, logs_request = httpretty.latest_requests()[-2:] + self.assertEqual( + _json_body(auth_request), + {"resource_type": "log_channel", "resource_id": "chan-1", "permission": "revision.logs:read"}, + ) + # The logs host takes the scoped channel token, not the account token. + self.assertEqual(logs_request.headers["Authorization"], "Bearer channel-token") + self.assertEqual(logs_request.querystring["traversal_direction"], ["backward"]) + + +class TestConnectCloudClientTokenRefresh(unittest.TestCase): + def _get_user_with_refresh(self, server, mock_target, token_response): + """Serve a 401 then a 200, driving a request through the refresh-and-retry + path with the named rsconnect.connect_cloud function mocked; returns the mock.""" + httpretty.register_uri( + httpretty.GET, + f"{API}/users/me", + responses=[httpretty.Response(body="", status=401), _json_response({"id": "u1"})], + ) + client = ConnectCloudClient(server) + with mock.patch(f"rsconnect.connect_cloud.{mock_target}") as refresher: + refresher.return_value = token_response + with client: + client.get_current_user() + return refresher + + def _save_entry(self, **fields): + """Persist a "cloud" entry and point the client's write-back store at it.""" + self._base_dir = tempfile.mkdtemp() + store = ServerStore(base_dir=self._base_dir) + fields.setdefault("connect_cloud_account_name", "acme") + store.set("cloud", API, **fields) + store.save() + # The client imports ServerStore inside the function, so patch it at its source. + patch = mock.patch("rsconnect.metadata.ServerStore", lambda: ServerStore(base_dir=self._base_dir)) + patch.start() + self.addCleanup(patch.stop) + + def _stored_entry(self): + entry = ServerStore(base_dir=self._base_dir).get_by_name("cloud") + assert entry is not None + return entry + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_401_refreshes_with_refresh_token_and_retries_once(self): + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt") + refresh = self._get_user_with_refresh(server, "refresh", {"access_token": "fresh", "refresh_token": "rt2"}) + + refresh.assert_called_once() + self.assertEqual(server.access_token, "fresh") + self.assertEqual(server.refresh_token, "rt2") + self.assertEqual(httpretty.last_request().headers["Authorization"], "Bearer fresh") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_401_uses_client_credentials_when_available(self): + server = ConnectCloudServer("acme", access_token="stale", client_id="cid", client_secret="csecret") + # RFC 6749 4.4.3: no refresh token comes back from a client-credentials grant. + login = self._get_user_with_refresh(server, "login_client_credentials", {"access_token": "fresh"}) + + # The environment comes from the server, not from the ambient env var. + login.assert_called_once_with("cid", "csecret", "production") + self.assertEqual(server.access_token, "fresh") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_401_retries_only_once(self): + httpretty.register_uri(httpretty.GET, f"{API}/users/me", body="", status=401) + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt") + client = ConnectCloudClient(server) + + with mock.patch("rsconnect.connect_cloud.refresh") as refresh: + refresh.return_value = {"access_token": "fresh"} + with client: + with self.assertRaises(RSConnectException): + client.get_current_user() + + refresh.assert_called_once() + self.assertEqual(len(httpretty.latest_requests()), 2) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_401_without_any_credential_does_not_retry(self): + httpretty.register_uri(httpretty.GET, f"{API}/users/me", body="", status=401) + client = ConnectCloudClient(ConnectCloudServer("acme", access_token="stale")) + + with client: + with self.assertRaises(RSConnectException): + client.get_current_user() + + self.assertEqual(len(httpretty.latest_requests()), 1) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_refreshed_token_is_written_back_to_the_store(self): + self._save_entry(connect_cloud_refresh_token="rt") + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="cloud") + self._get_user_with_refresh(server, "refresh", {"access_token": "fresh", "refresh_token": "rt2"}) + + entry = self._stored_entry() + self.assertEqual(entry["connect_cloud_access_token"], "fresh") + self.assertEqual(entry["connect_cloud_refresh_token"], "rt2") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_refresh_does_not_graft_environment_credentials_onto_the_entry(self): + # An interactively created entry has no client credentials. If the shell + # happens to export CONNECT_CLOUD_CLIENT_ID/SECRET, a refresh may *use* + # them, but persisting them would silently convert the entry into a + # service-account credential. + self._save_entry(connect_cloud_refresh_token="rt") + server = ConnectCloudServer( + "acme", + access_token="stale", + refresh_token="rt", + client_id="env-client-id", + client_secret="env-client-secret", + server_name="cloud", + ) + self._get_user_with_refresh(server, "login_client_credentials", {"access_token": "fresh"}) + + entry = self._stored_entry() + self.assertEqual(entry["connect_cloud_access_token"], "fresh") + self.assertNotIn("connect_cloud_client_id", entry) + self.assertNotIn("connect_cloud_client_secret", entry) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_refresh_does_not_repoint_the_entry_at_another_account(self): + # A deploy can publish to a different account on the same login, so the + # in-memory account is not necessarily the one the entry was saved for. + self._save_entry( + connect_cloud_account_name="alice", + connect_cloud_account_id="acct-alice", + connect_cloud_refresh_token="rt", + ) + server = ConnectCloudServer("team-x", access_token="stale", refresh_token="rt", server_name="cloud") + self._get_user_with_refresh(server, "refresh", {"access_token": "fresh", "refresh_token": "rt2"}) + + entry = self._stored_entry() + self.assertEqual(entry["connect_cloud_account_name"], "alice") + self.assertEqual(entry["connect_cloud_account_id"], "acct-alice") + self.assertEqual(entry["connect_cloud_access_token"], "fresh") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_refresh_does_not_persist_client_credentials_from_the_environment(self): + # --client-id/--client-secret default from the environment and beat the store, + # so the in-memory credential is not necessarily the saved one either. + self._save_entry(connect_cloud_client_id="saved-cid", connect_cloud_client_secret="saved-secret") + server = ConnectCloudServer( + "acme", + access_token="stale", + client_id="env-cid", + client_secret="env-secret", + server_name="cloud", + ) + self._get_user_with_refresh(server, "login_client_credentials", {"access_token": "fresh"}) + + entry = self._stored_entry() + self.assertEqual(entry["connect_cloud_client_id"], "saved-cid") + self.assertEqual(entry["connect_cloud_client_secret"], "saved-secret") + self.assertEqual(entry["connect_cloud_access_token"], "fresh") + + +class TestConnectCloudAdd(CliTestCase): + """CLI-level tests for `rsconnect add -s connect.posit.cloud`.""" + + def test_add_interactive_stores_tokens(self): + self._mock_device_login() + result = self.runner.invoke( + cli, ["add", "--name", "cloud", "--server", "connect.posit.cloud", "--account", "acme"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + self.store.get_by_name("cloud"), + { + "name": "cloud", + "url": "https://api.connect.posit.cloud/v1", + "connect_cloud_account_name": "acme", + "connect_cloud_account_id": "acct-1", + "connect_cloud_access_token": "at", + "connect_cloud_refresh_token": "rt", + }, + ) + self.assertIn("Posit Connect Cloud", result.output) + + def test_a_stale_ca_certificate_env_var_does_not_block_add(self): + # CONNECT_CA_CERTIFICATE pointing at a missing file used to fail at CLI + # parsing (click Path(exists=True)), before the Cloud target was known. + self._mock_device_login() + with mock.patch.dict(os.environ, {"CONNECT_CA_CERTIFICATE": "/nonexistent/ca.pem"}): + result = self.runner.invoke(cli, ["add", "-n", "cloud", "--connect-cloud", "-A", "acme"]) + + self.assertEqual(result.exit_code, 0, result.output) + + def test_add_explicit_environment_url_pins_the_environment(self): + # An explicitly typed staging API URL must authenticate against staging's + # auth host even when CONNECT_CLOUD_ENVIRONMENT is unset; previously the + # URL was replaced with the pseudo-name and the ambient environment won. + login = self._mock_device_login() + result = self.runner.invoke( + cli, + ["add", "-n", "cc", "-s", "https://api.staging.connect.posit.cloud/v1", "-A", "acme"], + ) + + self.assertEqual(result.exit_code, 0, result.output) + kwargs = login.call_args.kwargs + self.assertEqual(kwargs["url"], "https://login.staging.posit.cloud") + self.assertEqual(kwargs["client_id"], "rsconnect-python-staging") + self.assertEqual(self.store.get_by_name("cc")["url"], "https://api.staging.connect.posit.cloud/v1") + + def test_add_flag_discards_a_stray_connect_server(self): + # A CONNECT_SERVER pointing at some other target must not be stored as + # the Connect Cloud URL when --connect-cloud selects the target. + self._mock_device_login() + result = self.runner.invoke( + cli, + ["add", "-n", "cc", "--connect-cloud", "-A", "acme"], + env={"CONNECT_SERVER": "https://connect.example.com"}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(self.store.get_by_name("cc")["url"], "https://api.connect.posit.cloud/v1") + + def test_add_flag_discards_an_environment_sourced_cloud_url(self): + # --connect-cloud beats CONNECT_SERVER even when that variable holds a + # Connect Cloud URL for another environment; only a *typed* -s pins one. + login = self._mock_device_login() + result = self.runner.invoke( + cli, + ["add", "-n", "cc", "--connect-cloud", "-A", "acme"], + env={"CONNECT_SERVER": "https://api.staging.connect.posit.cloud/v1"}, + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(self.store.get_by_name("cc")["url"], "https://api.connect.posit.cloud/v1") + self.assertEqual(login.call_args.kwargs["url"], "https://login.posit.cloud") + + def test_add_client_credentials_stores_credentials(self): + with mock.patch("rsconnect.connect_cloud.request_client_credentials_token") as request: + request.return_value = {"access_token": "at"} + result = self.runner.invoke( + cli, + [ + "add", + "--name", + "cloud", + "--server", + "connect.posit.cloud", + "--account", + "acme", + "--client-id", + "cid", + "--client-secret", + "csecret", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertEqual(entry["connect_cloud_client_id"], "cid") + self.assertEqual(entry["connect_cloud_client_secret"], "csecret") + self.assertEqual(entry["connect_cloud_access_token"], "at") + # RFC 6749 4.4.3: no refresh token is issued for client credentials. + self.assertNotIn("connect_cloud_refresh_token", entry) + + def test_add_requires_account(self): + result = self.runner.invoke(cli, ["add", "--name", "cloud", "--server", "connect.posit.cloud"]) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("-A/--account is required for Posit Connect Cloud", result.output) + + def test_add_rejects_shinyapps_credentials(self): + result = self.runner.invoke( + cli, + [ + "add", + "--name", + "cloud", + "--server", + "connect.posit.cloud", + "--account", + "acme", + "--token", + "tok", + "--secret", + "sec", + ], + ) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("shinyapps.io options", result.output) + + def test_add_rejects_connect_api_key(self): + result = self.runner.invoke( + cli, + ["add", "--name", "cloud", "--server", "connect.posit.cloud", "--account", "acme", "--api-key", "key"], + ) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("may not be passed", result.output) + + def test_add_rejects_half_a_service_account_credential(self): + result = self.runner.invoke( + cli, + [ + "add", + "--name", + "cloud", + "--server", + "connect.posit.cloud", + "--account", + "acme", + "--client-id", + "cid", + ], + ) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("--client-id and --client-secret must be provided together", result.output) + + def test_client_credentials_rejected_without_connect_cloud_server(self): + result = self.runner.invoke( + cli, + [ + "add", + "--name", + "other", + "--server", + "https://connect.example.com", + "--api-key", + "key", + "--client-id", + "cid", + "--client-secret", + "csecret", + ], + ) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("require --connect-cloud or -s/--server connect.posit.cloud", result.output) + + def test_exported_client_credentials_do_not_block_another_target(self): + # CI is told to export these, and they are unused for a non-cloud target, so + # they must not conflict with an explicitly named server. Only a typed + # credential does (see the test above). + with mock.patch.dict( + os.environ, + {"CONNECT_CLOUD_CLIENT_ID": "cid", "CONNECT_CLOUD_CLIENT_SECRET": "csecret"}, + ): + with mock.patch("rsconnect.main.test_server") as test_server: + test_server.return_value = ( + api.RSConnectServer("https://connect.example.com", "key"), + None, + ) + with mock.patch("rsconnect.main.test_api_key", return_value="user"): + result = self.runner.invoke( + cli, + ["add", "--name", "other", "--server", "https://connect.example.com", "--api-key", "key"], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertNotIn("require --connect-cloud", result.output) + + def test_add_honors_selected_environment(self): + self._mock_device_login() + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "staging"}): + result = self.runner.invoke( + cli, ["add", "--name", "cloud", "--server", "connect.posit.cloud", "--account", "acme"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertEqual(entry["url"], "https://api.staging.connect.posit.cloud/v1") + + +class TestConnectCloudEnvironmentIsPinnedToTheServer(unittest.TestCase): + """A saved server keeps its own environment, whatever the env var says later.""" + + def test_environment_for_url(self): + self.assertEqual(connect_cloud.environment_for_url("https://api.connect.posit.cloud/v1"), "production") + self.assertEqual(connect_cloud.environment_for_url("https://api.staging.connect.posit.cloud/v1"), "staging") + self.assertEqual(connect_cloud.environment_for_url("https://api.dev.connect.posit.cloud/v1"), "development") + # A URL saved before resolve_url canonicalized still maps to its environment. + self.assertEqual(connect_cloud.environment_for_url("https://api.staging.connect.posit.cloud/v1/"), "staging") + + def test_unknown_url_falls_back_to_the_selected_environment(self): + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "staging"}, clear=True): + self.assertEqual(connect_cloud.environment_for_url("https://example.com"), "staging") + + def test_server_records_its_environment(self): + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "staging"}, clear=True): + server = ConnectCloudServer("acme", url=connect_cloud.SERVER_NAME) + self.assertEqual(server.environment, "staging") + self.assertEqual(server.url, "https://api.staging.connect.posit.cloud/v1") + + def test_saved_staging_server_ignores_a_later_production_env_var(self): + # The bug this guards: a staging server whose content URLs, logs host and + # token refresh all silently pointed at production. + server = ConnectCloudServer("acme", url="https://api.staging.connect.posit.cloud/v1") + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "production"}): + self.assertEqual(server.environment, "staging") + self.assertEqual(server.urls().ui, "https://staging.connect.posit.cloud") + self.assertEqual(server.urls().auth, "https://login.staging.posit.cloud") + self.assertEqual(server.urls().logs, "https://logs.staging.connect.posit.cloud") + + def test_content_url_uses_the_servers_environment(self): + server = ConnectCloudServer("acme", url="https://api.staging.connect.posit.cloud/v1") + client = mock.Mock(spec=ConnectCloudClient) + client.get_accounts.return_value = [{"id": "acct-1", "name": "acme"}] + service = ConnectCloudService(client, server) + + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "production"}): + url = service.content_url("c1", "acct-1") + + self.assertEqual(url, "https://staging.connect.posit.cloud/acme/content/c1") + + def test_executor_keeps_an_explicit_environment_url_with_the_flag(self): + # --connect-cloud plus a typed staging API URL must stay on staging, not + # be replaced by the pseudo-name and follow CONNECT_CLOUD_ENVIRONMENT. + server = _cloud_server( + environ={}, + url="https://api.staging.connect.posit.cloud/v1", + account_name="acme", + use_connect_cloud=True, + ) + self.assertEqual(server.url, "https://api.staging.connect.posit.cloud/v1") + self.assertEqual(server.environment, "staging") + + def test_executor_flag_overrides_an_environment_sourced_cloud_url(self): + # A staging URL arriving via CONNECT_SERVER must not survive the flag; + # only a typed -s does (previous test). + server = _cloud_server( + ctx=_ctx(server=ENV), + environ={}, + url="https://api.staging.connect.posit.cloud/v1", + account_name="acme", + use_connect_cloud=True, + ) + self.assertEqual(server.url, "https://api.connect.posit.cloud/v1") + self.assertEqual(server.environment, "production") + + def test_typed_connect_options_are_rejected_for_a_saved_cloud_nickname(self): + # -n resolves to a Connect Cloud entry only after the store lookup, so + # incompatible options must be re-checked then, not silently dropped. + entry = _cloud_entry(connect_cloud_access_token="at") + with self.assertRaises(RSConnectException) as context: + _setup_remote_server(resolve=entry, name="cloud", api_key="typed-key") + self.assertIn("may not be passed alongside Posit Connect Cloud", str(context.exception)) + + def test_env_sourced_connect_options_are_ignored_for_a_saved_cloud_nickname(self): + # A CONNECT_API_KEY exported for another target is just the environment. + entry = _cloud_entry(connect_cloud_access_token="at") + _cloud_server(ctx=_ctx(api_key=ENV), resolve=entry, name="cloud", api_key="env-key") + + def test_a_half_supplied_credential_never_mixes_with_the_entrys_pair(self): + # CONNECT_CLOUD_CLIENT_ID exported without its secret must not combine + # with the entry's stored secret into a pair that never existed; the + # stored pair wins and the lone half is ignored. + entry = _cloud_entry( + connect_cloud_client_id="stored-id", + connect_cloud_client_secret="stored-secret", + connect_cloud_access_token="saved-at", + connect_cloud_refresh_token="saved-rt", + ) + server = _cloud_server(ctx=_ctx(client_id=ENV), resolve=entry, name="cloud", client_id="env-id-alone") + + self.assertEqual(server.client_id, "stored-id") + self.assertEqual(server.client_secret, "stored-secret") + self.assertEqual(server.access_token, "saved-at") + self.assertEqual(server.server_name, "cloud") + + def test_supplied_credentials_that_differ_from_the_entry_are_a_new_identity(self): + # Explicit --client-id/--client-secret that differ from the saved entry's + # must not ride on the entry's tokens (they belong to whoever created the + # entry) and must not write back to it afterwards. + entry = _cloud_entry(connect_cloud_access_token="saved-at", connect_cloud_refresh_token="saved-rt") + # Typed --client-id with -n is rejected by validation, so credentials + # alongside a nickname can only arrive from the environment - model that. + server = _cloud_server( + ctx=_ctx(client_id=ENV, client_secret=ENV), + resolve=entry, + name="cloud", + client_id="other-id", + client_secret="other-secret", + ) + + self.assertIsNone(server.access_token) + self.assertIsNone(server.refresh_token) + self.assertIsNone(server.server_name) + self.assertEqual(server.client_id, "other-id") + + def test_matching_credentials_keep_the_entrys_tokens(self): + entry = _cloud_entry( + connect_cloud_client_id="same-id", + connect_cloud_client_secret="same-secret", + connect_cloud_access_token="saved-at", + connect_cloud_refresh_token="saved-rt", + ) + server = _cloud_server( + ctx=_ctx(client_id=ENV, client_secret=ENV), + resolve=entry, + name="cloud", + client_id="same-id", + client_secret="same-secret", + ) + + self.assertEqual(server.access_token, "saved-at") + self.assertEqual(server.server_name, "cloud") + + def test_refresh_uses_the_servers_environment(self): + server = ConnectCloudServer( + "acme", access_token="stale", refresh_token="rt", url="https://api.staging.connect.posit.cloud/v1" + ) + client = ConnectCloudClient(server) + with mock.patch("rsconnect.connect_cloud.refresh") as refresh: + refresh.return_value = {"access_token": "fresh"} + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "production"}): + client._attempt_token_refresh() + refresh.assert_called_once_with("rt", "staging") + + +class TestEnvironmentSourcedAccountIsScopedToItsTarget(unittest.TestCase): + """-A/--account is shared with shinyapps.io, whose environment variable is + SHINYAPPS_ACCOUNT. A value exported for shinyapps.io CI must not retarget a + Connect Cloud deploy; Connect Cloud's own variable is CONNECT_CLOUD_ACCOUNT.""" + + def _server(self, ctx, account_name, environ, saved=True): + return _cloud_server( + ctx=ctx, + resolve=_cloud_entry(connect_cloud_access_token="at"), + has_cloud_account=saved, + environ=environ, + url="connect.posit.cloud", + account_name=account_name, + ) + + def test_env_sourced_shinyapps_account_does_not_retarget_a_cloud_deploy(self): + server = self._server(_ctx(account=ENV), "shinyapps-acct", {"SHINYAPPS_ACCOUNT": "shinyapps-acct"}) + self.assertEqual(server.account_name, "acme") + + def test_connect_cloud_account_env_var_selects_the_account(self): + server = self._server(_ctx(account=ENV), "shinyapps-acct", {"CONNECT_CLOUD_ACCOUNT": "cloud-acct"}) + self.assertEqual(server.account_name, "cloud-acct") + + def test_a_typed_account_still_wins(self): + server = self._server(_ctx(account=TYPED), "typed-acct", {"CONNECT_CLOUD_ACCOUNT": "cloud-acct"}) + self.assertEqual(server.account_name, "typed-acct") + + def test_shinyapps_account_alone_does_not_satisfy_the_account_requirement(self): + with self.assertRaises(RSConnectException) as context: + self._server(_ctx(account=ENV), "shinyapps-acct", {"SHINYAPPS_ACCOUNT": "shinyapps-acct"}, saved=False) + self.assertIn("-A/--account is required", str(context.exception)) + + def test_env_shinyapps_credentials_do_not_block_or_merge_into_a_nickname_deploy(self): + # SHINYAPPS_* exported for CI elsewhere used to make any -n deploy fail + # with a name/option conflict; and once allowed through, the values must + # not merge into the resolved entry (an env account would retarget a + # Connect Cloud nickname). + server = _cloud_server( + ctx=_ctx(account=ENV, token=ENV, secret=ENV), + resolve=_cloud_entry(connect_cloud_access_token="at"), + environ={ + "SHINYAPPS_ACCOUNT": "shinyapps-acct", + "SHINYAPPS_TOKEN": "shinyapps-token", + "SHINYAPPS_SECRET": "shinyapps-secret", + }, + name="cloud", + account_name="shinyapps-acct", + token="shinyapps-token", + secret="shinyapps-secret", + ) + self.assertEqual(server.account_name, "acme") + + def test_a_typed_shinyapps_option_still_conflicts_with_a_nickname(self): + with self.assertRaises(RSConnectException) as context: + _setup_remote_server(ctx=_ctx(account=TYPED), name="cloud", account_name="typed-acct") + self.assertIn("cannot be specified in conjunction", str(context.exception)) + + +class TestDefaultServerAccountDeferral(unittest.TestCase): + """With a default server, -A cannot be judged until the default resolves: + it selects the Connect Cloud account when the default is Cloud, and is the + incomplete shinyapps.io credential set otherwise.""" + + _connect_entry = ServerData( + "production", + "https://connect.example.com", + True, + api_key="stored-key", + ) + _shinyapps_entry = ServerData( + "shiny", + "https://api.shinyapps.io", + True, + account_name="stored-acct", + token="stored-token", + secret="stored-secret", + ) + + def _default_deploy(self, entry, ctx=None, environ=None, **kwargs): + return _setup_remote_server(ctx=ctx, default=entry, environ=environ or {}, **kwargs) + + def test_a_typed_account_selects_the_cloud_account_on_the_default_login(self): + executor = self._default_deploy(_cloud_entry(connect_cloud_access_token="at"), account_name="other-acct") + assert isinstance(executor.remote_server, ConnectCloudServer) + self.assertEqual(executor.remote_server.account_name, "other-acct") + + def test_connect_cloud_account_env_var_applies_to_the_default_server(self): + executor = self._default_deploy( + _cloud_entry(connect_cloud_access_token="at"), environ={"CONNECT_CLOUD_ACCOUNT": "env-acct"} + ) + assert isinstance(executor.remote_server, ConnectCloudServer) + self.assertEqual(executor.remote_server.account_name, "env-acct") + + def test_env_shinyapps_account_does_not_retarget_the_default_cloud_server(self): + executor = self._default_deploy( + _cloud_entry(connect_cloud_access_token="at"), + ctx=_ctx(account=ENV), + environ={"SHINYAPPS_ACCOUNT": "shinyapps-acct"}, + account_name="shinyapps-acct", + ) + assert isinstance(executor.remote_server, ConnectCloudServer) + self.assertEqual(executor.remote_server.account_name, "acme") + + def test_a_lone_account_is_still_rejected_when_the_default_is_connect(self): + with self.assertRaises(RSConnectException) as context: + self._default_deploy(self._connect_entry, account_name="some-acct") + self.assertIn("must all be provided", str(context.exception)) + + def test_a_lone_account_cannot_borrow_a_default_shinyapps_entrys_credentials(self): + # The deferred all-or-nothing check judges what the user supplied; the + # default entry's token and secret must not combine with a typed -A to + # deploy a different account with borrowed credentials. + with self.assertRaises(RSConnectException) as context: + self._default_deploy(self._shinyapps_entry, account_name="other-acct") + self.assertIn("must all be provided", str(context.exception)) + + def test_an_env_api_key_does_not_conflict_with_a_typed_account(self): + # CONNECT_API_KEY exported for a Connect server elsewhere is just the + # environment; with a default Cloud server, -A selects the account. + executor = self._default_deploy( + _cloud_entry(connect_cloud_access_token="at"), + ctx=_ctx(api_key=ENV), + account_name="other-acct", + api_key="env-key", + ) + assert isinstance(executor.remote_server, ConnectCloudServer) + self.assertEqual(executor.remote_server.account_name, "other-acct") + + def test_an_exported_ca_certificate_does_not_fail_a_cloud_deploy(self): + # CONNECT_CA_CERTIFICATE exported for a Connect server is only read for + # non-Cloud targets, so an unreadable path cannot block a Cloud deploy. + executor = self._default_deploy( + _cloud_entry(connect_cloud_access_token="at"), ctx=_ctx(cacert=ENV), cacert="/nonexistent/ca.pem" + ) + assert isinstance(executor.remote_server, ConnectCloudServer) + + def test_the_certificate_is_still_read_for_a_connect_deploy(self): + with mock.patch.object(api, "read_certificate_file", return_value=b"cert-bytes") as read: + executor = self._default_deploy(self._connect_entry, cacert="/etc/ca.pem", api_key="typed-key") + read.assert_called_once_with("/etc/ca.pem") + assert isinstance(executor.remote_server, api.RSConnectServer) + self.assertEqual(executor.remote_server.ca_data, b"cert-bytes") + + +class TestConnectCloudAccountVerification(unittest.TestCase): + def setUp(self): + self.client = ConnectCloudClient(ConnectCloudServer("acme", access_token="at")) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_unknown_account_lists_the_available_ones(self): + _register_accounts({"id": "1", "name": "alpha"}, {"id": "2", "name": "beta"}) + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_account_by_name("typo") + + message = str(context.exception) + self.assertIn('No Posit Connect Cloud account named "typo"', message) + self.assertIn("alpha, beta", message) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_no_accounts_at_all(self): + _register_accounts() + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_account_by_name("anything") + self.assertIn("do not have publish access to any", str(context.exception)) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_view_only_account_is_rejected(self): + # Without this the failure surfaces later as a raw error from POST /contents. + _register_accounts({"id": "1", "name": "acme", "permissions": ["content:read"]}) + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_account_by_name("acme") + + message = str(context.exception) + self.assertIn('You have access to the Posit Connect Cloud account "acme"', message) + self.assertIn("do not have permission to publish to it", message) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_publishable_account_resolves(self): + _register_accounts({"id": "1", "name": "acme", "permissions": ["content:read", "content:create"]}) + with self.client: + self.assertEqual(self.client.get_account_by_name("acme")["id"], "1") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_an_account_without_a_permissions_field_resolves(self): + # The field is optional, and the server enforces the permission regardless, + # so its absence must not deny a publish the server would accept. + _register_accounts({"id": "1", "name": "acme"}) + with self.client: + self.assertEqual(self.client.get_account_by_name("acme")["id"], "1") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_the_suggestions_exclude_accounts_that_cannot_publish(self): + # The list is effectively the set of valid -A values. + _register_accounts( + {"id": "1", "name": "publishable", "permissions": ["content:create"]}, + {"id": "2", "name": "view-only", "permissions": ["content:read"]}, + {"id": "3", "name": "unknown-permissions"}, + ) + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_account_by_name("typo") + + message = str(context.exception) + self.assertIn("You can publish to: publishable, unknown-permissions.", message) + self.assertNotIn("view-only", message) + + +class TestConnectCloudAddVerifiesAccount(CliTestCase): + skip_account_check = False + + def _add(self, account): + with mock.patch("rsconnect.connect_cloud.request_client_credentials_token") as request: + request.return_value = {"access_token": "at"} + return self.runner.invoke( + cli, + [ + "add", + "--name", + "cloud", + "--connect-cloud", + "--account", + account, + "--client-id", + "cid", + "--client-secret", + "csecret", + ], + ) + + def test_a_bad_account_name_fails_at_add_time(self): + # A token proves the credentials are good but says nothing about the + # account, so a typo used to surface only at deploy time. + with mock.patch.object( + ConnectCloudClient, + "get_accounts", + return_value=[{"id": "1", "name": "real-account"}], + ): + result = self._add("typo-account") + + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("real-account", result.output) + self.assertIsNone(self.store.get_by_name("cloud"), "nothing should be stored on failure") + + def test_a_good_account_name_is_stored(self): + with mock.patch.object( + ConnectCloudClient, + "get_accounts", + return_value=[{"id": "1", "name": "acme"}], + ): + result = self._add("acme") + + self.assertEqual(result.exit_code, 0, result.output) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertEqual(entry["connect_cloud_account_name"], "acme") + + def test_a_view_only_account_fails_at_add_time(self): + with mock.patch.object( + ConnectCloudClient, + "get_accounts", + return_value=[{"id": "1", "name": "acme", "permissions": ["content:read"]}], + ): + result = self._add("acme") + + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("do not have permission to publish", result.output) + self.assertIsNone(self.store.get_by_name("cloud"), "nothing should be stored on failure") + + +class TestConnectCloudAppModes(unittest.TestCase): + def test_supported_modes_map_to_content_types(self): + cases = { + AppModes.JUPYTER_NOTEBOOK: "jupyter", + AppModes.PYTHON_SHINY: "shiny", + AppModes.SHINY: "shiny", + AppModes.STREAMLIT_APP: "streamlit", + AppModes.DASH_APP: "dash", + AppModes.BOKEH_APP: "bokeh", + AppModes.STATIC_QUARTO: "quarto", + AppModes.SHINY_QUARTO: "quarto", + AppModes.RMD: "rmarkdown", + AppModes.SHINY_RMD: "rmarkdown", + AppModes.STATIC: "static", + } + for mode, expected in cases.items(): + self.assertEqual(AppModes.get_connect_cloud_content_type(mode), expected, mode.name()) + self.assertTrue(AppModes.supported_by_connect_cloud(mode), mode.name()) + + def test_unsupported_modes(self): + # Flask/FastAPI/Plumber were removed from the backend's content type enum; + # Gradio, Panel, Voila, TensorFlow and Node.js were never there. + for mode in ( + AppModes.PYTHON_API, + AppModes.PYTHON_FASTAPI, + AppModes.PLUMBER, + AppModes.PYTHON_GRADIO, + AppModes.PYTHON_PANEL, + AppModes.JUPYTER_VOILA, + AppModes.TENSORFLOW, + AppModes.NODE_JS, + ): + self.assertIsNone(AppModes.get_connect_cloud_content_type(mode), mode.name()) + self.assertFalse(AppModes.supported_by_connect_cloud(mode), mode.name()) + + def test_content_types_are_the_eight_the_api_accepts(self): + self.assertEqual( + sorted(set(AppModes._connect_cloud_content_types.values())), + ["bokeh", "dash", "jupyter", "quarto", "rmarkdown", "shiny", "static", "streamlit"], + ) + + +def _bundle_with_manifest(metadata, files=None, manifest_path="manifest.json"): + """A minimal gzipped bundle containing just a manifest.json.""" + body = {"version": 1, "metadata": metadata} + if files is not None: + body["files"] = {name: {"checksum": "0"} for name in files} + manifest = json.dumps(body).encode("utf-8") + buffer = io.BytesIO() + with tarfile.open(mode="w:gz", fileobj=buffer) as tar: + info = tarfile.TarInfo(manifest_path) + info.size = len(manifest) + tar.addfile(info, io.BytesIO(manifest)) + buffer.seek(0) + return buffer + + +class TestConnectCloudPrimaryFile(unittest.TestCase): + def _executor(self, bundle, path="/deploys/some-project", quarto_inputs=None): + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.bundle = bundle + executor.path = path + executor.quarto_inputs = quarto_inputs + return executor + + def test_reads_entrypoint_from_manifest(self): + executor = self._executor(_bundle_with_manifest({"appmode": "python-shiny", "entrypoint": "app.py"})) + self.assertEqual(executor.primary_file_for_connect_cloud(), "app.py") + + def test_module_entrypoint_resolves_to_the_file(self): + # Python app manifests carry "module" or "module:object" (bundle.validate_entry_point), + # but Connect Cloud fails the publish unless it is given the file itself. + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-streamlit", "entrypoint": "app1"}, + files=["app1.py", "requirements.txt", "data.csv"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "app1.py") + + def test_module_object_entrypoint_resolves_to_the_file(self): + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-api", "entrypoint": "app:app"}, + files=["app.py", "requirements.txt"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "app.py") + + def test_dotted_module_entrypoint_resolves_to_the_nested_file(self): + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-dash", "entrypoint": "src.app"}, + files=["src/app.py", "requirements.txt"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "src/app.py") + + def test_entrypoint_that_is_a_listed_file_is_used_as_is(self): + executor = self._executor( + _bundle_with_manifest( + {"appmode": "jupyter-static", "entrypoint": "notebook.ipynb"}, + files=["notebook.ipynb", "requirements.txt"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "notebook.ipynb") + + def test_r_shiny_manifest_without_entrypoint_infers_app_r(self): + # R manifests record no entrypoint for Shiny content; Connect infers the + # conventional file names, so the fallback must too. + executor = self._executor(_bundle_with_manifest({"appmode": "shiny"}, files=["app.R", "data.csv"])) + self.assertEqual(executor.primary_file_for_connect_cloud(), "app.R") + + def test_r_shiny_manifest_without_entrypoint_infers_server_r(self): + executor = self._executor(_bundle_with_manifest({"appmode": "shiny"}, files=["server.R", "ui.R"])) + self.assertEqual(executor.primary_file_for_connect_cloud(), "server.R") + + def test_shiny_express_entrypoint_resolves_to_the_source_file(self): + # Express manifests wrap the file in a synthetic module entrypoint with + # the file name escaped to a variable name (app.py -> app_2e_py). + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-shiny", "entrypoint": "shiny.express.app:app_2e_py"}, + files=["app.py", "requirements.txt"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "app.py") + + def test_shiny_express_entrypoint_with_escaped_underscores(self): + from rsconnect.shiny_express import escape_to_var_name + + entrypoint = "shiny.express.app:" + escape_to_var_name("my_app.py") + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-shiny", "entrypoint": entrypoint}, + files=["my_app.py"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "my_app.py") + + def test_quarto_manifest_without_entrypoint_prefers_index_qmd(self): + # Generated Quarto manifests record no entrypoint at all. + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["about.qmd", "index.qmd", "_quarto.yml"]) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "index.qmd") + + def test_quarto_manifest_with_a_single_document_uses_it(self): + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-shiny"}, files=["report.qmd", "requirements.txt"]) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "report.qmd") + + def test_quarto_standalone_deploy_uses_its_own_file(self): + # A single-document deploy knows its file from the deploy path, whatever + # the format — Quarto renders .ipynb and .Rmd too, not just .qmd. + executor = self._executor( + _bundle_with_manifest( + {"appmode": "quarto-static"}, + files=["analysis.ipynb", "helper.qmd", "requirements.txt"], + ), + path="/deploys/project/analysis.ipynb", + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "analysis.ipynb") + + def test_quarto_project_with_index_ipynb(self): + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["index.ipynb", "requirements.txt"]) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "index.ipynb") + + def test_quarto_manifest_with_a_single_rmd_input(self): + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["report.Rmd", "README.md"]) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "report.Rmd") + + def test_quarto_project_with_a_sole_markdown_input(self): + # Quarto renders plain .md; a README does not count as the document. + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["notes.md", "README.md", "_quarto.yml"]) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "notes.md") + + def test_quarto_project_with_only_a_readme_is_still_ambiguous(self): + executor = self._executor(_bundle_with_manifest({"appmode": "quarto-static"}, files=["README.md"])) + with self.assertRaises(RSConnectException): + executor.primary_file_for_connect_cloud() + + def test_ambiguous_quarto_manifest_is_reported(self): + executor = self._executor(_bundle_with_manifest({"appmode": "quarto-static"}, files=["a.qmd", "b.qmd"])) + with self.assertRaises(RSConnectException) as context: + executor.primary_file_for_connect_cloud() + self.assertIn("primary file", str(context.exception)) + + def test_multi_input_project_without_index_uses_quarto_render_order(self): + # A directory project with several documents and no index.* used to fail; + # `quarto inspect` reports the inputs in render order, so the first one wins. + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["a.qmd", "b.qmd", "zebra.qmd"]), + quarto_inputs=["zebra.qmd", "a.qmd", "b.qmd"], + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "zebra.qmd") + + def test_render_order_inputs_missing_from_the_bundle_are_skipped(self): + # An input excluded from the bundle (e.g. via --exclude) cannot be the + # primary file Connect Cloud is told about. + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["a.qmd", "b.qmd"]), + quarto_inputs=["excluded.qmd", "b.qmd", "a.qmd"], + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "b.qmd") + + def test_render_order_resolves_several_markdown_inputs(self): + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["notes.md", "extra.md", "_quarto.yml"]), + quarto_inputs=["notes.md", "extra.md"], + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "notes.md") + + def test_an_index_file_still_outranks_the_render_order(self): + executor = self._executor( + _bundle_with_manifest({"appmode": "quarto-static"}, files=["about.qmd", "index.qmd"]), + quarto_inputs=["about.qmd", "index.qmd"], + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "index.qmd") + + def test_unresolvable_entrypoint_is_returned_unchanged(self): + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-shiny", "entrypoint": "mystery"}, + files=["other.py"], + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "mystery") + + def test_falls_back_to_primary_rmd_and_html(self): + executor = self._executor(_bundle_with_manifest({"appmode": "rmd-static", "primary_rmd": "report.Rmd"})) + self.assertEqual(executor.primary_file_for_connect_cloud(), "report.Rmd") + + executor = self._executor(_bundle_with_manifest({"appmode": "static", "primary_html": "index.html"})) + self.assertEqual(executor.primary_file_for_connect_cloud(), "index.html") + + def test_leaves_the_bundle_readable(self): + bundle = _bundle_with_manifest({"appmode": "python-shiny", "entrypoint": "app.py"}) + position = bundle.tell() + executor = self._executor(bundle) + executor.primary_file_for_connect_cloud() + self.assertEqual(bundle.tell(), position) + self.assertTrue(bundle.read()) + + def test_manifest_nested_in_a_single_directory_is_found(self): + # Downloaded bundles may store everything under one top-level directory, + # the same layout read_bundle_manifest tolerates. + executor = self._executor( + _bundle_with_manifest( + {"appmode": "python-shiny", "entrypoint": "app.py"}, + manifest_path="bundle/manifest.json", + ) + ) + self.assertEqual(executor.primary_file_for_connect_cloud(), "app.py") + + def test_reports_a_missing_entrypoint(self): + executor = self._executor(_bundle_with_manifest({"appmode": "static"})) + with self.assertRaises(RSConnectException) as context: + executor.primary_file_for_connect_cloud() + self.assertIn("primary file", str(context.exception)) + + +class TestConnectCloudService(unittest.TestCase): + def setUp(self): + self.client = mock.Mock(spec=ConnectCloudClient) + self.server = ConnectCloudServer("acme", access_token="at") + self.service = ConnectCloudService(self.client, self.server) + # Happy-path responses; tests override the pieces they are about. + # The account-ownership check resolves the target account on the update + # path too, so give it a stable answer by default. + self.client.get_account_by_name.return_value = {"id": "acct-1", "name": "acme"} + self.client.get_accounts.return_value = [{"id": "acct-1", "name": "acme"}] + self.client.get_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "current_revision": {"id": "r0"}, + } + self.client.create_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "title": "My App", + "next_revision": {"id": "r1", "source_bundle_upload_url": "https://up.example/1"}, + } + self.client.update_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "next_revision": {"id": "r2", "source_bundle_upload_url": "https://up.example/2"}, + } + + def _prepare_deploy(self, **overrides): + kwargs = dict( + app_id=None, app_name="my-app", title="My App", app_mode=AppModes.PYTHON_SHINY, primary_file="app.py" + ) + kwargs.update(overrides) + return self.service.prepare_deploy(**kwargs) + + def test_prepare_deploy_creates_content_when_no_app_id(self): + result = self._prepare_deploy(env_vars={"FOO": "bar"}) + + self.client.create_content.assert_called_once() + kwargs = self.client.create_content.call_args.kwargs + self.assertEqual(kwargs["content_type"], "shiny") + self.assertEqual(kwargs["primary_file"], "app.py") + self.assertEqual(kwargs["secrets"], [{"name": "FOO", "value": "bar"}]) + self.client.update_content.assert_not_called() + + self.assertEqual(result.content_id, "c1") + self.assertEqual(result.revision_id, "r1") + self.assertEqual(result.upload_url, "https://up.example/1") + self.assertEqual(result.app_url, "https://connect.posit.cloud/acme/content/c1") + + def test_prepare_deploy_without_env_vars_does_not_touch_secrets(self): + # env_vars is empty when no -E was given; the PATCH must then omit + # secrets entirely (None) so existing ones are not deleted. + self._prepare_deploy(app_id="c1", env_vars={}) + self.assertIsNone(self.client.update_content.call_args.kwargs["secrets"]) + + def test_prepare_deploy_updates_existing_content(self): + result = self._prepare_deploy(app_id="c1") + + self.client.create_content.assert_not_called() + self.client.update_content.assert_called_once() + self.assertEqual(result.revision_id, "r2") + + def test_prepare_deploy_patches_content_whose_first_publish_failed(self): + # A failed first publish leaves content with no current_revision and a stale + # next_revision that still carries the old primary_file and secrets. The + # PATCH must happen anyway, or the retry replays the stale revision. + self.client.get_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "current_revision": None, + "next_revision": {"id": "r1", "source_bundle_upload_url": "https://up.example/stale"}, + } + self.client.update_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "next_revision": {"id": "r2", "source_bundle_upload_url": "https://up.example/fresh"}, + } + + result = self._prepare_deploy(app_id="c1") + + self.client.create_content.assert_not_called() + self.client.update_content.assert_called_once() + self.assertEqual(result.revision_id, "r2") + self.assertEqual(result.upload_url, "https://up.example/fresh") + + def test_prepare_deploy_updates_the_title_only_when_explicit(self): + self._prepare_deploy(app_id="c1") + self.assertIsNone(self.client.update_content.call_args.kwargs["title"]) + + self._prepare_deploy(app_id="c1", update_title=True) + self.assertEqual(self.client.update_content.call_args.kwargs["title"], "My App") + + def test_content_url_resolves_a_renamed_account(self): + # The saved account name can be stale after a rename; the id is what + # survives, so the URL is built from the name the id currently maps to. + self.server.account_id = "acct-1" + self.client.get_accounts.return_value = [{"id": "acct-1", "name": "acme-renamed"}] + url = self.service.content_url("c1", "acct-1") + self.assertEqual(url, "https://connect.posit.cloud/acme-renamed/content/c1") + + def test_prepare_deploy_refuses_content_owned_by_another_account(self): + # One token can publish to several accounts, so a stale or copied record + # can point at another account's content; updating it would silently + # ignore the account being published to. + self.client.get_content.return_value = { + "id": "c1", + "account_id": "acct-other", + "current_revision": {"id": "r0"}, + } + self.client.create_content.return_value = { + "id": "c2", + "account_id": "acct-1", + "next_revision": {"id": "r1", "source_bundle_upload_url": "https://up.example/1"}, + } + + result = self._prepare_deploy(app_id="c1") + + self.client.update_content.assert_not_called() + self.client.create_content.assert_called_once() + self.assertEqual(result.content_id, "c2") + + def test_prepare_deploy_recreates_deleted_content(self): + self.client.get_content.side_effect = RSConnectException("gone", status=404) + self.client.create_content.return_value = { + "id": "c2", + "account_id": "acct-1", + "next_revision": {"id": "r1", "source_bundle_upload_url": "https://up.example/1"}, + } + + result = self._prepare_deploy(app_id="c1") + + self.client.create_content.assert_called_once() + self.assertEqual(result.content_id, "c2") + + def test_prepare_deploy_rejects_an_explicit_app_id_that_no_longer_exists(self): + # A typed --app-id names content the user expects to replace; quietly + # creating new content instead would produce an unintended duplicate. + self.client.get_content.side_effect = RSConnectException("gone", status=404) + + with self.assertRaises(RSConnectException) as context: + self._prepare_deploy(app_id="c1", app_id_is_explicit=True) + + self.assertIn("does not exist", str(context.exception)) + self.assertIn("--app-id", str(context.exception)) + self.client.create_content.assert_not_called() + + def test_prepare_deploy_rejects_an_explicit_app_id_in_another_account(self): + self.client.get_content.return_value = { + "id": "c1", + "account_id": "acct-other", + "current_revision": {"id": "r0"}, + } + + with self.assertRaises(RSConnectException) as context: + self._prepare_deploy(app_id="c1", app_id_is_explicit=True) + + self.assertIn("different Posit Connect Cloud account", str(context.exception)) + self.client.update_content.assert_not_called() + self.client.create_content.assert_not_called() + + def test_prepare_deploy_uses_a_saved_account_id_without_a_lookup(self): + self.service = ConnectCloudService( + self.client, ConnectCloudServer("acme", access_token="at", account_id="acct-1") + ) + + result = self._prepare_deploy() + + self.assertEqual(self.client.create_content.call_args.kwargs["account_id"], "acct-1") + self.client.get_account_by_name.assert_not_called() + # The content URL still resolves the owner's *name* by id, because the + # saved name can be stale after an account rename. + self.assertEqual(result.app_url, "https://connect.posit.cloud/acme/content/c1") + + def test_prepare_deploy_resolves_the_account_when_no_id_is_saved(self): + # Servers saved before the id was recorded still work. + self._prepare_deploy() + + self.client.get_account_by_name.assert_called_once_with("acme") + self.assertEqual(self.client.create_content.call_args.kwargs["account_id"], "acct-1") + + def test_content_url_still_resolves_an_account_that_is_not_the_one_saved(self): + # Team-owned content carries a different account id, which has to be looked up. + server = ConnectCloudServer("acme", access_token="at", account_id="acct-1") + service = ConnectCloudService(self.client, server) + self.client.get_accounts.return_value = [ + {"id": "acct-1", "name": "acme"}, + {"id": "acct-2", "name": "team-analytics"}, + ] + + self.assertEqual( + service.content_url("c1", "acct-2"), + "https://connect.posit.cloud/team-analytics/content/c1", + ) + self.client.get_accounts.assert_called_once() + + def test_prepare_deploy_rejects_unsupported_app_mode(self): + with self.assertRaises(RSConnectException) as context: + self._prepare_deploy(app_name="my-api", title="My API", app_mode=AppModes.PYTHON_FASTAPI) + self.assertIn("does not support", str(context.exception)) + + def test_content_url_resolves_a_team_account(self): + # Content can live in a team account, not the authenticating one. + self.client.get_accounts.return_value = [ + {"id": "acct-1", "name": "acme"}, + {"id": "acct-2", "name": "team-analytics"}, + ] + url = self.service.content_url("c1", "acct-2") + self.assertEqual(url, "https://connect.posit.cloud/team-analytics/content/c1") + + def test_content_url_failure_does_not_propagate(self): + # A URL we cannot build must not mask an otherwise successful deploy. + self.client.get_accounts.side_effect = RSConnectException("boom") + self.assertEqual(self.service.content_url("c1", "acct-2"), "") + + def test_wait_for_publish_returns_on_success(self): + self.client.get_revision.side_effect = [ + {"id": "r1", "status": "building", "publish_result": None}, + {"id": "r1", "status": "publishing", "publish_result": None}, + {"id": "r1", "status": "published", "publish_result": "success"}, + ] + with mock.patch("rsconnect.api.time.sleep"): + revision = self.service.wait_for_publish("r1") + self.assertEqual(revision["publish_result"], "success") + + def test_wait_for_publish_tolerates_unknown_status(self): + # The server can add states without us knowing about them. + self.client.get_revision.side_effect = [ + {"id": "r1", "status": "some-new-state", "publish_result": None}, + {"id": "r1", "status": "published", "publish_result": "success"}, + ] + with mock.patch("rsconnect.api.time.sleep"): + self.service.wait_for_publish("r1") + + def test_wait_for_publish_reports_failure_with_logs(self): + self.client.get_revision.return_value = { + "id": "r1", + "status": "building", + "publish_result": "failure", + "publish_error_details": "build failed", + "publish_log_channel": "chan-1", + } + self.client.get_publish_logs.return_value = [{"timestamp": 1700000000000000, "message": "boom"}] + + with mock.patch("rsconnect.api.time.sleep"): + with self.assertRaises(DeploymentFailedException) as context: + self.service.wait_for_publish("r1") + + self.assertIn("build failed", str(context.exception)) + self.client.get_publish_logs.assert_called_once_with("chan-1") + + def test_log_failure_does_not_mask_publish_failure(self): + self.client.get_revision.return_value = { + "id": "r1", + "publish_result": "failure", + "publish_log_channel": "chan-1", + } + self.client.get_publish_logs.side_effect = RSConnectException("no logs for you") + + with mock.patch("rsconnect.api.time.sleep"): + with self.assertRaises(DeploymentFailedException): + self.service.wait_for_publish("r1") + + def test_wait_for_publish_times_out(self): + self.client.get_revision.return_value = {"id": "r1", "status": "building", "publish_result": None} + with mock.patch("rsconnect.api.time.sleep"): + with self.assertRaises(RSConnectException) as context: + self.service.wait_for_publish("r1", timeout=0) + self.assertIn("Timed out", str(context.exception)) + + +class TestConnectCloudDeployRecordsContentEarly(unittest.TestCase): + """Connect Cloud cannot look content up by name, so the local deployment record is + the only way back to a content item. It has to be written before publishing.""" + + def setUp(self): + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + self.app_path = os.path.join(tempdir.name, "app.py") + self.server = ConnectCloudServer("acme", access_token="at") + + def _executor(self, app_id=None): + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.remote_server = self.server + executor.client = mock.MagicMock(spec=ConnectCloudClient) + executor.app_mode = AppModes.PYTHON_SHINY + executor.app_id = app_id + executor.app_id_is_explicit = app_id is not None + executor.app_store = AppStore(self.app_path) + executor.path = self.app_path + executor.deployment_name = "my-app" + executor.title = "My App" + executor.title_is_default = True + executor.env_vars = None + executor.deployed_info = None + executor.logger = None + executor.bundle = _bundle_with_manifest({"appmode": "python-shiny", "entrypoint": "app.py"}) + return executor + + def _service(self): + service = mock.Mock(spec=ConnectCloudService) + service.prepare_deploy.return_value = api.ConnectCloudDeployResult( + content_id="c1", + revision_id="r1", + upload_url="https://up.example/1", + app_url="https://connect.posit.cloud/acme/content/c1", + title="My App", + ) + return service + + def _saved_app_id(self): + # Records are scoped by account, not just the shared API URL. + key = "%s#%s" % (self.server.url, self.server.account_name) + return AppStore(self.app_path).resolve(key, None, AppModes.PYTHON_SHINY)[0] + + def test_publish_failure_still_records_the_content_id(self): + executor = self._executor() + service = self._service() + service.do_deploy.side_effect = DeploymentFailedException("publish failed") + + with mock.patch.object(api, "ConnectCloudService", return_value=service): + with self.assertRaises(DeploymentFailedException): + executor.deploy_bundle() + + self.assertEqual(self._saved_app_id(), "c1") + + def test_a_retry_after_a_failed_publish_reuses_the_content(self): + self.test_publish_failure_still_records_the_content_id() + + executor = self._executor() + executor.new = False + executor.validate_app_mode(AppModes.PYTHON_SHINY) + self.assertEqual(executor.app_id, "c1") + + service = self._service() + with mock.patch.object(api, "ConnectCloudService", return_value=service): + with mock.patch.object(api.webbrowser, "open_new"): + executor.deploy_bundle() + + self.assertEqual(service.prepare_deploy.call_args.kwargs["app_id"], "c1") + + def test_the_record_is_written_before_the_bundle_is_uploaded(self): + executor = self._executor() + service = self._service() + seen = [] + service.upload_bundle.side_effect = lambda *args: seen.append(self._saved_app_id()) + + with mock.patch.object(api, "ConnectCloudService", return_value=service): + with mock.patch.object(api.webbrowser, "open_new"): + executor.deploy_bundle() + + self.assertEqual(seen, ["c1"]) + + def test_upload_failure_still_records_the_content_id(self): + executor = self._executor() + service = self._service() + service.upload_bundle.side_effect = RSConnectException("upload failed") + + with mock.patch.object(api, "ConnectCloudService", return_value=service): + with self.assertRaises(RSConnectException): + executor.deploy_bundle() + + self.assertEqual(self._saved_app_id(), "c1") + + +class TestConnectCloudRecordKey(unittest.TestCase): + """Deployment records must be scoped by account, not just the shared API URL.""" + + def _executor(self, server): + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.remote_server = server + return executor + + def test_connect_cloud_key_includes_the_account(self): + executor = self._executor(ConnectCloudServer("acme")) + self.assertEqual(executor.record_server_key(), "https://api.connect.posit.cloud/v1#acme") + + def test_the_account_id_is_preferred_over_the_renamable_name(self): + executor = self._executor(ConnectCloudServer("acme", account_id="acct-1")) + self.assertEqual(executor.record_server_key(), "https://api.connect.posit.cloud/v1#acct-1") + self.assertEqual(executor.record_server_key_fallback(), "https://api.connect.posit.cloud/v1#acme") + + def test_no_fallback_without_an_id(self): + self.assertIsNone(self._executor(ConnectCloudServer("acme")).record_server_key_fallback()) + + def test_different_accounts_get_different_keys(self): + self.assertNotEqual( + self._executor(ConnectCloudServer("acme")).record_server_key(), + self._executor(ConnectCloudServer("emca")).record_server_key(), + ) + + def test_other_servers_keep_the_plain_url(self): + executor = self._executor(api.RSConnectServer("https://connect.example.com", "key")) + self.assertEqual(executor.record_server_key(), "https://connect.example.com") + self.assertIsNone(executor.record_server_key_fallback()) + + def test_a_name_keyed_record_is_still_found_when_an_id_arrives(self): + # Records written before the account id was stored are keyed by name; + # the read falls back to them, and the next write migrates the record. + tempdir = tempfile.TemporaryDirectory() + self.addCleanup(tempdir.cleanup) + app_path = os.path.join(tempdir.name, "app.py") + + server = ConnectCloudServer("acme", account_id="acct-1") + store = AppStore(app_path) + store.set( + "https://api.connect.posit.cloud/v1#acme", + app_path, + "https://connect.posit.cloud/acme/content/c1", + "c1", + None, + "T", + AppModes.PYTHON_SHINY, + ) + store.save() + + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.logger = None + executor.remote_server = server + executor.app_store = AppStore(app_path) + executor.app_store_version = None + executor.path = app_path + executor.new = False + executor.app_id = None + executor.app_mode = None + executor.validate_app_mode(app_mode=AppModes.PYTHON_SHINY) + + self.assertEqual(executor.app_id, "c1") + + +class TestPresignedUrlErrorRedaction(unittest.TestCase): + def test_upload_error_does_not_leak_the_signed_url(self): + # handle_bad_response quotes the URI in its message; for a presigned + # upload URL that would include the URL's own credentials. + from rsconnect.api import S3Server + from rsconnect.http_support import HTTPResponse + + url = "https://bucket.example/path?token=signed-credential&X-Amz-Signature=deadbeef&sig=sastoken" + response = HTTPResponse(url, exception=ConnectionError("boom")) + with self.assertRaises(RSConnectException) as context: + S3Server(url).handle_bad_response(response, is_httpresponse=True) + + message = str(context.exception) + self.assertNotIn("signed-credential", message) + self.assertNotIn("deadbeef", message) + self.assertNotIn("sastoken", message) + self.assertIn("bucket.example", message) + + +class TestConnectCloudCliPolish(CliTestCase): + def _manifest_path(self): + path = os.path.join(tempfile.mkdtemp(), "manifest.json") + with open(path, "w") as f: + json.dump({"version": 1, "metadata": {"appmode": "python-shiny", "entrypoint": "app1"}, "files": {}}, f) + return path + + def test_client_secret_is_masked_in_verbose_output(self): + # The parameter dump goes to the logger, not to click's output stream. + with mock.patch("rsconnect.connect_cloud.request_client_credentials_token") as request: + request.return_value = {"access_token": "at"} + with self.assertLogs("rsconnect", level=VERBOSE) as captured: + result = self.runner.invoke( + cli, + [ + "add", + "-v", + "--name", + "cloud", + "--server", + "connect.posit.cloud", + "--account", + "acme", + "--client-id", + "cid", + "--client-secret", + "sup3rs3cret", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + logged = "\n".join(captured.output) + self.assertNotIn("sup3rs3cret", logged) + self.assertIn("client_secret: **********", logged) + # The non-secret client id is still shown, so the log stays useful. + self.assertIn("client_id: cid", logged) + + def test_shinyapps_token_and_secret_are_masked_too(self): + with mock.patch("rsconnect.main._test_rstudio_creds"): + with self.assertLogs("rsconnect", level=VERBOSE) as captured: + result = self.runner.invoke( + cli, + [ + "add", + "-v", + "--name", + "sa", + "--server", + "shinyapps.io", + "--account", + "me", + "--token", + "tok3n", + "--secret", + "s3cret", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + logged = "\n".join(captured.output) + self.assertNotIn("tok3n", logged) + self.assertNotIn("s3cret", logged) + + def test_list_shows_connect_cloud_details(self): + self.store.set( + "cloud", + "https://api.connect.posit.cloud/v1", + connect_cloud_account_name="acme", + connect_cloud_client_id="cid", + connect_cloud_access_token="at", + ) + result = self.runner.invoke(cli, ["list"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("Posit Connect Cloud account: acme", result.output) + self.assertIn("Service account client ID: cid", result.output) + self.assertIn("Credentials are saved", result.output) + self.assertNotIn("at", result.output.split("Credentials are saved")[0].split("client ID: cid")[1]) + + def test_manifest_deploy_marks_a_defaulted_title(self): + # deploy manifest computes a default title before building the executor; + # it must still record that --title was not typed, or redeploys would + # overwrite existing Connect Cloud content's title with the default. + path = self._manifest_path() + + with mock.patch("rsconnect.main.RSConnectExecutor") as executor_cls: + result = self.runner.invoke(cli, ["deploy", "manifest", path]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(executor_cls.call_args.kwargs["title"]) + self.assertTrue(executor_cls.return_value.title_is_default) + + with mock.patch("rsconnect.main.RSConnectExecutor") as executor_cls: + result = self.runner.invoke(cli, ["deploy", "manifest", path, "-t", "Typed Title"]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(executor_cls.call_args.kwargs["title"], "Typed Title") + self.assertFalse(executor_cls.return_value.title_is_default) + + def test_env_var_values_are_hidden_in_verbose_output(self): + # -E values are sent to Connect Cloud as secrets; -v must log names only. + path = self._manifest_path() + + with mock.patch("rsconnect.main.RSConnectExecutor"): + with self.assertLogs("rsconnect", level=VERBOSE) as captured: + result = self.runner.invoke(cli, ["deploy", "manifest", path, "-v", "-E", "API_KEY=hunter2"]) + + self.assertEqual(result.exit_code, 0, result.output) + logged = "\n".join(captured.output) + self.assertIn("API_KEY", logged) + self.assertNotIn("hunter2", logged) + self.assertIn("values hidden", logged) + + def test_manifest_pyproject_and_html_help_mention_connect_cloud(self): + from rsconnect.main import deploy + + for command in ("manifest", "pyproject", "html"): + self.assertIn("Posit Connect Cloud", str(deploy.commands[command].short_help), command) + + def test_pyproject_deploy_does_not_use_the_nickname_as_title(self): + # The server nickname used to be a title fallback, so `deploy pyproject + # -n cloud` renamed existing Connect Cloud content to "cloud". + project_dir = tempfile.mkdtemp() + with open(os.path.join(project_dir, "pyproject.toml"), "w") as f: + f.write('[tool.rsconnect]\napp_mode = "python-shiny"\nentrypoint = "app:app"\n') + with open(os.path.join(project_dir, "app.py"), "w") as f: + f.write("app = None\n") + with open(os.path.join(project_dir, "requirements.txt"), "w") as f: + f.write("shiny\n") + + with mock.patch("rsconnect.main.RSConnectExecutor") as executor_cls: + result = self.runner.invoke(cli, ["deploy", "pyproject", project_dir, "-n", "cloud"]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIsNone(executor_cls.call_args.kwargs["title"]) + + with mock.patch("rsconnect.main.RSConnectExecutor") as executor_cls: + result = self.runner.invoke(cli, ["deploy", "pyproject", project_dir, "-n", "cloud", "-t", "Typed"]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(executor_cls.call_args.kwargs["title"], "Typed") + + def test_notebook_and_quarto_take_connect_cloud_options(self): + # jupyter and quarto are supported Connect Cloud content types, so their + # commands need the target and credential options like the others. + for command in ("notebook", "quarto"): + result = self.runner.invoke(cli, ["deploy", command, "--help"]) + self.assertIn("--connect-cloud", result.output, command) + self.assertIn("--client-id", result.output, command) + self.assertIn("-A, --account", result.output, command) + + def test_help_mentions_connect_cloud_only_for_supported_types(self): + # The generated commands share a template; unsupported types must not + # advertise a target that rejects them at validation time. The + # --connect-cloud *option* still appears everywhere (its rejection + # message is the explanation), so only the descriptions are checked. + from rsconnect.main import deploy + + for command in ("streamlit", "dash", "bokeh", "shiny"): + self.assertIn("Posit Connect Cloud", str(deploy.commands[command].short_help), command) + self.assertIn("Posit Connect Cloud", str(deploy.commands[command].help), command) + for command in ("fastapi", "api", "flask", "gradio", "voila"): + self.assertNotIn("Posit Connect Cloud", str(deploy.commands[command].short_help), command) + self.assertNotIn("Posit Connect Cloud", str(deploy.commands[command].help), command) + + def test_deploy_group_help_mentions_connect_cloud(self): + result = self.runner.invoke(cli, ["deploy", "--help"]) + self.assertIn("Posit Connect Cloud", result.output) + + +class TestConnectCloudFlagAlias(CliTestCase): + """`--connect-cloud` is shorthand for `--server connect.posit.cloud`.""" + + def _add(self, *args): + self._mock_device_login() + return self.runner.invoke(cli, ["add", "--name", "cloud", "--account", "acme", *args]) + + def test_flag_produces_the_same_entry_as_the_pseudo_name(self): + result = self._add("--connect-cloud") + self.assertEqual(result.exit_code, 0, result.output) + via_flag = self.store.get_by_name("cloud") + + self.store.remove_by_name("cloud") + result = self._add("--server", "connect.posit.cloud") + self.assertEqual(result.exit_code, 0, result.output) + via_pseudo_name = self.store.get_by_name("cloud") + + self.assertEqual(via_flag, via_pseudo_name) + assert via_flag is not None + self.assertEqual(via_flag["url"], "https://api.connect.posit.cloud/v1") + self.assertEqual(via_flag["connect_cloud_account_name"], "acme") + + def test_flag_is_redundant_but_allowed_with_the_pseudo_name(self): + result = self._add("--connect-cloud", "--server", "connect.posit.cloud") + self.assertEqual(result.exit_code, 0, result.output) + + def test_flag_conflicts_with_an_explicit_other_server(self): + result = self._add("--connect-cloud", "--server", "https://connect.example.com") + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("--connect-cloud cannot be combined with", result.output) + + def test_flag_conflicts_with_a_nickname_on_deploy(self): + # On a deploy, -n references a saved server that may be a plain Connect + # server; the flag must be rejected rather than silently dropped. `add` + # is unaffected because there -n names the entry being created and add + # does not pass it to validation. + with self.assertRaises(RSConnectException) as context: + _validate_options(name="myconnect") + self.assertIn("cannot be specified in conjunction", str(context.exception)) + self.assertIn("--connect-cloud", str(context.exception)) + + def test_flag_wins_over_connect_server_env_var(self): + # A leftover CONNECT_SERVER from another target must not defeat the flag. + with mock.patch.dict(os.environ, {"CONNECT_SERVER": "https://connect.example.com"}): + result = self._add("--connect-cloud") + + self.assertEqual(result.exit_code, 0, result.output) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertEqual(entry["url"], "https://api.connect.posit.cloud/v1") + + def test_flag_alone_satisfies_the_target_requirement(self): + # The flag names the target, so the "you must specify one of ..." check + # must not fire and mask the real problem (a missing account). + result = self.runner.invoke(cli, ["add", "--name", "cloud", "--connect-cloud"]) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("-A/--account is required for Posit Connect Cloud", result.output) + self.assertNotIn("You must specify one of", result.output) + + def test_conflicting_options_are_reported_against_connect_cloud(self): + # -A/--account is shared with shinyapps.io, so the generic connect-vs-shinyapps + # rule would otherwise blame the wrong target. + result = self.runner.invoke( + cli, ["add", "--name", "cloud", "--connect-cloud", "--account", "acme", "--api-key", "key"] + ) + self.assertEqual(result.exit_code, 1, result.output) + message = result.output + self.assertIn("may not be passed alongside Posit Connect Cloud", message) + self.assertNotIn("shinyapps.io options", message) + + def test_env_sourced_connect_options_do_not_block_the_flag(self): + # A CONNECT_API_KEY or CONNECT_INSECURE exported for another target is + # just the environment, the same as for a saved cloud nickname. + _validate_options(ctx=_ctx(api_key=ENV, insecure=ENV), api_key="env-key", insecure=True, account_name="acme") + + def test_env_sourced_shinyapps_token_does_not_block_the_flag(self): + _validate_options(ctx=_ctx(token=ENV, secret=ENV), account_name="acme", token="env-token", secret="env-secret") + + def test_typed_shinyapps_token_still_conflicts_with_the_flag(self): + with self.assertRaises(RSConnectException) as context: + _validate_options(ctx=_ctx(token=TYPED), account_name="acme", token="typed-token") + self.assertIn("shinyapps.io options", str(context.exception)) + + def test_flag_appears_in_deploy_help(self): + result = self.runner.invoke(cli, ["deploy", "shiny", "--help"]) + self.assertIn("--connect-cloud", result.output) + + def test_flag_selects_connect_cloud_in_the_executor(self): + server = _cloud_server(account_name="acme", use_connect_cloud=True) + self.assertEqual(server.url, "https://api.connect.posit.cloud/v1") + self.assertEqual(server.account_name, "acme") + + +class TestConnectCloudSameAccountAmbiguity(unittest.TestCase): + """Several entries for the same account may hold different credentials, so + -A must not silently pick one of them.""" + + def _store(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + for name in ("cloud-a", "cloud-b"): + store.set(name, API, connect_cloud_account_name="acme", connect_cloud_access_token="at-" + name) + return store + + def test_lookup_by_account_with_several_matches_is_rejected(self): + store = self._store() + with self.assertRaises(RSConnectException) as context: + store.get_by_url("connect.posit.cloud", "acme") + message = str(context.exception) + self.assertIn('"cloud-a"', message) + self.assertIn('"cloud-b"', message) + self.assertIn("-n/--name", message) + + def test_a_nickname_still_selects_one(self): + store = self._store() + entry = store.get_by_name("cloud-b") + assert entry is not None + self.assertEqual(entry["connect_cloud_access_token"], "at-cloud-b") + + def test_a_complete_supplied_pair_bypasses_the_ambiguity(self): + # A one-shot/CI deploy brings its own identity, so saved entries must not + # block it however many there are; it resolves to transient server data. + server = _cloud_server( + store=self._store(), + account_name="acme", + use_connect_cloud=True, + client_id="ci-id", + client_secret="ci-secret", + ) + + self.assertIsNone(server.access_token) + self.assertIsNone(server.server_name) + self.assertEqual(server.client_id, "ci-id") + self.assertEqual(server.account_name, "acme") + + def test_without_credentials_the_ambiguity_still_stands(self): + with self.assertRaises(RSConnectException) as context: + _setup_remote_server(store=self._store(), account_name="acme", use_connect_cloud=True) + self.assertIn("-n/--name", str(context.exception)) + + def test_remove_by_url_is_rejected_when_ambiguous(self): + store = self._store() + with self.assertRaises(RSConnectException): + store.remove_by_url("connect.posit.cloud") + self.assertEqual(len(store.get_all_servers()), 2) + + +class TestConnectCloudUrlVariantLookup(unittest.TestCase): + """Every URL variant is_connect_cloud_url accepts must also find the saved + credential, or a trailing slash silently loses the login.""" + + def test_lookup_by_variant_urls_finds_the_saved_entry(self): + store = _store_with_cloud_entry() + for variant in ( + "https://api.connect.posit.cloud/v1/", + "HTTPS://API.CONNECT.POSIT.CLOUD/v1", + "connect.posit.cloud/", + ): + entry = store.get_by_url(variant, "acme") + assert entry is not None, variant + self.assertEqual(entry["name"], "cloud", variant) + + def test_non_cloud_urls_are_not_rewritten(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set("prod", "https://connect.example.com", api_key="key") + entry = store.get_by_url("https://connect.example.com") + assert entry is not None + self.assertEqual(entry["name"], "prod") + + +class TestConnectCloudCredentialOptionsDeferred(unittest.TestCase): + """Typed --client-id/--client-secret are judged against the resolved target: + a nickname or default server may be Connect Cloud, which validation cannot + see before the store lookup.""" + + def _typed_credentials(self, store, **kwargs): + return _setup_remote_server( + ctx=_ctx(client_id=TYPED, client_secret=TYPED), store=store, client_id="cid", client_secret="sec", **kwargs + ) + + def test_typed_credentials_are_accepted_with_a_saved_cloud_nickname(self): + server = self._typed_credentials(_store_with_cloud_entry(), name="cloud").remote_server + assert isinstance(server, ConnectCloudServer) + # Credentials that differ from the entry's are their own identity + # (the finding-34 override), so the entry's token is not attached. + self.assertEqual(server.client_id, "cid") + self.assertIsNone(server.access_token) + + def test_typed_credentials_with_a_connect_nickname_fail_after_resolution(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set("prod", "https://connect.example.com", api_key="key") + with self.assertRaises(RSConnectException) as context: + self._typed_credentials(store, name="prod") + self.assertIn("require --connect-cloud", str(context.exception)) + + def test_typed_credentials_with_a_non_cloud_default_fail_after_resolution(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set("prod", "https://connect.example.com", api_key="key", set_as_default=True) + with self.assertRaises(RSConnectException) as context: + self._typed_credentials(store) + self.assertIn("require --connect-cloud", str(context.exception)) + + def test_typed_credentials_with_a_cloud_default_are_accepted(self): + executor = self._typed_credentials(_store_with_cloud_entry(set_as_default=True)) + self.assertIsInstance(executor.remote_server, ConnectCloudServer) + + def test_remove_by_url_with_a_single_entry_removes_it(self): + store = _store_with_cloud_entry() + self.assertTrue(store.remove_by_url("connect.posit.cloud")) + self.assertEqual(store.get_all_servers(), []) + + +class TestConnectCloudFindsSavedCredentialsByUrl(unittest.TestCase): + """`--connect-cloud` and `-s connect.posit.cloud` must find a saved server. + + `rsconnect add` stores the entry under the API URL, so a lookup by the short + name only matches once it has been translated. + """ + + def setUp(self): + env_patch = mock.patch.dict(os.environ, {}, clear=True) + env_patch.start() + self.addCleanup(env_patch.stop) + self.store = self._store("https://api.connect.posit.cloud/v1") + self._use(self.store) + + def _store(self, url): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set( + "cloud", + url, + connect_cloud_account_name="acme", + connect_cloud_account_id="acct-1", + connect_cloud_access_token="at", + connect_cloud_refresh_token="rt", + ) + return store + + def _use(self, store): + store_patch = mock.patch("rsconnect.api.ServerStore", return_value=store) + store_patch.start() + self.addCleanup(store_patch.stop) + main_patch = mock.patch("rsconnect.main.server_store", store) + main_patch.start() + self.addCleanup(main_patch.stop) + + def _server(self, **kwargs): + return _cloud_server(**kwargs) + + def test_resolve_translates_the_short_name(self): + data = self.store.resolve(None, connect_cloud.SERVER_NAME) + self.assertTrue(data.from_store) + self.assertEqual(data.name, "cloud") + self.assertEqual(data.connect_cloud_access_token, "at") + + def test_flag_uses_the_saved_credentials(self): + server = self._server(account_name="acme", use_connect_cloud=True) + self.assertEqual(server.access_token, "at") + self.assertEqual(server.refresh_token, "rt") + # Without the nickname a refreshed token cannot be written back. + self.assertEqual(server.server_name, "cloud") + + def test_short_name_uses_the_saved_credentials(self): + server = self._server(url=connect_cloud.SERVER_NAME, account_name="acme") + self.assertEqual(server.access_token, "at") + self.assertEqual(server.server_name, "cloud") + + def test_an_explicit_account_wins_over_the_saved_one(self): + server = self._server(account_name="team-b", use_connect_cloud=True) + self.assertEqual(server.account_name, "team-b") + self.assertEqual(server.access_token, "at") + + def test_the_saved_account_id_is_used_for_the_saved_account(self): + server = self._server(account_name="acme", use_connect_cloud=True) + self.assertEqual(server.account_id, "acct-1") + + def test_the_saved_account_id_is_dropped_for_a_different_account(self): + # The id belongs to the saved account, so publishing elsewhere must resolve + # the name rather than send the wrong id. + server = self._server(account_name="team-b", use_connect_cloud=True) + self.assertIsNone(server.account_id) + + def test_remove_accepts_the_short_name(self): + runner = CliRunner() + result = runner.invoke(cli, ["remove", "-s", connect_cloud.SERVER_NAME]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIsNone(self.store.get_by_name("cloud")) + + def test_a_saved_staging_server_is_not_used_for_production(self): + self._use(self._store("https://api.staging.connect.posit.cloud/v1")) + server = self._server(account_name="acme", use_connect_cloud=True) + self.assertEqual(server.url, "https://api.connect.posit.cloud/v1") + self.assertIsNone(server.access_token) + + def test_a_saved_staging_server_is_used_when_staging_is_selected(self): + self._use(self._store("https://api.staging.connect.posit.cloud/v1")) + with mock.patch.dict(os.environ, {connect_cloud.ENVIRONMENT_ENV_VAR: "staging"}): + server = self._server(account_name="acme", use_connect_cloud=True) + self.assertEqual(server.url, "https://api.staging.connect.posit.cloud/v1") + self.assertEqual(server.access_token, "at") + + +class TestConnectCloudAccountSelection(unittest.TestCase): + """Every Connect Cloud server is saved under the same API URL, so the account is + what picks one out. Mirrors how the R client's findAccount() keys on + (account, server) and refuses to guess between several.""" + + def setUp(self): + env_patch = mock.patch.dict(os.environ, {}, clear=True) + env_patch.start() + self.addCleanup(env_patch.stop) + + def _store(self, *entries): + store = ServerStore(base_dir=tempfile.mkdtemp()) + for nickname, account, token in entries: + store.set(nickname, API, connect_cloud_account_name=account, connect_cloud_access_token=token) + return store + + def _one(self): + return self._store(("cloud", "sam", "sam-token")) + + def _two(self): + return self._store(("personal", "sam", "sam-token"), ("ci", "acme-team", "ci-token")) + + def _server(self, store, **kwargs): + return _cloud_server(store=store, **kwargs) + + def test_the_account_is_not_required_when_one_server_is_saved(self): + server = self._server(self._one(), use_connect_cloud=True) + self.assertEqual(server.account_name, "sam") + self.assertEqual(server.access_token, "sam-token") + + def test_the_account_is_still_required_with_nothing_saved(self): + with self.assertRaises(RSConnectException) as context: + self._server(self._store(), use_connect_cloud=True) + self.assertIn("-A/--account is required", str(context.exception)) + + def test_add_still_requires_the_account_when_a_server_is_saved(self): + # `add` registers a named account, so it must not fall back to a saved one. + store = self._one() + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["add", "--name", "second", "--connect-cloud"]) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn("-A/--account is required", result.output) + + def test_the_account_selects_among_several_saved_servers(self): + server = self._server(self._two(), use_connect_cloud=True, account_name="acme-team") + self.assertEqual(server.access_token, "ci-token") + self.assertEqual(server.server_name, "ci") + + server = self._server(self._two(), use_connect_cloud=True, account_name="sam") + self.assertEqual(server.access_token, "sam-token") + self.assertEqual(server.server_name, "personal") + + def test_several_saved_servers_and_no_account_is_an_error(self): + with self.assertRaises(RSConnectException) as context: + self._server(self._two(), use_connect_cloud=True) + message = str(context.exception) + self.assertIn("Several Posit Connect Cloud accounts are saved", message) + self.assertIn('acme-team (nickname "ci")', message) + self.assertIn('sam (nickname "personal")', message) + + def test_several_saved_servers_and_an_unknown_account_is_an_error(self): + with self.assertRaises(RSConnectException) as context: + self._server(self._two(), use_connect_cloud=True, account_name="stranger") + message = str(context.exception) + self.assertIn('No saved Posit Connect Cloud credential is for account "stranger"', message) + self.assertIn('acme-team (nickname "ci")', message) + + def test_a_nickname_selects_a_server_without_the_account(self): + server = self._server(self._two(), name="ci") + self.assertEqual(server.account_name, "acme-team") + self.assertEqual(server.access_token, "ci-token") + + def test_one_saved_login_publishes_to_another_of_its_accounts(self): + # A Connect Cloud token belongs to a user, who can publish to every account + # they have access to, so an explicit account picks the target rather than + # the credential. + server = self._server(self._one(), use_connect_cloud=True, account_name="other-team") + self.assertEqual(server.account_name, "other-team") + self.assertEqual(server.access_token, "sam-token") + + def test_remove_reports_the_ambiguity_instead_of_deleting_one(self): + store = self._two() + runner = CliRunner() + with mock.patch("rsconnect.main.server_store", store): + result = runner.invoke(cli, ["remove", "-s", connect_cloud.SERVER_NAME]) + self.assertEqual(result.exit_code, 1, result.output) + # `remove` reports through cli_feedback, so the message lands on stdout. + self.assertIn("Several Posit Connect Cloud accounts are saved", result.output) + self.assertIsNotNone(store.get_by_name("personal")) + self.assertIsNotNone(store.get_by_name("ci")) + + def test_a_connect_server_url_is_unaffected(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set("prod", "https://connect.example.com", api_key="key") + entry = store.get_by_url("https://connect.example.com", account_name="ignored") + assert entry is not None + self.assertEqual(entry["name"], "prod") + + +class TestConnectCloudVisibility(unittest.TestCase): + def _executor(self, visibility=None, server=None): + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.remote_server = server or ConnectCloudServer("acme", access_token="at") + executor.client = mock.Mock(spec=ConnectCloudClient) + executor.client.__enter__ = mock.Mock(return_value=executor.client) + executor.client.__exit__ = mock.Mock(return_value=False) + executor.client.get_account_by_name.return_value = {"id": "acct-1", "name": "acme"} + executor.visibility = visibility + return executor + + def test_visibility_is_rejected(self): + # Connect Cloud has no equivalent setting. R silently ignores it; we do not. + executor = self._executor("private") + with self.assertRaises(RSConnectException) as context: + executor.validate_connect_cloud_server() + self.assertIn("--visibility is not supported", str(context.exception)) + + def test_no_visibility_is_accepted(self): + executor = self._executor() + executor.validate_connect_cloud_server() + executor.client.get_current_user.assert_called_once() + + def test_validation_resolves_the_account_id_when_none_is_saved(self): + # Deployment records are keyed by account id; resolving it here, before + # validate_app_mode reads the records, keeps redeployments finding their + # content after an account rename. + executor = self._executor() + executor.validate_connect_cloud_server() + executor.client.get_account_by_name.assert_called_once_with("acme") + self.assertEqual(executor.remote_server.account_id, "acct-1") + + def test_validation_keeps_a_saved_account_id_without_a_lookup(self): + executor = self._executor(server=ConnectCloudServer("acme", access_token="at", account_id="acct-saved")) + executor.validate_connect_cloud_server() + executor.client.get_account_by_name.assert_not_called() + self.assertEqual(executor.remote_server.account_id, "acct-saved") + + def test_missing_credentials_are_reported(self): + executor = self._executor(server=ConnectCloudServer("acme")) + with self.assertRaises(RSConnectException) as context: + executor.validate_connect_cloud_server() + message = str(context.exception) + self.assertIn("No Posit Connect Cloud credentials found", message) + # The hint gets copied verbatim, and `add` without a nickname stores an entry + # under a null name rather than failing. + self.assertIn("rsconnect add -n -s connect.posit.cloud -A ", message) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http_support.py b/tests/test_http_support.py index f7c2be1d9..f51822c54 100644 --- a/tests/test_http_support.py +++ b/tests/test_http_support.py @@ -109,3 +109,187 @@ def test_as_dict(self): "content": {"my-cookie": "my-value", "my-2nd-cookie": "my-other-value"}, }, ) + + def test_cookie_values_do_not_reach_the_debug_log(self): + # Cookies are session credentials; the jar logs names only. + jar = CookieJar() + with self.assertLogs("rsconnect", level="DEBUG") as captured: + jar.store_cookies(FakeSetCookieResponse(["session=s3ssionv4lue"])) + header = jar.get_cookie_header_value() + + self.assertEqual(header, "session=s3ssionv4lue") + log_text = "\n".join(captured.output) + self.assertNotIn("s3ssionv4lue", log_text) + self.assertIn("session", log_text) + + +class TestDebugLogRedaction(TestCase): + """Credential material must not reach the debug (-vv) log.""" + + def test_form_encoded_credentials_are_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = "grant_type=client_credentials&client_id=abc&client_secret=hunter2&scope=vivid" + redacted = _redacted_body_for_log(body) + self.assertNotIn("hunter2", str(redacted)) + self.assertIn("client_secret=", str(redacted)) + self.assertIn("client_id=abc", str(redacted)) + self.assertIn("scope=vivid", str(redacted)) + + def test_bytes_bodies_are_redacted_too(self): + from rsconnect.http_support import _redacted_body_for_log + + body = b"grant_type=refresh_token&refresh_token=r3fr3sh&client_id=abc" + redacted = _redacted_body_for_log(body) + self.assertNotIn("r3fr3sh", str(redacted)) + self.assertIn("refresh_token=", str(redacted)) + + def test_json_token_response_is_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = '{"access_token": "AAA", "refresh_token": "RRR", "token_type": "bearer"}' + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("AAA", redacted) + self.assertNotIn("RRR", redacted) + self.assertIn('"token_type": "bearer"', redacted) + + def test_json_secret_values_are_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = '{"secrets": [{"name": "MY_VAR", "value": "s3cret"}]}' + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("s3cret", redacted) + self.assertIn('"name": "MY_VAR"', redacted) + + def test_authorization_code_exchange_body_is_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = ( + "grant_type=authorization_code&client_id=abc&code=authc0de" + "&redirect_uri=http%3A%2F%2Flocalhost%3A9999%2Fcallback&code_verifier=v3rifier" + ) + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("authc0de", redacted) + self.assertNotIn("v3rifier", redacted) + self.assertIn("code=", redacted) + self.assertIn("code_verifier=", redacted) + self.assertIn("grant_type=authorization_code", redacted) + self.assertIn("client_id=abc", redacted) + + def test_token_exchange_subject_token_is_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = ( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange" + "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token" + "&subject_token=oidc.jwt.value" + ) + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("oidc.jwt.value", redacted) + self.assertIn("subject_token=", redacted) + self.assertIn("subject_token_type=urn", redacted) + + def test_bootstrap_api_key_response_is_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = '{"api_key": "fr3shAdminKey"}' + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("fr3shAdminKey", redacted) + self.assertIn('"api_key": ""', redacted) + + def test_json_error_codes_stay_readable(self): + from rsconnect.http_support import _redacted_body_for_log + + body = '{"error": "An object with that name already exists.", "code": 26}' + redacted = str(_redacted_body_for_log(body)) + self.assertIn('"code": 26', redacted) + + def test_streams_are_left_alone(self): + from io import BytesIO + + from rsconnect.http_support import _redacted_body_for_log + + stream = BytesIO(b"client_secret=hunter2") + self.assertIs(_redacted_body_for_log(stream), stream) + + def test_authorization_header_is_redacted(self): + from rsconnect.http_support import _redacted_header_for_log + + self.assertEqual(_redacted_header_for_log("Authorization", "Bearer AAA"), "Bearer ") + self.assertEqual(_redacted_header_for_log("authorization", "Key my-api-key"), "Key ") + self.assertEqual(_redacted_header_for_log("Set-Cookie", "session=abc"), "") + self.assertEqual(_redacted_header_for_log("Content-Type", "application/json"), "application/json") + + def test_all_credential_headers_are_redacted(self): + # shinyapps.io signs requests with X-Auth-Token/X-Auth-Signature and SPCS + # sends the API key as X-RSC-Authorization; none may reach the -vv log. + from rsconnect.http_support import _redacted_header_for_log + + self.assertEqual(_redacted_header_for_log("X-Auth-Token", "tok3n"), "") + self.assertEqual(_redacted_header_for_log("x-rsc-authorization", "my-api-key"), "") + # The signature is the first token of the value, so no scheme survives. + self.assertEqual(_redacted_header_for_log("X-Auth-Signature", "deadbeef; version=1"), "") + + def test_cookie_values_with_spaces_leave_no_first_token(self): + from rsconnect.http_support import _redacted_header_for_log + + self.assertEqual(_redacted_header_for_log("Cookie", "session=abc; other=def"), "") + + def test_a_connection_failure_response_has_a_none_status(self): + # Exception-only responses used to have no status attribute at all, so + # status checks crashed with AttributeError before reaching the + # connection-error handling. + from rsconnect.http_support import HTTPResponse + + response = HTTPResponse("https://example.com/x", exception=OSError("connection refused")) + self.assertIsNone(response.status) + self.assertIsNone(response.reason) + + def test_json_redaction_survives_escaped_quotes(self): + from rsconnect.http_support import _redacted_body_for_log + + body = '{"secrets": [{"name": "V", "value": "with \\" quote and tail"}]}' + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("quote and tail", redacted) + self.assertIn("", redacted) + + def test_presigned_url_query_is_redacted(self): + from rsconnect.http_support import _redacted_uri_for_log + + uri = ( + "/bucket/bundle.tar.gz?X-Amz-Credential=AKIA%2F123&X-Amz-Signature=deadbeef" + "&X-Amz-Security-Token=tok123&X-Amz-Expires=300" + ) + redacted = _redacted_uri_for_log(uri) + self.assertNotIn("deadbeef", redacted) + self.assertNotIn("tok123", redacted) + self.assertNotIn("AKIA", redacted) + self.assertIn("X-Amz-Expires=300", redacted) + + def test_azure_sas_sig_param_is_redacted(self): + # Azure-style presigned URLs carry the signature in a bare "sig" param. + from rsconnect.http_support import _redacted_uri_for_log + + uri = "/bucket/bundle.tar.gz?sv=2024-01-01&sig=Sw%2Fabc123&se=2026-08-13" + redacted = _redacted_uri_for_log(uri) + self.assertNotIn("abc123", redacted) + self.assertIn("sig=", redacted) + self.assertIn("sv=2024-01-01", redacted) + self.assertIn("se=2026-08-13", redacted) + + def test_uri_redaction_is_case_insensitive(self): + from rsconnect.http_support import _redacted_uri_for_log + + self.assertNotIn("hunter2", _redacted_uri_for_log("/path?TOKEN=hunter2&x=1")) + + def test_presigned_urls_inside_json_string_values_are_redacted(self): + from rsconnect.http_support import _redacted_body_for_log + + body = ( + '{"next_revision": {"id": "r1", "source_bundle_upload_url": ' + '"https://up.example/b?token=signed-cred&X-Amz-Signature=deadbeef"}}' + ) + redacted = str(_redacted_body_for_log(body)) + self.assertNotIn("signed-cred", redacted) + self.assertNotIn("deadbeef", redacted) + self.assertIn("up.example", redacted) diff --git a/tests/test_main.py b/tests/test_main.py index 2ad2e6666..76665b8e1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1473,7 +1473,7 @@ def test_add_name_only_missing_server_and_credentials(self): ], ) assert result.exit_code == 1, result.output - assert "`rsconnect add` requires" in str(result.exception) + assert "`rsconnect add` requires" in result.output finally: if original_api_key_value: os.environ["CONNECT_API_KEY"] = original_api_key_value @@ -1497,9 +1497,9 @@ def test_add_shinyapps_missing_options(self): ) assert result.exit_code == 1, result.output assert ( - str(result.exception) - == "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io. \ + "-A/--account, -T/--token, and -S/--secret must all be provided for shinyapps.io. \ See command help for further details." + in result.output ) finally: if original_api_key_value: diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 920379470..3464c7e57 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -10,6 +10,7 @@ ContentBuildStore, ServerStore, _normalize_server_url, + resolve_server_alias, ) from rsconnect.models import BuildStatus @@ -352,6 +353,25 @@ def test_normalize_server_url(self): self.assertEqual("connect_dev", _normalize_server_url("https://connect.dev")) self.assertEqual("connect_dev_6443", _normalize_server_url("https://connect.dev:6443")) + def test_resolve_server_alias(self): + self.assertEqual("https://api.connect.posit.cloud/v1", resolve_server_alias("connect.posit.cloud")) + self.assertEqual("https://api.shinyapps.io", resolve_server_alias("shinyapps.io")) + # Anything else, including an already-resolved URL, is left alone. + self.assertEqual( + "https://api.connect.posit.cloud/v1", resolve_server_alias("https://api.connect.posit.cloud/v1") + ) + self.assertEqual("https://connect.example.com", resolve_server_alias("https://connect.example.com")) + + def test_lookup_by_short_name_finds_the_stored_api_url(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set("cloud", "https://api.connect.posit.cloud/v1", connect_cloud_account_name="acme") + store.set("sa", "https://api.shinyapps.io", account_name="me", token="t", secret="s") + + for short_name, nickname in (("connect.posit.cloud", "cloud"), ("shinyapps.io", "sa")): + entry = store.get_by_url(short_name) + assert entry is not None + self.assertEqual(entry["name"], nickname) + class TestBuildMetadata(TestCase): def setUp(self): diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 8d0ce7e36..7bfc2e223 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -217,6 +217,38 @@ def test_device_code_not_supported(self): with pytest.raises(RSConnectException, match="does not support the device code flow"): login_with_device_code(FAKE_URL, "client-1", metadata) + @patch("rsconnect.oauth.time.sleep") + @patch("rsconnect.oauth.webbrowser.open", return_value=True) + def test_opens_a_browser_by_default(self, mock_open: MagicMock, _, mock_http_server: MagicMock): + mock_http_server.request.side_effect = [ + _make_response(200, {"device_code": "dc", "user_code": "UC", "verification_uri": "https://verify"}), + _make_response(200, {"access_token": "at"}), + ] + login_with_device_code(FAKE_URL, "client-1", FAKE_METADATA) + mock_open.assert_called_once_with("https://verify") + + @patch("rsconnect.oauth.time.sleep") + @patch("rsconnect.oauth.webbrowser.open", return_value=True) + def test_open_browser_false_does_not_open_one(self, mock_open: MagicMock, _, mock_http_server: MagicMock): + # `rsconnect login --use-device-code` relies on this: asking for the + # device flow against Connect usually means there is no usable browser. + mock_http_server.request.side_effect = [ + _make_response(200, {"device_code": "dc", "user_code": "UC", "verification_uri": "https://verify"}), + _make_response(200, {"access_token": "at"}), + ] + login_with_device_code(FAKE_URL, "client-1", FAKE_METADATA, open_browser=False) + mock_open.assert_not_called() + + @patch("rsconnect.oauth.time.sleep") + @patch("rsconnect.oauth.webbrowser.open", return_value=False) + def test_a_browser_that_will_not_open_is_not_fatal(self, _open: MagicMock, _, mock_http_server: MagicMock): + mock_http_server.request.side_effect = [ + _make_response(200, {"device_code": "dc", "user_code": "UC", "verification_uri": "https://verify"}), + _make_response(200, {"access_token": "at"}), + ] + result = login_with_device_code(FAKE_URL, "client-1", FAKE_METADATA) + assert result["access_token"] == "at" + @patch("rsconnect.oauth.time.sleep") def test_poll_success(self, _, mock_http_server: MagicMock): mock_http_server.request.side_effect = [ From 58e096c0d8fdb7d837db9e3a669f8d625c69bf96 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Thu, 13 Aug 2026 14:18:56 -0400 Subject: [PATCH 2/2] fix: repair Connect Cloud log authorization on Python 3.8 `cast(dict[str, Any], ...)` evaluates its first argument at runtime, so `from __future__ import annotations` does not cover it and `dict[...]` raises TypeError on Python 3.8. Describe the response with a TypedDict instead, matching the other ConnectCloudClient methods. --- rsconnect/api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 99e7f0c2a..6d4d7ba0e 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -2884,6 +2884,10 @@ class ConnectCloudLogs(TypedDict): data: list[ConnectCloudLogEntry] +class ConnectCloudAuthorization(TypedDict): + token: NotRequired[str] + + # How many accounts to request per page from GET /accounts. _CONNECT_CLOUD_ACCOUNT_PAGE_SIZE = 100 @@ -3175,8 +3179,8 @@ def authorize_log_channel(self, channel: str) -> str: "resource_id": channel, "permission": "revision.logs:read", } - response = self.post("/authorization", body=body) - data = cast(dict[str, Any], self._server.handle_bad_response(response)) + response = cast(Union[ConnectCloudAuthorization, HTTPResponse], self.post("/authorization", body=body)) + data = self._server.handle_bad_response(response) token = data.get("token") if not token: raise RSConnectException("Posit Connect Cloud did not return a log authorization token.")