From e101baab64f9452948d47837e35a9ef5f20cde0f Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Wed, 12 Aug 2026 08:46:28 -0400 Subject: [PATCH 01/12] 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 02/12] 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.") From 95da59fda860cc54a4ad17ab91ad02cd14fa3644 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Fri, 14 Aug 2026 14:05:49 -0400 Subject: [PATCH 03/12] fix: make Connect Cloud token refresh failures actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConnectCloudClient._attempt_token_refresh logged every failure at debug and returned False, so an expired session or a revoked service account secret surfaced only as the original opaque 401. oauth.py now raises a typed InvalidGrantError when the token endpoint returns error=invalid_grant, carrying the server's error_description. The Cloud client acts on the two credential rejections it can explain: - invalid_grant on the refresh-token path clears connect_cloud_access_token and connect_cloud_refresh_token on the saved servers.json entry (account name/id and nickname are kept) and raises "Your Posit Connect Cloud session has expired and could not be renewed. Authenticate again with `rsconnect add --connect-cloud -n -A `." - invalid_client on the client-credentials path raises a message saying the service account credential was revoked or rotated, pointing at /identity/credentials and the `rsconnect add` command with --client-id/--client-secret. The stored entry is left alone. Everything else — network failures, a rejected CLI OAuth client, unexpected responses — still returns False so the original 401 surfaces, but logs at warning instead of debug, matching the Connect refresh path. The servers.json write-back moved into _persist_tokens so the clearing path reuses the field-preserving update. Connect's refresh is unchanged: its generic `except Exception` already covers the new error type. --- rsconnect/api.py | 107 +++++++++++++++++++------ rsconnect/oauth.py | 27 +++++-- tests/test_connect_cloud.py | 150 +++++++++++++++++++++++++++++++++++- tests/test_oauth.py | 15 ++++ 4 files changed, 269 insertions(+), 30 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 6d4d7ba0e..515da8896 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -12,6 +12,7 @@ import json import os import re +import shlex import sys import tarfile import time @@ -2947,10 +2948,15 @@ def _attempt_token_refresh(self) -> bool: Uses the client credentials grant when a service account credential is stored, and the refresh token otherwise. Returns whether a new token was obtained. + + A credential the auth server rejects outright raises instead, since no + retry will fix it and the caller would otherwise report only the opaque + 401. Transient failures still return False so the original 401 surfaces. """ - from .metadata import ServerStore + from .oauth import InvalidClientError, InvalidGrantError server = self._server + service_account = bool(server.client_id and server.client_secret) try: if server.client_id and server.client_secret: tokens = connect_cloud.login_client_credentials( @@ -2960,12 +2966,32 @@ def _attempt_token_refresh(self) -> bool: tokens = connect_cloud.refresh(server.refresh_token, server.environment) else: return False + except InvalidClientError as exc: + if not service_account: + # This CLI's own OAuth client, not the user's credential. + logger.warning("Posit Connect Cloud token refresh failed: %s" % exc) + return False + raise RSConnectException( + "The Posit Connect Cloud service account credential was rejected — it has been revoked or " + "rotated. Create a new one at %s/identity/credentials, then save it with `%s`." + % (server.urls().auth, self._add_command(service_account=True)) + ) from exc + except InvalidGrantError as exc: + if service_account: + logger.warning("Posit Connect Cloud token refresh failed: %s" % exc) + return False + self._persist_tokens(None, None) + raise RSConnectException( + "Your Posit Connect Cloud session has expired and could not be renewed. " + "Authenticate again with `%s`." % self._add_command() + ) from exc except RSConnectException as exc: - logger.debug("Posit Connect Cloud token refresh failed: %s" % exc) + logger.warning("Posit Connect Cloud token refresh failed: %s" % exc) return False access_token = tokens.get("access_token") if not access_token: + logger.warning("Posit Connect Cloud returned no access token when refreshing the credential.") return False server.access_token = access_token @@ -2973,30 +2999,67 @@ def _attempt_token_refresh(self) -> bool: # so keep the existing one rather than clearing it. server.refresh_token = tokens.get("refresh_token") or server.refresh_token self._apply_authorization() + self._persist_tokens(server.access_token, server.refresh_token) + return True + + def _add_command(self, service_account: bool = False) -> str: + """The `rsconnect add` invocation that would re-save this credential.""" + from .metadata import ServerStore + + server = self._server + # Re-adding must keep the nickname pointed at the saved entry's account, + # not this run's, which may be publishing to a different one via -A. + account = server.account_name if server.server_name: - store = ServerStore() - entry = store.get_by_name(server.server_name) + entry = ServerStore().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, - ) + account = entry.get("connect_cloud_account_name") or account + parts = ["rsconnect add --connect-cloud"] + if server.environment != connect_cloud.DEFAULT_ENVIRONMENT: + # Without the URL, add would authenticate against production. + parts.append("-s %s" % shlex.quote(server.url)) + parts.append("-n %s" % (shlex.quote(server.server_name) if server.server_name else "")) + parts.append("-A %s" % (shlex.quote(account) if account else "")) + if service_account: + parts.append("--client-id --client-secret ") + return " ".join(parts) + + def _persist_tokens(self, access_token: Optional[str], refresh_token: Optional[str]) -> None: + """Write the tokens back to the saved entry, or clear them when both are None. + + Does nothing for a run with no saved entry behind it: a credential + override or a one-shot deploy. + """ + from .metadata import ServerStore - return True + server = self._server + if not server.server_name: + return + + store = ServerStore() + entry = store.get_by_name(server.server_name) + if not entry: + return + + # 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, and omits + # the token fields when they are None. + 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=access_token, + connect_cloud_refresh_token=refresh_token, + ) def get_current_user(self) -> JsonData: response = self.get("/users/me") diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index bf03b8c7f..260a79455 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -37,15 +37,31 @@ def __init__(self) -> None: super().__init__("OAuth client_id is invalid or has been deleted on the server.") +class InvalidGrantError(RSConnectException): + """Raised when the OAuth server returns an invalid_grant error. + + The grant presented — usually a refresh token — has expired, been revoked, + or was issued to another client. Callers decide what to do about it; the + server's ``error_description`` is kept in ``description`` when it sends one. + """ + + def __init__(self, description: Optional[str] = None) -> None: + self.description = description + detail = f": {description}" if description else "." + super().__init__(f"The OAuth grant is invalid, expired, or has been revoked{detail}") + + def _check_oauth_error_response(response: HTTPResponse) -> None: """Check an HTTPResponse for OAuth error codes and raise appropriately.""" if response.json_data and isinstance(response.json_data, dict): - error = response.json_data.get("error", "") + error = str(response.json_data.get("error") or "") + description = str(response.json_data.get("error_description") or "") if error == "invalid_client": raise InvalidClientError() - description = response.json_data.get("error_description", error) - if description: - raise RSConnectException(f"OAuth error: {description}") + if error == "invalid_grant": + raise InvalidGrantError(description or None) + if description or error: + raise RSConnectException(f"OAuth error: {description or error}") def _unwrap_json_response(response: Any) -> dict[str, Any]: @@ -442,7 +458,8 @@ def refresh_access_token( """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. + client_id has been deleted server-side, or InvalidGrantError if the refresh + token has expired or been revoked. """ params = { "grant_type": "refresh_token", diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index b2f459ea9..581818e75 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -25,6 +25,7 @@ from rsconnect.main import cli from rsconnect.metadata import AppStore, ServerData, ServerStore from rsconnect.models import AppModes +from rsconnect.oauth import InvalidClientError, InvalidGrantError from rsconnect.validation import validate_connection_options ENV = ParameterSource.ENVIRONMENT @@ -720,12 +721,30 @@ def _get_user_with_refresh(self, server, mock_target, token_response): 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.""" + def _get_user_with_refresh_failure(self, server, mock_target, error): + """Serve a 401 with the named rsconnect.connect_cloud function raising `error`. + + Returns the exception that reached the caller, so a test can tell an + actionable refresh failure from the original 401 passing through. + """ + 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}", side_effect=error): + with client: + with self.assertRaises(RSConnectException) as raised: + client.get_current_user() + return raised.exception + + def _save_entry(self, name="cloud", **fields): + """Persist a saved 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.set(name, 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)) @@ -851,6 +870,131 @@ def test_a_refresh_does_not_persist_client_credentials_from_the_environment(self self.assertEqual(entry["connect_cloud_client_secret"], "saved-secret") self.assertEqual(entry["connect_cloud_access_token"], "fresh") + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_an_expired_refresh_token_clears_the_stored_tokens_and_says_how_to_reauthenticate(self): + self._save_entry( + connect_cloud_account_id="acct-1", + connect_cloud_access_token="stale", + connect_cloud_refresh_token="rt", + ) + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="cloud") + exception = self._get_user_with_refresh_failure(server, "refresh", InvalidGrantError("token expired")) + + self.assertIn("session has expired", exception.message) + self.assertIn("rsconnect add --connect-cloud -n cloud -A acme", exception.message) + entry = self._stored_entry() + self.assertNotIn("connect_cloud_access_token", entry) + self.assertNotIn("connect_cloud_refresh_token", entry) + # Only the tokens go: the entry is still the credential to re-authenticate. + self.assertEqual(entry["name"], "cloud") + self.assertEqual(entry["connect_cloud_account_name"], "acme") + self.assertEqual(entry["connect_cloud_account_id"], "acct-1") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_an_expired_refresh_token_is_reported_without_a_saved_entry(self): + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt") + with mock.patch("rsconnect.metadata.ServerStore") as store: + exception = self._get_user_with_refresh_failure(server, "refresh", InvalidGrantError()) + + store.assert_not_called() + self.assertIn("session has expired", exception.message) + self.assertIn("rsconnect add --connect-cloud -n -A acme", exception.message) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_the_reauthentication_command_names_the_saved_entrys_account(self): + # The run may be publishing to another account on the same login; + # re-adding must not repoint the nickname at it. + self._save_entry(connect_cloud_account_name="alice", connect_cloud_refresh_token="rt") + server = ConnectCloudServer("team-x", access_token="stale", refresh_token="rt", server_name="cloud") + exception = self._get_user_with_refresh_failure(server, "refresh", InvalidGrantError()) + + self.assertIn("-A alice", exception.message) + self.assertNotIn("team-x", exception.message) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_the_reauthentication_command_keeps_a_non_production_server(self): + # Without the URL, the suggested add would authenticate against production. + staging_api = "https://api.staging.connect.posit.cloud/v1" + httpretty.register_uri(httpretty.GET, f"{staging_api}/users/me", body="", status=401) + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", url=staging_api) + client = ConnectCloudClient(server) + + with mock.patch("rsconnect.connect_cloud.refresh", side_effect=InvalidGrantError()): + with client: + with self.assertRaises(RSConnectException) as raised: + client.get_current_user() + + self.assertIn("-s %s" % staging_api, raised.exception.message) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_the_reauthentication_command_quotes_values_that_need_it(self): + self._save_entry(name="my cloud", connect_cloud_account_name="acme", connect_cloud_refresh_token="rt") + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="my cloud") + exception = self._get_user_with_refresh_failure(server, "refresh", InvalidGrantError()) + + self.assertIn("-n 'my cloud' -A acme", exception.message) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_rejected_service_account_secret_is_reported_and_the_entry_is_left_alone(self): + self._save_entry( + connect_cloud_client_id="cid", + connect_cloud_client_secret="csecret", + connect_cloud_access_token="stale", + ) + server = ConnectCloudServer( + "acme", access_token="stale", client_id="cid", client_secret="csecret", server_name="cloud" + ) + exception = self._get_user_with_refresh_failure(server, "login_client_credentials", InvalidClientError()) + + self.assertIn("service account credential was rejected", exception.message) + self.assertIn("https://login.posit.cloud/identity/credentials", exception.message) + self.assertIn( + "rsconnect add --connect-cloud -n cloud -A acme --client-id --client-secret ", + exception.message, + ) + entry = self._stored_entry() + self.assertEqual(entry["connect_cloud_client_secret"], "csecret") + self.assertEqual(entry["connect_cloud_access_token"], "stale") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_transient_refresh_failure_leaves_the_original_401_and_warns(self): + self._save_entry(connect_cloud_access_token="stale", connect_cloud_refresh_token="rt") + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="cloud") + + with self.assertLogs("rsconnect", level="WARNING") as captured: + exception = self._get_user_with_refresh_failure( + server, "refresh", RSConnectException("Could not connect to https://login.posit.cloud") + ) + + self.assertIn("401", exception.message) + self.assertIn("token refresh failed", "\n".join(captured.output)) + self.assertEqual(self._stored_entry()["connect_cloud_refresh_token"], "rt") + self.assertEqual(len(httpretty.latest_requests()), 1) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_rejected_cli_oauth_client_is_not_reported_as_an_expired_session(self): + # invalid_client on the refresh-token path is about this CLI's own OAuth + # client, not a credential the user can re-save. + self._save_entry(connect_cloud_access_token="stale", connect_cloud_refresh_token="rt") + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="cloud") + + with self.assertLogs("rsconnect", level="WARNING"): + exception = self._get_user_with_refresh_failure(server, "refresh", InvalidClientError()) + + self.assertIn("401", exception.message) + self.assertEqual(self._stored_entry()["connect_cloud_refresh_token"], "rt") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_invalid_grant_from_a_client_credentials_grant_leaves_the_original_401(self): + server = ConnectCloudServer("acme", access_token="stale", client_id="cid", client_secret="csecret") + + with self.assertLogs("rsconnect", level="WARNING"): + exception = self._get_user_with_refresh_failure( + server, "login_client_credentials", InvalidGrantError("no grant") + ) + + self.assertIn("401", exception.message) + class TestConnectCloudAdd(CliTestCase): """CLI-level tests for `rsconnect add -s connect.posit.cloud`.""" diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 7bfc2e223..cda9a275e 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -12,6 +12,7 @@ from rsconnect.metadata import ServerData from rsconnect.oauth import ( InvalidClientError, + InvalidGrantError, _exchange_code_for_token, _poll_for_device_token, discover_oauth_metadata, @@ -290,6 +291,20 @@ def test_invalid_client(self, mock_http_server: MagicMock): with pytest.raises(InvalidClientError): refresh_access_token(FAKE_METADATA, "bad-client", "old-rt") + def test_invalid_grant(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response( + 400, {"error": "invalid_grant", "error_description": "refresh token expired"} + ) + with pytest.raises(InvalidGrantError, match="refresh token expired") as raised: + refresh_access_token(FAKE_METADATA, "client-1", "old-rt") + assert raised.value.description == "refresh token expired" + + def test_invalid_grant_without_a_description(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(400, {"error": "invalid_grant"}) + with pytest.raises(InvalidGrantError, match="revoked") as raised: + refresh_access_token(FAKE_METADATA, "client-1", "old-rt") + assert raised.value.description is None + class TestKeyringIntegration: def test_store_success(self): From a3ef2684cb31b04de6697d705d0a7d9455680b4d Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Fri, 14 Aug 2026 14:36:09 -0400 Subject: [PATCH 04/12] feat: select Posit Connect Cloud credentials by nickname only A saved Connect Cloud entry is a credential, not an account binding: the login behind it can publish to every account its user has rights on. So -A/--account no longer picks which saved credential to use, only where to publish. With one credential saved it is used whatever account is named; with several, -n/--name is now required and the error lists the saved nicknames with the account each publishes to by default. This drops the account-filter branch and its two error paths from ServerStore._get_connect_cloud_server, along with the now-dead account_name argument to get_by_url and resolve. Behavior change: With several saved credentials, -n selects the credential; -A no longer matches against entries and instead always selects the account to publish to, so `-n cred -A other-account` publishes there with that credential. --- docs/CHANGELOG.md | 11 +-- rsconnect/api.py | 24 ++++--- rsconnect/main.py | 7 +- rsconnect/metadata.py | 63 +++++++---------- rsconnect/validation.py | 39 ++++++----- tests/test_connect_cloud.py | 134 +++++++++++++++++++++++++++--------- 6 files changed, 175 insertions(+), 103 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4d9bf0528..6c0b46049 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,17 +19,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `--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. + credential — which publishes to the account it was saved with, or to another + account of the same login when `-A` is given as well. 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`. + the nickname means. A typed `-T/--token` or `-S/--secret` still conflicts 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 diff --git a/rsconnect/api.py b/rsconnect/api.py index 515da8896..32792fafa 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1577,12 +1577,12 @@ def setup_remote_server( server_data = ServerData(None, None, False) else: try: - server_data = store.resolve(name, url, account_name) + server_data = store.resolve(name, url) except RSConnectException: - # Several saved Connect Cloud entries can make the lookup ambiguous, + # Several saved Connect Cloud credentials 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 + # A single saved credential 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) @@ -1653,11 +1653,19 @@ def setup_remote_server( # 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. + # Also deferred: a lone -A was allowed through when a nickname or + # default server might have resolved to Connect Cloud. It did not, so + # -A means the shinyapps account again -- which a nickname already + # names, and which the all-or-nothing rule below governs otherwise. + # Both are judged on the pre-merge values, or a default shinyapps + # entry's token and secret would satisfy the rule and deploy the typed + # account with borrowed credentials. + if name and supplied_account_name: + raise RSConnectException( + "-n/--name cannot be specified in conjunction with -A/--account, unless the nickname \ +names a Posit Connect Cloud credential, where -A selects the account to publish to. \ +See command help for further details." + ) if supplied_account_name and not (supplied_token and supplied_secret): raise RSConnectException( "-A/--account, -T/--token, and -S/--secret must all be provided \ diff --git a/rsconnect/main.py b/rsconnect/main.py index c9faa9753..e371ef8ad 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -287,7 +287,9 @@ def cloud_shinyapps_args(func: Callable[P, T]) -> Callable[P, T]: envvar=["SHINYAPPS_ACCOUNT"], 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.)", +Posit Connect Cloud; each applies only to its own target.) For Posit Connect Cloud this \ +is the account to publish to, and may accompany -n/--name to publish to an account other \ +than the one the credential was saved with.", ) @click.option( "--token", @@ -358,7 +360,8 @@ def connect_cloud_account_arg(func: Callable[P, T]) -> Callable[P, T]: "--account", "-A", help="The Posit Connect Cloud account to deploy to. (Also settable via the \ -CONNECT_CLOUD_ACCOUNT environment variable.)", +CONNECT_CLOUD_ACCOUNT environment variable.) May accompany -n/--name to publish to an \ +account other than the one the credential was saved with.", ) @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs): diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index c54e1869b..4bcc185ab 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -370,28 +370,26 @@ def get_by_name(self, name: str): """ return self._get_by_key(name) - def get_by_url(self, url: str, account_name: Optional[str] = None): + def get_by_url(self, url: str): """ Get the server information for the given 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. + :raises RSConnectException: if several Posit Connect Cloud credentials share + the URL, since only a nickname can tell them apart. """ 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_connect_cloud_server(target) 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. + command line or can come from a saved credential's default target. """ if not url: return False @@ -408,45 +406,32 @@ def _connect_cloud_servers(self, url: str) -> list[ServerDataDict]: 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. + def _get_connect_cloud_server(self, url: str): + """Pick the Posit Connect Cloud credential 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. + the URL does not identify one entry. An entry is a credential, not an account + binding: the login behind it can publish to every account its user has rights + on, so -A/--account selects the publish target and only the nickname selects + the credential. With several saved the choice is reported rather than guessed + at, because they may hold different credentials (an interactive login and a + service account, or two identities) which refresh differently. """ 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] + # The account each credential publishes to by default, so the nicknames can + # be recognized. saved = ", ".join( - '%s (nickname "%s")' % (entry.get("connect_cloud_account_name"), entry.get("name")) for entry in candidates + '"%s" (account %s)' % (entry.get("name"), entry.get("connect_cloud_account_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 + "Several Posit Connect Cloud credentials are saved: %s. Use -n/--name to choose one, adding " + "-A/--account to publish to a different account, or pass --client-id and --client-secret to " + "use a service account credential directly." % saved ) def get_all_servers(self): @@ -590,8 +575,8 @@ def remove_by_url(self, url: str): arbitrary one. :param url: the Connect URL of the server to remove. - :raises RSConnectException: if several Posit Connect Cloud entries share - the URL. + :raises RSConnectException: if several Posit Connect Cloud credentials + share the URL. """ entry = self.get_by_url(url) if entry is None: @@ -620,7 +605,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], account_name: Optional[str] = None) -> ServerData: + def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: """ This function will resolve the given inputs into a set of server information. It assumes that either `name` or `url` is provided. @@ -638,8 +623,6 @@ def resolve(self, name: Optional[str], url: Optional[str], account_name: Optiona :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. """ @@ -648,7 +631,7 @@ def resolve(self, name: Optional[str], url: Optional[str], account_name: Optiona if not entry: raise RSConnectException('The nickname, "%s", does not exist.' % name) elif url: - entry = self.get_by_url(url, account_name) + entry = self.get_by_url(url) else: entry = self.get_default() if entry is None and self.count() == 1: diff --git a/rsconnect/validation.py b/rsconnect/validation.py index bab2aabf7..43791b9df 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -184,15 +184,17 @@ def validate_connection_options( # `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 + # A typed -T/--token or -S/--secret contradicts 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. + # entry either. -A/--account is not judged here at all: a nickname may name a + # Posit Connect Cloud credential, where -A selects the account to publish to. + # The executor raises the conflict once the nickname is known not to be one. 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",)) + ) + _get_present_options({"-T/--token": token, "-S/--secret": secret}, ctx, ignore_sources=("ENVIRONMENT",)) if name and present_options_mutually_exclusive_with_name: name_source = get_parameter_source_name_from_ctx("name", ctx) @@ -278,25 +280,28 @@ def validate_connection_options( 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 + # A lone -A alongside a nickname or a default server cannot be judged yet: + # either 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 raises 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_saved_server = bool( + account_name and not token and not secret and (has_default_server or 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 - ) + # With a nickname, not even a typed one is judged here: its -A may be a + # Connect Cloud publish target, and this rule would report the mistake as a + # shinyapps.io conflict. That case fails after resolution too, from the Cloud + # check above or the executor's -n/-A conflict. + if lone_account_with_saved_server: + connect_conflicts = [] if name else _get_present_options(connect_options, ctx, ignore_sources=("ENVIRONMENT",)) + else: + connect_conflicts = present_connect_options if connect_conflicts and present_shinyapps_options: raise RSConnectException( f"Connect options ({', '.join(connect_conflicts)}) may not be passed \ @@ -318,7 +323,7 @@ def validate_connection_options( ) if present_shinyapps_options: - if len(present_shinyapps_options) != len(shinyapps_options) and not lone_account_with_default: + if len(present_shinyapps_options) != len(shinyapps_options) and not lone_account_with_saved_server: 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_connect_cloud.py b/tests/test_connect_cloud.py index 581818e75..2ca0d99de 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1404,12 +1404,69 @@ def test_env_shinyapps_credentials_do_not_block_or_merge_into_a_nickname_deploy( ) self.assertEqual(server.account_name, "acme") - def test_a_typed_shinyapps_option_still_conflicts_with_a_nickname(self): + def test_a_typed_shinyapps_token_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") + _setup_remote_server(ctx=_ctx(token=TYPED), name="cloud", token="typed-token") self.assertIn("cannot be specified in conjunction", str(context.exception)) +class TestNicknameWithATypedAccount(unittest.TestCase): + """-A alongside -n is only meaningful for Connect Cloud, where the nickname + names the credential and -A the account to publish to. For every other target + the nickname already names the account, so the combination is a conflict -- + judged after resolution, since only the store knows which target it is.""" + + def test_a_typed_account_selects_the_target_account_of_a_cloud_nickname(self): + server = _cloud_server( + ctx=_ctx(account=TYPED), + resolve=_cloud_entry(connect_cloud_account_id="acct-acme", connect_cloud_access_token="at"), + name="cloud", + account_name="typed-acct", + ) + self.assertEqual(server.account_name, "typed-acct") + self.assertEqual(server.access_token, "at") + # The saved id belongs to the saved account, so publishing elsewhere resolves + # the name against the server instead. + self.assertIsNone(server.account_id) + + def test_a_typed_account_still_conflicts_with_a_connect_nickname(self): + store = ServerStore(base_dir=tempfile.mkdtemp()) + store.set("prod", "https://connect.example.com", api_key="key") + with self.assertRaises(RSConnectException) as context: + _setup_remote_server(ctx=_ctx(account=TYPED), store=store, name="prod", account_name="typed-acct") + self.assertIn("cannot be specified in conjunction", str(context.exception)) + + def test_a_typed_account_cannot_borrow_a_shinyapps_nicknames_credentials(self): + # The nickname's token and secret belong to its own account, so a typed -A + # must not deploy somewhere else with them. + with self.assertRaises(RSConnectException) as context: + _setup_remote_server( + ctx=_ctx(account=TYPED), + resolve=ServerData( + "shiny", + "https://api.shinyapps.io", + True, + account_name="saved-acct", + token="saved-token", + secret="saved-secret", + ), + name="shiny", + account_name="other-acct", + ) + self.assertIn("cannot be specified in conjunction", str(context.exception)) + + def test_connect_options_with_a_cloud_nickname_are_reported_against_connect_cloud(self): + with self.assertRaises(RSConnectException) as context: + _setup_remote_server( + ctx=_ctx(account=TYPED, insecure=TYPED), + resolve=_cloud_entry(connect_cloud_access_token="at"), + name="cloud", + account_name="typed-acct", + insecure=True, + ) + self.assertIn("alongside Posit Connect Cloud", 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 @@ -2601,8 +2658,8 @@ def test_flag_selects_connect_cloud_in_the_executor(self): class TestConnectCloudSameAccountAmbiguity(unittest.TestCase): - """Several entries for the same account may hold different credentials, so - -A must not silently pick one of them.""" + """Several credentials may share a default account (an interactive login and a + service account, say), so the account cannot pick one of them.""" def _store(self): store = ServerStore(base_dir=tempfile.mkdtemp()) @@ -2610,10 +2667,10 @@ def _store(self): 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): + def test_lookup_by_url_with_several_credentials_is_rejected(self): store = self._store() with self.assertRaises(RSConnectException) as context: - store.get_by_url("connect.posit.cloud", "acme") + store.get_by_url("connect.posit.cloud") message = str(context.exception) self.assertIn('"cloud-a"', message) self.assertIn('"cloud-b"', message) @@ -2664,7 +2721,7 @@ def test_lookup_by_variant_urls_finds_the_saved_entry(self): "HTTPS://API.CONNECT.POSIT.CLOUD/v1", "connect.posit.cloud/", ): - entry = store.get_by_url(variant, "acme") + entry = store.get_by_url(variant) assert entry is not None, variant self.assertEqual(entry["name"], "cloud", variant) @@ -2809,9 +2866,9 @@ def test_a_saved_staging_server_is_used_when_staging_is_selected(self): 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.""" + """A saved entry is a credential, not an account binding: -A/--account says where + to publish and only -n/--name picks the credential. A single saved credential is + used whatever the account; several are ambiguous until a nickname names one.""" def setUp(self): env_patch = mock.patch.dict(os.environ, {}, clear=True) @@ -2833,6 +2890,14 @@ def _two(self): def _server(self, store, **kwargs): return _cloud_server(store=store, **kwargs) + def test_nothing_saved_resolves_to_no_credential(self): + self.assertIsNone(self._store().get_by_url(connect_cloud.SERVER_NAME)) + + def test_one_saved_credential_is_found_by_url(self): + entry = self._one().get_by_url(connect_cloud.SERVER_NAME) + assert entry is not None + self.assertEqual(entry["name"], "cloud") + 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") @@ -2852,35 +2917,42 @@ def test_add_still_requires_the_account_when_a_server_is_saved(self): 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_the_account_does_not_select_among_several_credentials(self): + # The account a credential was saved with is its default publish target, not + # its scope, so naming an account cannot say which credential to use. + for account in ("acme-team", "sam", "stranger"): + with self.assertRaises(RSConnectException) as context: + self._server(self._two(), use_connect_cloud=True, account_name=account) + self.assertIn("-n/--name", str(context.exception)) - def test_several_saved_servers_and_no_account_is_an_error(self): + def test_several_saved_credentials_are_listed_with_their_accounts(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) + self.assertIn("Several Posit Connect Cloud credentials are saved", message) + self.assertIn('"ci" (account acme-team)', message) + self.assertIn('"personal" (account sam)', 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_a_nickname_publishes_to_another_account_of_the_same_credential(self): + store = self._two() + store.set( + "personal", + API, + connect_cloud_account_name="sam", + connect_cloud_account_id="acct-sam", + connect_cloud_access_token="sam-token", + ) + server = self._server(store, name="personal", environ={"CONNECT_CLOUD_ACCOUNT": "acme-team"}) + + self.assertEqual(server.access_token, "sam-token") + self.assertEqual(server.account_name, "acme-team") + self.assertIsNone(server.account_id) + 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 @@ -2896,14 +2968,14 @@ def test_remove_reports_the_ambiguity_instead_of_deleting_one(self): 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.assertIn("Several Posit Connect Cloud credentials 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") + entry = store.get_by_url("https://connect.example.com") assert entry is not None self.assertEqual(entry["name"], "prod") From 159a22ce881daec3ce1079588dfd7f64dc12a388 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Fri, 14 Aug 2026 15:42:05 -0400 Subject: [PATCH 05/12] feat: keep Posit Connect Cloud credentials in the system keyring Connect Cloud tokens and service account client secrets now go to the system keyring, keyed "#" because every Connect Cloud entry records the same API URL. `rsconnect add` and token refresh write there when a keyring is available and leave the matching servers.json fields out, which moves the secrets of an entry saved before this change out of the file on its next add or refresh. Reads prefer the keyring and fall back to those fields, so a machine without a usable keyring (a CI runner) keeps working as before, and `rsconnect server remove` deletes the entries for the removed nickname. `rsconnect list` reports which of the two holds the credentials. The keyring helpers in oauth.py now take the entry key explicitly. Posit Connect keeps passing the bare server URL, so its ":access_token" and ":refresh_token" usernames are unchanged and existing logins are untouched. Tests get a conftest fixture that makes the keyring unavailable by default, since the module is installed in the test environment and would otherwise reach the machine's real keychain. --- docs/deploying.md | 6 + rsconnect/api.py | 35 ++++- rsconnect/connect_cloud.py | 85 ++++++++++- rsconnect/main.py | 33 ++++- rsconnect/metadata.py | 44 +++++- rsconnect/oauth.py | 136 ++++++++++++++---- tests/conftest.py | 15 ++ tests/test_connect_cloud.py | 279 ++++++++++++++++++++++++++++++++++++ tests/test_oauth.py | 95 ++++++++++++ 9 files changed, 682 insertions(+), 46 deletions(-) create mode 100644 tests/conftest.py diff --git a/docs/deploying.md b/docs/deploying.md index 6b05a26a0..079a09171 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -553,6 +553,12 @@ Stored information files are stored in a platform-specific directory: Remembered server information is stored in the `servers.json` file in that directory. +OAuth credentials — the tokens from `rsconnect login` for Posit Connect, and the +tokens and service account client secret for Posit Connect Cloud — are stored in the +system keyring when one is available. On a machine without a usable keyring, such as a +CI runner, they are kept in `servers.json` instead, which is written with owner-only +permissions. + ### Deployment Data After a deployment is completed, information about the deployment is saved diff --git a/rsconnect/api.py b/rsconnect/api.py index 32792fafa..0201064b8 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3034,7 +3034,11 @@ def _add_command(self, service_account: bool = False) -> str: return " ".join(parts) def _persist_tokens(self, access_token: Optional[str], refresh_token: Optional[str]) -> None: - """Write the tokens back to the saved entry, or clear them when both are None. + """Write the tokens back to the saved credential, or clear them when both are None. + + Prefers the system keyring, falling back to the tokens' fields in + servers.json. A keyring write also scrubs those fields, which is how an + entry saved before keyring support moves its tokens out of the file. Does nothing for a run with no saved entry behind it: a credential override or a one-shot deploy. @@ -3050,6 +3054,27 @@ def _persist_tokens(self, access_token: Optional[str], refresh_token: Optional[s if not entry: return + entry_url = entry["url"] + # A client secret still in the file predates keyring support, so it moves + # with the tokens -- unless the keyring already holds one, which is the + # secret in use and must not be overwritten by the file's older copy. + file_client_secret = entry.get("connect_cloud_client_secret") + keyring_readable, keyring_client_secret = connect_cloud.client_secret_from_keyring( + entry_url, server.server_name + ) + migrating_secret = bool(file_client_secret) and keyring_readable and not keyring_client_secret + if migrating_secret: + in_keyring = connect_cloud.store_credentials_in_keyring( + entry_url, server.server_name, access_token, refresh_token, file_client_secret + ) + else: + in_keyring = connect_cloud.store_tokens_in_keyring( + entry_url, server.server_name, access_token, refresh_token + ) + # The file's copy only goes once the keyring is known to hold a secret; a + # read that failed says nothing about what is in there. + secret_in_keyring = in_keyring and (migrating_secret or bool(keyring_client_secret)) + # 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 @@ -3060,13 +3085,13 @@ def _persist_tokens(self, access_token: Optional[str], refresh_token: Optional[s # the token fields when they are None. store.set( server.server_name, - server.url, + entry_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=access_token, - connect_cloud_refresh_token=refresh_token, + connect_cloud_client_secret=None if secret_in_keyring else file_client_secret, + connect_cloud_access_token=None if in_keyring else access_token, + connect_cloud_refresh_token=None if in_keyring else refresh_token, ) def get_current_user(self) -> JsonData: diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index 100e14a59..f32ed1468 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -13,7 +13,18 @@ from urllib.parse import urlparse from .exception import RSConnectException -from .oauth import login_with_device_code, refresh_access_token, request_client_credentials_token +from .oauth import ( + ACCESS_TOKEN_FIELD, + CLIENT_SECRET_FIELD, + REFRESH_TOKEN_FIELD, + keyring_delete_values, + keyring_get_value, + keyring_read_value, + keyring_store_values, + 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. @@ -230,3 +241,75 @@ def refresh(refresh_token: str, environment: Optional[str] = None) -> dict[str, refresh_token=refresh_token, scope=SCOPE, ) + + +_CREDENTIAL_FIELDS = (ACCESS_TOKEN_FIELD, REFRESH_TOKEN_FIELD, CLIENT_SECRET_FIELD) + + +def keyring_key(url: str, nickname: str) -> str: + """The system keyring key for a saved Connect Cloud credential. + + Every Connect Cloud entry records the same API URL, so the nickname is what + makes the key unique. Posit Connect keys its entries by URL alone and must + keep doing so, or existing logins stop finding their tokens. + """ + return "%s#%s" % (url, nickname) + + +def store_credentials_in_keyring( + url: str, + nickname: str, + access_token: Optional[str], + refresh_token: Optional[str], + client_secret: Optional[str], +) -> bool: + """Store a saved credential's secrets in the system keyring, replacing all of them. + + Returns False when no keyring is available, which means the caller has to keep + the secrets in servers.json instead. + """ + return keyring_store_values( + keyring_key(url, nickname), + { + ACCESS_TOKEN_FIELD: access_token, + REFRESH_TOKEN_FIELD: refresh_token, + CLIENT_SECRET_FIELD: client_secret, + }, + ) + + +def store_tokens_in_keyring(url: str, nickname: str, access_token: Optional[str], refresh_token: Optional[str]) -> bool: + """Store (or, for empty values, delete) a saved credential's tokens. + + Leaves any stored client secret alone: a token refresh rotates the tokens + only, and `rsconnect add` is what changes the credential itself. + """ + return keyring_store_values( + keyring_key(url, nickname), + {ACCESS_TOKEN_FIELD: access_token, REFRESH_TOKEN_FIELD: refresh_token}, + ) + + +def client_secret_from_keyring(url: str, nickname: str) -> tuple[bool, Optional[str]]: + """The stored service account client secret, and whether the keyring could be read. + + A read failure is not the same as no secret: overwriting one that may be there + with an older copy from servers.json would break the credential. + """ + return keyring_read_value(keyring_key(url, nickname), CLIENT_SECRET_FIELD) + + +def credentials_from_keyring(url: str, nickname: str) -> tuple[Optional[str], Optional[str], Optional[str]]: + """A saved credential's secrets from the system keyring, as + (access token, refresh token, client secret). Each is None when absent.""" + key = keyring_key(url, nickname) + return ( + keyring_get_value(key, ACCESS_TOKEN_FIELD), + keyring_get_value(key, REFRESH_TOKEN_FIELD), + keyring_get_value(key, CLIENT_SECRET_FIELD), + ) + + +def delete_credentials_from_keyring(url: str, nickname: str) -> None: + """Delete a saved credential's secrets from the system keyring.""" + keyring_delete_values(keyring_key(url, nickname), _CREDENTIAL_FIELDS) diff --git a/rsconnect/main.py b/rsconnect/main.py index e371ef8ad..9a57d2210 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -936,19 +936,34 @@ def add( account = cast(str, account) cloud_server = _connect_cloud_login(account, client_id, client_secret, url=server) + # The keyring holds the secrets when there is one, and the entry keeps only + # the account and client id. servers.json (written 0600) carries them + # otherwise, for machines with no usable keyring such as CI runners. + in_keyring = connect_cloud_auth.store_credentials_in_keyring( + cloud_server.url, + name, + cloud_server.access_token, + cloud_server.refresh_token, + cloud_server.client_secret, + ) 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, + connect_cloud_client_secret=None if in_keyring else cloud_server.client_secret, + connect_cloud_access_token=None if in_keyring else cloud_server.access_token, + connect_cloud_refresh_token=None if in_keyring else 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)) + if not in_keyring: + click.secho( + "Note: keyring not available; credentials stored in local file (chmod 600).", + fg="yellow", + ) elif token: server = cast(str, server) account = cast(str, account) @@ -1040,7 +1055,12 @@ def list_servers(verbose: int): 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"): + access, _, client_secret = connect_cloud_auth.credentials_from_keyring( + server["url"], server["name"] + ) + if access or client_secret: + click.echo(" Credentials stored in system keyring") + elif server.get("connect_cloud_access_token") or server.get("connect_cloud_client_secret"): click.echo(" Credentials are saved") if server.get("api_key"): click.echo(" API key is saved") @@ -1163,6 +1183,11 @@ def remove( else: raise RSConnectException("You must specify one of -n/--name or -s/--server.") + # Removing the entry leaves any keyring secrets orphaned, since nothing + # else records the nickname they are keyed by. + if entry and entry.get("connect_cloud_account_name"): + connect_cloud_auth.delete_credentials_from_keyring(entry["url"], entry["name"]) + if message: click.echo(message) if removed_was_default: diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 4bcc185ab..168ce28bb 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -434,6 +434,29 @@ def _get_connect_cloud_server(self, url: str): "use a service account credential directly." % saved ) + def _connect_cloud_secrets(self, entry: ServerDataDict) -> tuple[Optional[str], Optional[str], Optional[str]]: + """A Posit Connect Cloud entry's secrets, as (access, refresh, client secret). + + `rsconnect add` and token refresh store these in the system keyring when + there is one, leaving the fields in this file empty; those fields are the + fallback for machines without a usable keyring. Entries for other targets + have no Connect Cloud secrets to look for. + """ + access = entry.get("connect_cloud_access_token") + refresh = entry.get("connect_cloud_refresh_token") + client_secret = entry.get("connect_cloud_client_secret") + if not entry.get("connect_cloud_account_name"): + return access, refresh, client_secret + + keyring_access, keyring_refresh, keyring_secret = connect_cloud.credentials_from_keyring( + entry["url"], entry["name"] + ) + return ( + keyring_access or access, + keyring_refresh or refresh, + keyring_secret or client_secret, + ) + def get_all_servers(self): """ Returns a list of all known servers sorted by nickname. @@ -531,9 +554,10 @@ def set( 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. + # Callers pass the tokens only when they could not be stored in the + # system keyring; this file, written 0600, is the fallback for machines + # without one -- 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: @@ -558,6 +582,13 @@ def set( entry["default"] = True self._set(name, entry) # type: ignore + # Nothing records the URL a replaced Connect Cloud credential's keyring + # values are keyed by any more, so they would be both unreachable and able + # to shadow a later credential saved under the same nickname and URL. + if existing and existing.get("connect_cloud_account_name"): + if not connect_cloud_account_name or existing["url"] != url: + connect_cloud.delete_credentials_from_keyring(existing["url"], name) + def remove_by_name(self, name: str): """ Remove the server information for the given nickname. @@ -638,6 +669,7 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: entry = self._get_first_value() if entry: + cloud_access_token, cloud_refresh_token, cloud_client_secret = self._connect_cloud_secrets(entry) return ServerData( name or entry["name"], entry["url"], @@ -656,9 +688,9 @@ def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData: 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"), + connect_cloud_client_secret=cloud_client_secret, + connect_cloud_access_token=cloud_access_token, + connect_cloud_refresh_token=cloud_refresh_token, ) else: return ServerData( diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 260a79455..0b1817ef7 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -14,7 +14,7 @@ import time import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer as _HTTPServer -from typing import Any, Dict, Optional, Tuple, cast +from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, cast from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import click @@ -639,60 +639,136 @@ def _token_exchange_error(status: Optional[int], data: dict[str, Any]) -> RSConn # --------------------------------------------------------------------------- -def keyring_store_token(server_url: str, access_token: str, refresh_token: Optional[str]) -> bool: - """Store OAuth tokens in the system keyring. +ACCESS_TOKEN_FIELD = "access_token" +REFRESH_TOKEN_FIELD = "refresh_token" +CLIENT_SECRET_FIELD = "client_secret" - Returns True on success, False if keyring is not available. + +def keyring_store_values(key: str, values: Mapping[str, Optional[str]]) -> bool: + """Store credential values in the system keyring, deleting the empty ones. + + Entries are named ":". A Posit Connect caller passes the bare + server URL as the key: that format is frozen, because changing it would make + every existing `rsconnect login` miss its keychain entry. Callers that share + one URL across several saved credentials -- Posit Connect Cloud -- pass a key + that includes the nickname. + + Returns True on success, False if keyring is not available, which is the + caller's signal to fall back to servers.json. """ + # Only the import itself means "no keyring here": keyring imports its backend + # lazily, so an ImportError from set_password is a failure of one that exists. try: import keyring # type: ignore[import-untyped] - - keyring.set_password(_KEYRING_SERVICE, f"{server_url}:access_token", access_token) - if refresh_token: - keyring.set_password(_KEYRING_SERVICE, f"{server_url}:refresh_token", refresh_token) - else: - try: - keyring.delete_password(_KEYRING_SERVICE, f"{server_url}:refresh_token") - except keyring.errors.PasswordDeleteError: - pass - return True except ImportError: return False + + try: + for field, value in values.items(): + username = f"{key}:{field}" + if value: + keyring.set_password(_KEYRING_SERVICE, username, value) + else: + try: + keyring.delete_password(_KEYRING_SERVICE, username) + except keyring.errors.PasswordDeleteError: + pass + return True except Exception as e: logger.warning(f"keyring storage failed: {e}") + # The caller now writes all of these values to servers.json, and reads + # prefer the keyring, so a field written before the failure would shadow + # the file with a value that no longer belongs to the others. Returning + # False is only safe once they are gone. + if not keyring_delete_values(key, values): + raise RSConnectException( + "Could not store credentials in the system keyring, and the entries written before the " + "failure could not be removed either. Delete the rsconnect-python entries from your " + "keyring, then save the credential again." + ) from e return False -def keyring_get_tokens(server_url: str) -> Tuple[Optional[str], Optional[str]]: - """Retrieve OAuth tokens from the system keyring. +def keyring_read_value(key: str, field: str) -> Tuple[bool, Optional[str]]: + """Retrieve one credential value, saying whether the keyring could be read. - Returns (access_token, refresh_token), or (None, None) if unavailable. + A caller that decides what to write based on what is already stored needs to + tell "nothing stored" from "could not look": a machine with no keyring at all + knowably has nothing, but a keyring that raises could have anything. """ try: import keyring # type: ignore[import-untyped] - - access = keyring.get_password(_KEYRING_SERVICE, f"{server_url}:access_token") - refresh = keyring.get_password(_KEYRING_SERVICE, f"{server_url}:refresh_token") - return access, refresh except ImportError: - return None, None + return True, None + + try: + return True, keyring.get_password(_KEYRING_SERVICE, f"{key}:{field}") except Exception as e: logger.warning(f"keyring retrieval failed: {e}") - return None, None + return False, None -def keyring_delete_tokens(server_url: str) -> None: - """Delete OAuth tokens from the system keyring.""" +def keyring_get_value(key: str, field: str) -> Optional[str]: + """Retrieve one credential value from the system keyring, or None.""" + return keyring_read_value(key, field)[1] + + +def keyring_delete_values(key: str, fields: Iterable[str]) -> bool: + """Delete credential values from the system keyring. + + Returns whether the values are gone, which they also are when there is no + keyring holding them or they were never stored. + """ try: import keyring # type: ignore[import-untyped] + except ImportError: + return True + + try: + # Inside this block, not above: keyring itself imported, so there may be + # values in it, and without its errors module the "nothing was stored" case + # cannot be told from a deletion that failed. import keyring.errors # type: ignore[import-untyped] - for suffix in (":access_token", ":refresh_token"): + deleted = True + for field in fields: + username = f"{key}:{field}" try: - keyring.delete_password(_KEYRING_SERVICE, f"{server_url}{suffix}") + keyring.delete_password(_KEYRING_SERVICE, username) except keyring.errors.PasswordDeleteError: - pass - except ImportError: - pass + # The deletion did not happen. Usually because there was nothing + # stored, which is the state the caller is after, so check. + try: + if keyring.get_password(_KEYRING_SERVICE, username) is not None: + deleted = False + except Exception as e: + logger.warning(f"keyring deletion failed: {e}") + deleted = False + except Exception as e: + logger.warning(f"keyring deletion failed: {e}") + deleted = False + return deleted except Exception as e: logger.warning(f"keyring deletion failed: {e}") + return False + + +def keyring_store_token(key: str, access_token: Optional[str], refresh_token: Optional[str]) -> bool: + """Store OAuth tokens in the system keyring. + + Returns True on success, False if keyring is not available. + """ + return keyring_store_values(key, {ACCESS_TOKEN_FIELD: access_token, REFRESH_TOKEN_FIELD: refresh_token}) + + +def keyring_get_tokens(key: str) -> Tuple[Optional[str], Optional[str]]: + """Retrieve OAuth tokens from the system keyring. + + Returns (access_token, refresh_token), or (None, None) if unavailable. + """ + return keyring_get_value(key, ACCESS_TOKEN_FIELD), keyring_get_value(key, REFRESH_TOKEN_FIELD) + + +def keyring_delete_tokens(key: str) -> None: + """Delete OAuth tokens from the system keyring.""" + keyring_delete_values(key, (ACCESS_TOKEN_FIELD, REFRESH_TOKEN_FIELD)) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..9b9f11e8d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def no_system_keyring(monkeypatch: pytest.MonkeyPatch): + """Make the system keyring unavailable to every test. + + `keyring` is installed in the test environment (twine depends on it), so + without this the credential paths would read and write the machine's real + keychain. Tests that exercise keyring storage replace `sys.modules["keyring"]` + with a mock of their own, or patch the helpers in `rsconnect.oauth`. + """ + monkeypatch.setitem(sys.modules, "keyring", None) diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 2ca0d99de..1422fd65a 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -2,6 +2,7 @@ import io import json import os +import sys import tarfile import tempfile import unittest @@ -448,6 +449,37 @@ def _skip_account_check(test): test.addCleanup(patch.stop) +class FakeKeyring: + """A stand-in for the keyring module, backed by a dict.""" + + class errors: + class PasswordDeleteError(Exception): + pass + + def __init__(self): + self.passwords: Dict[Any, str] = {} + + def set_password(self, service: str, username: str, password: str) -> None: + self.passwords[(service, username)] = password + + def get_password(self, service: str, username: str) -> Optional[str]: + return self.passwords.get((service, username)) + + def delete_password(self, service: str, username: str) -> None: + if (service, username) not in self.passwords: + raise self.errors.PasswordDeleteError() + del self.passwords[(service, username)] + + +def _use_fake_keyring(test: unittest.TestCase) -> FakeKeyring: + """Give one test a working keyring; conftest makes it unavailable by default.""" + fake = FakeKeyring() + patch = mock.patch.dict(sys.modules, {"keyring": fake, "keyring.errors": fake.errors}) + patch.start() + test.addCleanup(patch.stop) + return fake + + class CliTestCase(unittest.TestCase): """A CliRunner against a temporary server store and a clean environment.""" @@ -1019,6 +1051,13 @@ def test_add_interactive_stores_tokens(self): ) self.assertIn("Posit Connect Cloud", result.output) + def test_add_says_where_the_credentials_went_without_a_keyring(self): + self._mock_device_login() + result = self.runner.invoke(cli, ["add", "-n", "cloud", "--connect-cloud", "-A", "acme"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("keyring not available", 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. @@ -2980,6 +3019,246 @@ def test_a_connect_server_url_is_unaffected(self): self.assertEqual(entry["name"], "prod") +class TestConnectCloudKeyringStorage(CliTestCase): + """Connect Cloud secrets go to the system keyring when there is one, keyed by URL + and nickname because one URL covers every Connect Cloud credential. The + servers.json fields stay as the fallback for machines without a usable keyring.""" + + def setUp(self): + super().setUp() + self.keyring = _use_fake_keyring(self) + self.base_dir = os.path.dirname(self.store.get_path()) + # Token write-back opens its own store, inside the function, from this module. + store_patch = mock.patch("rsconnect.metadata.ServerStore", lambda: ServerStore(base_dir=self.base_dir)) + store_patch.start() + self.addCleanup(store_patch.stop) + + def _stored(self, field: str, nickname: str = "cloud") -> Optional[str]: + return self.keyring.get_password("rsconnect-python", "%s#%s:%s" % (API, nickname, field)) + + def _store_in_keyring(self, field: str, value: str, nickname: str = "cloud") -> None: + self.keyring.set_password("rsconnect-python", "%s#%s:%s" % (API, nickname, field), value) + + def _saved_entry(self, nickname: str = "cloud") -> Any: + entry = ServerStore(base_dir=self.base_dir).get_by_name(nickname) + assert entry is not None + return entry + + def _refresh(self, **kwargs: Any) -> bool: + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="cloud") + client = ConnectCloudClient(server) + with mock.patch("rsconnect.connect_cloud.refresh", **kwargs): + return client._attempt_token_refresh() + + def test_add_stores_the_tokens_in_the_keyring_and_not_in_the_file(self): + self._mock_device_login() + result = self.runner.invoke(cli, ["add", "-n", "cloud", "--connect-cloud", "-A", "acme"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(self._stored("access_token"), "at") + self.assertEqual(self._stored("refresh_token"), "rt") + entry = self._saved_entry() + self.assertNotIn("connect_cloud_access_token", entry) + self.assertNotIn("connect_cloud_refresh_token", entry) + self.assertEqual(entry["connect_cloud_account_name"], "acme") + + def test_add_stores_a_service_account_secret_in_the_keyring(self): + with mock.patch( + "rsconnect.connect_cloud.request_client_credentials_token", return_value={"access_token": "at"} + ): + result = self.runner.invoke( + cli, + ["add", "-n", "cloud", "--connect-cloud", "-A", "acme", "--client-id", "cid", "--client-secret", "sec"], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(self._stored("client_secret"), "sec") + entry = self._saved_entry() + self.assertNotIn("connect_cloud_client_secret", entry) + # The client id is not a secret, and is what `list` shows to identify the credential. + self.assertEqual(entry["connect_cloud_client_id"], "cid") + + def test_each_nickname_keys_its_own_entries(self): + self._mock_device_login() + for nickname in ("cloud", "other"): + result = self.runner.invoke(cli, ["add", "-n", nickname, "--connect-cloud", "-A", "acme"]) + self.assertEqual(result.exit_code, 0, result.output) + + self.assertEqual(self._stored("access_token"), "at") + self.assertEqual(self._stored("access_token", nickname="other"), "at") + self.assertEqual(len(self.keyring.passwords), 4) + + def test_the_keyring_wins_over_the_fields_left_in_the_file(self): + # An entry saved before keyring support keeps its plaintext fields until the + # next add or refresh; whatever the keyring holds is used meanwhile. + self.store.set( + "cloud", + API, + connect_cloud_account_name="acme", + connect_cloud_access_token="file-at", + connect_cloud_refresh_token="file-rt", + connect_cloud_client_secret="file-secret", + ) + self._store_in_keyring("access_token", "keyring-at") + + data = self.store.resolve("cloud", None) + + self.assertEqual(data.connect_cloud_access_token, "keyring-at") + self.assertEqual(data.connect_cloud_refresh_token, "file-rt") + self.assertEqual(data.connect_cloud_client_secret, "file-secret") + + def test_a_refresh_moves_the_tokens_out_of_the_file(self): + self.store.set( + "cloud", + API, + connect_cloud_account_name="acme", + connect_cloud_access_token="stale", + connect_cloud_refresh_token="rt", + ) + + self.assertTrue(self._refresh(return_value={"access_token": "new-at", "refresh_token": "new-rt"})) + + self.assertEqual(self._stored("access_token"), "new-at") + self.assertEqual(self._stored("refresh_token"), "new-rt") + entry = self._saved_entry() + self.assertNotIn("connect_cloud_access_token", entry) + self.assertNotIn("connect_cloud_refresh_token", entry) + + def test_a_refresh_moves_a_client_secret_out_of_the_file_too(self): + self.store.set( + "cloud", + API, + connect_cloud_account_name="acme", + connect_cloud_client_id="cid", + connect_cloud_client_secret="file-secret", + connect_cloud_refresh_token="rt", + ) + + self.assertTrue(self._refresh(return_value={"access_token": "new-at"})) + + self.assertEqual(self._stored("client_secret"), "file-secret") + entry = self._saved_entry() + self.assertNotIn("connect_cloud_client_secret", entry) + self.assertEqual(entry["connect_cloud_client_id"], "cid") + + def test_a_refresh_keeps_a_client_secret_that_is_already_in_the_keyring(self): + self.store.set("cloud", API, connect_cloud_account_name="acme", connect_cloud_client_id="cid") + self._store_in_keyring("client_secret", "keyring-secret") + + self.assertTrue(self._refresh(return_value={"access_token": "new-at"})) + + self.assertEqual(self._stored("client_secret"), "keyring-secret") + + def test_a_refresh_does_not_overwrite_the_keyring_secret_with_the_files(self): + # A secret in both places means the file's copy predates the one in use; the + # keyring is what reads prefer, so it stays and the stale copy goes. + self.store.set( + "cloud", + API, + connect_cloud_account_name="acme", + connect_cloud_client_id="cid", + connect_cloud_client_secret="file-secret", + ) + self._store_in_keyring("client_secret", "keyring-secret") + + self.assertTrue(self._refresh(return_value={"access_token": "new-at"})) + + self.assertEqual(self._stored("client_secret"), "keyring-secret") + self.assertNotIn("connect_cloud_client_secret", self._saved_entry()) + + def test_a_keyring_read_failure_leaves_the_client_secret_where_it_is(self): + # A read that failed says nothing about what the keyring holds, so the + # file's copy is neither written over it nor dropped from the file. + self.store.set( + "cloud", + API, + connect_cloud_account_name="acme", + connect_cloud_client_id="cid", + connect_cloud_client_secret="file-secret", + connect_cloud_refresh_token="rt", + ) + secret_username = "%s#cloud:%s" % (API, "client_secret") + stored = self.keyring.get_password + + def failing_read(service: str, username: str) -> Optional[str]: + if username == secret_username: + raise Exception("keychain locked") + return stored(service, username) + + with mock.patch.object(self.keyring, "get_password", side_effect=failing_read): + self.assertTrue(self._refresh(return_value={"access_token": "new-at"})) + + self.assertIsNone(self._stored("client_secret")) + self.assertEqual(self._saved_entry()["connect_cloud_client_secret"], "file-secret") + + def test_replacing_a_credential_discards_its_keyring_values(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + self._store_in_keyring("access_token", "at") + + self.store.set("cloud", "https://connect.example.com", api_key="key") + + self.assertEqual(self.keyring.passwords, {}) + + def test_moving_a_credential_to_another_environment_discards_the_old_values(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + self._store_in_keyring("access_token", "at") + + self.store.set("cloud", "https://api.staging.connect.posit.cloud/v1", connect_cloud_account_name="acme") + + self.assertEqual(self.keyring.passwords, {}) + + def test_resaving_the_same_credential_keeps_its_keyring_values(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + self._store_in_keyring("access_token", "at") + + self.store.set("cloud", API, connect_cloud_account_name="other-account") + + self.assertEqual(self._stored("access_token"), "at") + + def test_an_expired_refresh_token_clears_the_keyring_tokens(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + self._store_in_keyring("access_token", "stale") + self._store_in_keyring("refresh_token", "rt") + self._store_in_keyring("client_secret", "sec") + + with self.assertRaises(RSConnectException): + self._refresh(side_effect=InvalidGrantError()) + + self.assertIsNone(self._stored("access_token")) + self.assertIsNone(self._stored("refresh_token")) + # Only the dead tokens go; the credential itself is what gets re-authenticated. + self.assertEqual(self._stored("client_secret"), "sec") + + def test_remove_deletes_the_keyring_entries(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + for field in ("access_token", "refresh_token", "client_secret"): + self._store_in_keyring(field, field + "-value") + + result = self.runner.invoke(cli, ["remove", "-n", "cloud"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(self.keyring.passwords, {}) + + def test_remove_by_url_deletes_the_keyring_entries(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + self._store_in_keyring("access_token", "at") + + result = self.runner.invoke(cli, ["remove", "-s", connect_cloud.SERVER_NAME]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(self.keyring.passwords, {}) + + def test_list_reports_credentials_in_the_keyring(self): + self.store.set("cloud", API, connect_cloud_account_name="acme") + self._store_in_keyring("access_token", "at") + + result = self.runner.invoke(cli, ["list"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("Credentials stored in system keyring", result.output) + self.assertNotIn("Credentials are saved", result.output) + + class TestConnectCloudVisibility(unittest.TestCase): def _executor(self, visibility=None, server=None): executor = RSConnectExecutor.__new__(RSConnectExecutor) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index cda9a275e..7599ba11d 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -19,7 +19,9 @@ exchange_token_for_api_key, generate_pkce_pair, keyring_delete_tokens, + keyring_delete_values, keyring_get_tokens, + keyring_read_value, keyring_store_token, login_with_browser, login_with_device_code, @@ -306,6 +308,10 @@ def test_invalid_grant_without_a_description(self, mock_http_server: MagicMock): assert raised.value.description is None +class _PasswordDeleteError(Exception): + """Stands in for keyring.errors.PasswordDeleteError, which means "nothing stored".""" + + class TestKeyringIntegration: def test_store_success(self): mock_keyring = MagicMock() @@ -333,6 +339,95 @@ def test_get_no_keyring(self): assert access is None assert refresh is None + def test_a_failure_partway_through_leaves_nothing_behind(self): + # The caller falls back to storing every value in servers.json, and reads + # prefer the keyring, so a field written before the failure would shadow it. + mock_keyring = MagicMock() + mock_keyring.set_password.side_effect = [None, Exception("keychain locked")] + mock_keyring_errors = MagicMock() + mock_keyring_errors.PasswordDeleteError = _PasswordDeleteError + with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": mock_keyring_errors}): + result = keyring_store_token("https://example.com", "at-1", "rt-1") + + assert result is False + assert [call.args[1] for call in mock_keyring.delete_password.call_args_list] == [ + "https://example.com:access_token", + "https://example.com:refresh_token", + ] + + def test_a_cleanup_that_leaves_the_value_behind_is_reported(self): + # PasswordDeleteError says the deletion did not happen, which is only the + # state the caller needs when there was nothing there to delete. + mock_keyring = MagicMock() + mock_keyring.set_password.side_effect = [None, Exception("keychain locked")] + mock_keyring.delete_password.side_effect = _PasswordDeleteError() + mock_keyring.get_password.return_value = "at-1" + mock_keyring_errors = MagicMock() + mock_keyring_errors.PasswordDeleteError = _PasswordDeleteError + with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": mock_keyring_errors}): + with pytest.raises(RSConnectException, match="could not be removed"): + keyring_store_token("https://example.com", "at-1", "rt-1") + + def test_a_read_failure_is_not_reported_as_an_absent_value(self): + mock_keyring = MagicMock() + mock_keyring.get_password.side_effect = Exception("keychain locked") + with patch.dict("sys.modules", {"keyring": mock_keyring}): + readable, value = keyring_read_value("https://example.com", "access_token") + + assert (readable, value) == (False, None) + + def test_no_keyring_at_all_reads_as_an_absent_value(self): + with patch.dict("sys.modules", {"keyring": None}): + assert keyring_read_value("https://example.com", "access_token") == (True, None) + + def test_deletion_without_the_keyring_errors_module_is_a_failure(self): + # keyring itself imported, so there may be values in it, and its errors + # module is what tells "nothing was stored" from a deletion that failed. + mock_keyring = MagicMock() + with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": None}): + assert keyring_delete_values("https://example.com", ("access_token",)) is False + + def test_deletion_without_a_keyring_at_all_succeeds(self): + with patch.dict("sys.modules", {"keyring": None}): + assert keyring_delete_values("https://example.com", ("access_token",)) is True + + def test_a_backend_import_error_is_a_read_failure(self): + # keyring imports its backend lazily, so an ImportError from get_password + # comes from a keyring that exists and could hold anything. + mock_keyring = MagicMock() + mock_keyring.get_password.side_effect = ImportError("no backend module") + with patch.dict("sys.modules", {"keyring": mock_keyring}): + assert keyring_read_value("https://example.com", "access_token") == (False, None) + + def test_a_failure_that_cannot_be_cleaned_up_is_reported(self): + # Falling back to servers.json would leave the written field shadowing it, + # so this is not something the caller can carry on from. + mock_keyring = MagicMock() + mock_keyring.set_password.side_effect = [None, Exception("keychain locked")] + mock_keyring.delete_password.side_effect = Exception("keychain locked") + mock_keyring_errors = MagicMock() + mock_keyring_errors.PasswordDeleteError = _PasswordDeleteError + with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": mock_keyring_errors}): + with pytest.raises(RSConnectException, match="could not be removed"): + keyring_store_token("https://example.com", "at-1", "rt-1") + + def test_connect_entries_are_named_by_url_alone(self): + # Frozen format: any change here makes every existing `rsconnect login` miss + # its keychain entry and orphans the old one. + mock_keyring = MagicMock() + with patch.dict("sys.modules", {"keyring": mock_keyring}): + keyring_store_token("https://connect.example.com", "at-1", "rt-1") + keyring_get_tokens("https://connect.example.com") + + assert [call.args for call in mock_keyring.set_password.call_args_list] == [ + ("rsconnect-python", "https://connect.example.com:access_token", "at-1"), + ("rsconnect-python", "https://connect.example.com:refresh_token", "rt-1"), + ] + assert [call.args for call in mock_keyring.get_password.call_args_list] == [ + ("rsconnect-python", "https://connect.example.com:access_token"), + ("rsconnect-python", "https://connect.example.com:refresh_token"), + ] + def test_delete_success(self): mock_keyring = MagicMock() mock_keyring_errors = MagicMock() From 25a7e975ce89c436c7aedbb28e46947062f20c5d Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Fri, 14 Aug 2026 16:53:40 -0400 Subject: [PATCH 06/12] refactor: share the 401 refresh-and-retry skeleton between both clients RSConnectClient and ConnectCloudClient each had their own copy of "send the request, on 401 mint a new token, send it once more". Both now inherit it from BearerTokenHTTPServer, which calls the subclass's _attempt_token_refresh to mint and apply the token and asks _can_refresh_token whether there is anything to mint from -- false for an API key, a bootstrap JWT, or a Snowflake token exchange. Connect Cloud gains the seekable-body rewind that only the Connect copy had, so a streamed body is not sent empty on the retry. The minting stays per-target, unchanged: Connect keeps discovery against its registered client and the InvalidClientError re-registration recovery, and Connect Cloud keeps the client-credentials-versus-refresh choice and its typed error handling. The keyring-with-servers.json-fallback load and write-back is already the same code on both sides, differing only in the key it is given; what remains target-specific is Connect's token expiry tracking and Connect Cloud's field-preserving write-back, which it skips for a run with no saved entry. Connect's three copies of "find the entry this server came from" become ServerStore.saved_entry. No behavior change other than the added rewind; every existing test passes unmodified. --- rsconnect/api.py | 74 ++++++++----------------------- rsconnect/http_support.py | 51 ++++++++++++++++++++++ rsconnect/metadata.py | 11 +++++ rsconnect/oauth.py | 43 +++++++++++++++++- tests/test_connect_cloud.py | 87 +++++++++++++++++++++++++++++++++++++ tests/test_oauth.py | 87 ++++++++++++++++++++++++++++++++++--- tests/utils.py | 29 +++++++++++++ 7 files changed, 317 insertions(+), 65 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 0201064b8..07c3f98fe 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -60,6 +60,7 @@ from .environment import fake_module_file_from_directory from .exception import DeploymentFailedException, RSConnectException from .http_support import ( + BearerTokenHTTPServer, CookieJar, HTTPResponse, HTTPServer, @@ -501,7 +502,7 @@ def server_supports_draft_deploy(server_version: Optional[str]) -> bool: return False -class RSConnectClient(HTTPServer): +class RSConnectClient(BearerTokenHTTPServer): def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None): if cookies is None: cookies = server.cookie_jar @@ -533,30 +534,10 @@ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: O ): self.authorization(f"Bearer {server.oauth_access_token}") - 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": - can_retry = isinstance(self._server, RSConnectServer) and bool(self._server.oauth_client_id) - start_pos: "int | None" = None - if can_retry and hasattr(body, "read"): - if getattr(body, "seekable", lambda: False)(): - start_pos = body.tell() # type: ignore[union-attr] - else: - body = body.read() # type: ignore[union-attr] - response = super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType] - if can_retry and isinstance(response, HTTPResponse) and response.status == 401: - if self._attempt_token_refresh(): - if start_pos is not None: - body.seek(start_pos) # type: ignore[union-attr] - return super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType] - return response + def _can_refresh_token(self) -> bool: + # An API key, a bootstrap JWT, or a Snowflake token exchange has nothing to + # mint a new credential from; only an OAuth login does. + return isinstance(self._server, RSConnectServer) and bool(self._server.oauth_client_id) def _attempt_token_refresh(self) -> bool: from .oauth import ( @@ -572,14 +553,11 @@ def _attempt_token_refresh(self) -> bool: server = cast(RSConnectServer, self._server) + # The keyring is where a login stores its tokens; the entry's own fields are + # the fallback for a machine without one. _, refresh_token = keyring_get_tokens(server.url) if not refresh_token: - store = ServerStore() - entry = None - if server.server_name: - entry = store.get_by_name(server.server_name) - if not entry: - entry = store.get_by_url(server.url) + entry = ServerStore().saved_entry(server.server_name, server.url) if entry: refresh_token = entry.get("oauth_refresh_token") # type: ignore[assignment] if not refresh_token: @@ -594,11 +572,7 @@ def _attempt_token_refresh(self) -> bool: # Client was deleted server-side; clear stale tokens and re-register keyring_delete_tokens(server.url) store = ServerStore() - entry = None - if server.server_name: - entry = store.get_by_name(server.server_name) - if not entry: - entry = store.get_by_url(server.url) + entry = store.saved_entry(server.server_name, server.url) if entry: entry_name = str(entry.get("name", server.server_name or server.url)) store.update_oauth_tokens(entry_name, None, None, None) @@ -630,11 +604,7 @@ def _attempt_token_refresh(self) -> bool: stored = keyring_store_token(server.url, new_access, new_refresh) if not stored: store = ServerStore() - entry = None - if server.server_name: - entry = store.get_by_name(server.server_name) - if not entry: - entry = store.get_by_url(server.url) + entry = store.saved_entry(server.server_name, server.url) if entry: entry_name = str(entry.get("name", server.server_name or server.url)) store.update_oauth_tokens(entry_name, new_access, new_refresh, new_expiry) @@ -2908,7 +2878,7 @@ class ConnectCloudAuthorization(TypedDict): _CONNECT_CLOUD_PUBLISH_PERMISSION = "content:create" -class ConnectCloudClient(HTTPServer): +class ConnectCloudClient(BearerTokenHTTPServer): """ An HTTP client to call the Posit Connect Cloud API. @@ -2934,21 +2904,11 @@ def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse: 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 _can_refresh_token(self) -> bool: + # The same two credentials _attempt_token_refresh mints from. Without either + # there is no retry to prepare a request body for. + server = self._server + return bool(server.refresh_token or (server.client_id and server.client_secret)) def _attempt_token_refresh(self) -> bool: """Mint a new access token and apply it to this client. diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index aced96619..34be9bc74 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -652,6 +652,57 @@ def _inject_cookies(self): del self._headers["Cookie"] +class BearerTokenHTTPServer(HTTPServer): + """An HTTPServer whose requests carry an OAuth access token. + + When a token expires the server answers 401, so the response is handled by + minting a new token and sending the request once more. Subclasses provide the + minting in `_attempt_token_refresh`, which also applies the new token to this + client, and say in `_can_refresh_token` whether there is anything to mint from. + """ + + def _can_refresh_token(self) -> bool: + return True + + def _attempt_token_refresh(self) -> bool: + """Mint a new access token and apply it to this client. + + Returns whether a new token was obtained. Raises for a credential that no + retry could fix, rather than leaving the caller with the opaque 401. + """ + raise NotImplementedError + + 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: + if not self._can_refresh_token(): + return super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType] + + start_pos: int | None = None + if hasattr(body, "read"): + # The first attempt consumes a streamed body (a bundle upload), so a + # retry has to rewind it -- or hold it in memory when it cannot seek. + if getattr(body, "seekable", lambda: False)(): + start_pos = body.tell() # type: ignore[union-attr] + else: + body = body.read() # type: ignore[union-attr] + + 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(): + if start_pos is not None: + body.seek(start_pos) # type: ignore[union-attr] + return super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType] + return response + + class CookieJar(object): @staticmethod def from_dict(source: dict[str, Any]): diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 168ce28bb..f08783dcc 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -385,6 +385,17 @@ def get_by_url(self, url: str): return self._get_connect_cloud_server(target) return self._get_by_value_attr("url", target) + def saved_entry(self, name: Optional[str], url: str) -> Optional[ServerDataDict]: + """The entry a server in hand came from: by its nickname, else by its URL. + + A one-shot target has no nickname, and neither does one resolved before the + nickname was carried on the server, so the URL is the fallback. + """ + entry = self.get_by_name(name) if name else None + if entry is None: + entry = self.get_by_url(url) + return entry + def has_connect_cloud_account(self, url: Optional[str]) -> bool: """Whether any Posit Connect Cloud credential is saved for this URL. diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 0b1817ef7..3ce8bc601 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -644,6 +644,27 @@ def _token_exchange_error(status: Optional[int], data: dict[str, Any]) -> RSConn CLIENT_SECRET_FIELD = "client_secret" +def _no_keyring_errors() -> tuple[type[BaseException], ...]: + """The exceptions that mean this machine has no keyring, for an `except` clause. + + keyring raises NoKeyringError from every operation when no backend is usable, + which is the normal state of a CI runner with the package installed. It is a + sibling of PasswordSetError and PasswordDeleteError under KeyringError, not a + subclass of either, so nothing catches it unless it is named here -- and being + treated as a keyring that failed rather than one that is absent is what broke the + servers.json fallback. Other KeyringError subclasses stay in the failure bucket: + a locked or half-initialized backend may well hold credentials. + + Empty, matching nothing, when the errors module will not import: the failure + bucket is the safe answer when the two cannot be told apart. + """ + try: + from keyring.errors import NoKeyringError # type: ignore[import-untyped] + except ImportError: + return () + return (NoKeyringError,) + + def keyring_store_values(key: str, values: Mapping[str, Optional[str]]) -> bool: """Store credential values in the system keyring, deleting the empty ones. @@ -674,6 +695,11 @@ def keyring_store_values(key: str, values: Mapping[str, Optional[str]]) -> bool: except keyring.errors.PasswordDeleteError: pass return True + except _no_keyring_errors() as e: + # Nothing was stored, so there is no partial write to clean up below -- and + # the cleanup would fail the same way, which used to make this raise. + logger.debug(f"no system keyring available: {e}") + return False except Exception as e: logger.warning(f"keyring storage failed: {e}") # The caller now writes all of these values to servers.json, and reads @@ -693,8 +719,9 @@ def keyring_read_value(key: str, field: str) -> Tuple[bool, Optional[str]]: """Retrieve one credential value, saying whether the keyring could be read. A caller that decides what to write based on what is already stored needs to - tell "nothing stored" from "could not look": a machine with no keyring at all - knowably has nothing, but a keyring that raises could have anything. + tell "nothing stored" from "could not look": a machine with no keyring, or none + with a usable backend, knowably has nothing, but a backend that failed could + have anything. """ try: import keyring # type: ignore[import-untyped] @@ -703,6 +730,9 @@ def keyring_read_value(key: str, field: str) -> Tuple[bool, Optional[str]]: try: return True, keyring.get_password(_KEYRING_SERVICE, f"{key}:{field}") + except _no_keyring_errors() as e: + logger.debug(f"no system keyring available: {e}") + return True, None except Exception as e: logger.warning(f"keyring retrieval failed: {e}") return False, None @@ -730,6 +760,7 @@ def keyring_delete_values(key: str, fields: Iterable[str]) -> bool: # cannot be told from a deletion that failed. import keyring.errors # type: ignore[import-untyped] + no_keyring = _no_keyring_errors() deleted = True for field in fields: username = f"{key}:{field}" @@ -741,13 +772,21 @@ def keyring_delete_values(key: str, fields: Iterable[str]) -> bool: try: if keyring.get_password(_KEYRING_SERVICE, username) is not None: deleted = False + except no_keyring: + raise except Exception as e: logger.warning(f"keyring deletion failed: {e}") deleted = False + except no_keyring: + # Answered for every field at once, below. + raise except Exception as e: logger.warning(f"keyring deletion failed: {e}") deleted = False return deleted + except _no_keyring_errors() as e: + logger.debug(f"no system keyring available: {e}") + return True except Exception as e: logger.warning(f"keyring deletion failed: {e}") return False diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 1422fd65a..cb9e31534 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -22,6 +22,7 @@ RSConnectExecutor, ) from rsconnect.exception import DeploymentFailedException, RSConnectException +from rsconnect.http_support import HTTPResponse, HTTPServer from rsconnect.log import VERBOSE from rsconnect.main import cli from rsconnect.metadata import AppStore, ServerData, ServerStore @@ -29,6 +30,8 @@ from rsconnect.oauth import InvalidClientError, InvalidGrantError from rsconnect.validation import validate_connection_options +from .utils import failing_keyring + ENV = ParameterSource.ENVIRONMENT TYPED = ParameterSource.COMMANDLINE @@ -1028,6 +1031,67 @@ def test_invalid_grant_from_a_client_credentials_grant_leaves_the_original_401(s self.assertIn("401", exception.message) +class TestConnectCloudStreamBodyRetry(unittest.TestCase): + """The retry-once skeleton is shared with the Posit Connect client, so a streamed + body is rewound before the retry here too rather than arriving empty.""" + + def _attempt_bodies(self, body: Any, server: Optional[ConnectCloudServer] = None, read: bool = True) -> list[Any]: + """The body each attempt was given, read out when `read`, with refresh stubbed.""" + client = ConnectCloudClient(server or ConnectCloudServer("acme", access_token="stale", refresh_token="rt")) + seen: list[Any] = [] + + def fake_request( + _self: Any, + method: str, + path: str, + query_params: Any = None, + body: Any = None, + maximum_redirects: int = 5, + decode_response: bool = True, + headers: Any = None, + ) -> Any: + seen.append(body.read() if read and hasattr(body, "read") else body) + response = mock.Mock(spec=HTTPResponse) + response.status = 401 if len(seen) == 1 else 200 + return response + + with mock.patch.object(HTTPServer, "request", fake_request): + with mock.patch.object(client, "_attempt_token_refresh", return_value=True): + client.request("POST", "/contents", body=body) + return seen + + def _retry_bodies(self, body: Any) -> list[Any]: + return self._attempt_bodies(body) + + def test_a_seekable_stream_is_rewound(self): + self.assertEqual(self._retry_bodies(io.BytesIO(b"payload")), [b"payload", b"payload"]) + + def test_a_stream_is_left_alone_when_there_is_nothing_to_refresh_with(self): + # No refresh token and no service account credential: nothing can be minted, + # so the request is sent once and its body is neither buffered nor rewound. + stream = io.BytesIO(b"payload") + seen = self._attempt_bodies(stream, server=ConnectCloudServer("acme", access_token="at"), read=False) + + self.assertEqual(seen, [stream]) + + def test_a_stream_that_cannot_seek_is_read_into_memory(self): + class NonSeekableStream(io.RawIOBase): + def __init__(self, data: bytes): + self._data = data + + def read(self, size: int = -1) -> bytes: + data, self._data = self._data, b"" + return data + + def readable(self) -> bool: + return True + + def seekable(self) -> bool: + return False + + self.assertEqual(self._retry_bodies(NonSeekableStream(b"payload")), [b"payload", b"payload"]) + + class TestConnectCloudAdd(CliTestCase): """CLI-level tests for `rsconnect add -s connect.posit.cloud`.""" @@ -1058,6 +1122,29 @@ def test_add_says_where_the_credentials_went_without_a_keyring(self): self.assertEqual(result.exit_code, 0, result.output) self.assertIn("keyring not available", result.output) + def test_add_falls_back_to_the_file_when_no_keyring_backend_is_usable(self): + # A CI runner has keyring installed with nothing behind it, which is the case + # the servers.json fallback exists for; it used to abort the command instead. + self._mock_device_login() + with failing_keyring(): + result = self.runner.invoke(cli, ["add", "-n", "cloud", "--connect-cloud", "-A", "acme"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("keyring not available", result.output) + entry = self.store.get_by_name("cloud") + assert entry is not None + self.assertEqual(entry["connect_cloud_access_token"], "at") + self.assertEqual(entry["connect_cloud_refresh_token"], "rt") + + def test_a_deploy_reads_the_credentials_back_without_a_keyring_backend(self): + self._mock_device_login() + with failing_keyring(): + self.runner.invoke(cli, ["add", "-n", "cloud", "--connect-cloud", "-A", "acme"]) + data = self.store.resolve("cloud", None) + + self.assertEqual(data.connect_cloud_access_token, "at") + self.assertEqual(data.connect_cloud_refresh_token, "rt") + 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. diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 7599ba11d..2f2c29cb1 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -10,6 +10,8 @@ from rsconnect.exception import RSConnectException from rsconnect.http_support import HTTPResponse from rsconnect.metadata import ServerData + +from .utils import failing_keyring from rsconnect.oauth import ( InvalidClientError, InvalidGrantError, @@ -312,6 +314,22 @@ class _PasswordDeleteError(Exception): """Stands in for keyring.errors.PasswordDeleteError, which means "nothing stored".""" +class _NoKeyringError(Exception): + """Stands in for keyring.errors.NoKeyringError, which means "no usable backend".""" + + +def _mock_keyring_errors() -> MagicMock: + """A stand-in for the keyring.errors module. + + The classes it names have to be real exceptions, since the code under test catches + them. + """ + errors = MagicMock() + errors.PasswordDeleteError = _PasswordDeleteError + errors.NoKeyringError = _NoKeyringError + return errors + + class TestKeyringIntegration: def test_store_success(self): mock_keyring = MagicMock() @@ -344,8 +362,7 @@ def test_a_failure_partway_through_leaves_nothing_behind(self): # prefer the keyring, so a field written before the failure would shadow it. mock_keyring = MagicMock() mock_keyring.set_password.side_effect = [None, Exception("keychain locked")] - mock_keyring_errors = MagicMock() - mock_keyring_errors.PasswordDeleteError = _PasswordDeleteError + mock_keyring_errors = _mock_keyring_errors() with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": mock_keyring_errors}): result = keyring_store_token("https://example.com", "at-1", "rt-1") @@ -362,8 +379,7 @@ def test_a_cleanup_that_leaves_the_value_behind_is_reported(self): mock_keyring.set_password.side_effect = [None, Exception("keychain locked")] mock_keyring.delete_password.side_effect = _PasswordDeleteError() mock_keyring.get_password.return_value = "at-1" - mock_keyring_errors = MagicMock() - mock_keyring_errors.PasswordDeleteError = _PasswordDeleteError + mock_keyring_errors = _mock_keyring_errors() with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": mock_keyring_errors}): with pytest.raises(RSConnectException, match="could not be removed"): keyring_store_token("https://example.com", "at-1", "rt-1") @@ -405,8 +421,7 @@ def test_a_failure_that_cannot_be_cleaned_up_is_reported(self): mock_keyring = MagicMock() mock_keyring.set_password.side_effect = [None, Exception("keychain locked")] mock_keyring.delete_password.side_effect = Exception("keychain locked") - mock_keyring_errors = MagicMock() - mock_keyring_errors.PasswordDeleteError = _PasswordDeleteError + mock_keyring_errors = _mock_keyring_errors() with patch.dict("sys.modules", {"keyring": mock_keyring, "keyring.errors": mock_keyring_errors}): with pytest.raises(RSConnectException, match="could not be removed"): keyring_store_token("https://example.com", "at-1", "rt-1") @@ -437,6 +452,66 @@ def test_delete_success(self): assert mock_keyring.delete_password.call_count == 2 +class TestNoKeyringBackend: + """keyring installed with no usable backend is the CI-runner case the servers.json + fallback exists for. Its fail backend raises NoKeyringError, which sits beside + PasswordDeleteError under KeyringError rather than under it, so it only counts as + "no keyring" by being named.""" + + def test_storing_falls_back_to_the_file(self): + with failing_keyring(): + assert keyring_store_token("https://example.com", "at-1", "rt-1") is False + + def test_storing_does_not_try_to_clean_up_after_itself(self): + # A backend that reports itself absent stored nothing, so there is no partial + # write to remove -- and the removal would fail the same way, which used to + # turn the fallback into a hard error. + with failing_keyring(): + with patch("rsconnect.oauth.keyring_delete_values") as cleanup: + assert keyring_store_token("https://example.com", "at-1", "rt-1") is False + cleanup.assert_not_called() + + def test_reading_reports_nothing_stored_rather_than_unreadable(self): + with failing_keyring(): + assert keyring_read_value("https://example.com", "access_token") == (True, None) + + def test_deleting_has_nothing_to_delete(self): + with failing_keyring(): + assert keyring_delete_values("https://example.com", ("access_token", "refresh_token")) is True + + @patch("rsconnect.oauth.login_with_browser") + @patch("rsconnect.oauth.register_client", return_value="new-client-id") + @patch("rsconnect.oauth.discover_oauth_metadata") + def test_login_stores_its_tokens_in_the_file( + self, + mock_discover: MagicMock, + mock_register: MagicMock, + mock_login: MagicMock, + ): + import tempfile + + from click.testing import CliRunner + + from rsconnect.main import cli + from rsconnect.metadata import ServerStore + + mock_discover.return_value = FAKE_METADATA + mock_login.return_value = {"access_token": "at-1", "refresh_token": "rt-1", "expires_in": 3600} + + store = ServerStore(base_dir=tempfile.mkdtemp()) + runner = CliRunner() + with patch("rsconnect.main.server_store", store): + with failing_keyring(): + result = runner.invoke(cli, ["login", "--server", FAKE_URL, "--name", "test-server"]) + + assert result.exit_code == 0, result.output + assert "keyring not available" in result.output + entry = store.get_by_name("test-server") + assert entry is not None + assert entry["oauth_access_token"] == "at-1" + assert entry["oauth_refresh_token"] == "rt-1" + + class TestLoginWithBrowser: @patch("rsconnect.oauth.webbrowser.open", return_value=True) @patch("rsconnect.oauth._exchange_code_for_token") diff --git a/tests/utils.py b/tests/utils.py index 274c62e38..e89c4c0d9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,12 +2,41 @@ import os import jwt import re +from contextlib import contextmanager from os.path import join, dirname, exists from packaging import version +from unittest import mock import pytest from rsconnect.api import RSConnectServer, RSConnectClient +# Captured while this module is imported, which is before the conftest fixture hides +# keyring from tests: failing_keyring() needs the real modules back. +try: + import keyring as _keyring + import keyring.backends.fail as _keyring_fail + import keyring.errors as _keyring_errors +except ImportError: # pragma: no cover + _keyring = None + + +@contextmanager +def failing_keyring(): + """Run with keyring installed and its fail backend active. + + That is the shape of a CI runner: the package is there, no backend is usable, and + every operation raises NoKeyringError. Credentials have to land in servers.json. + """ + if _keyring is None: # pragma: no cover + pytest.skip("keyring is not installed") + previous = _keyring.get_keyring() + _keyring.set_keyring(_keyring_fail.Keyring()) + try: + with mock.patch.dict(sys.modules, {"keyring": _keyring, "keyring.errors": _keyring_errors}): + yield + finally: + _keyring.set_keyring(previous) + def apply_common_args(args: list, server=None, key=None, cacert=None, insecure=False): if server: From 9a5f49ecc21a260956562e039738c871385fe23c Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Mon, 17 Aug 2026 15:14:48 -0400 Subject: [PATCH 07/12] fix: restore Python 3.8 test collection for test_connect_cloud The stream-body retry tests annotate returns as list[Any], which 3.8 evaluates at class-definition time and rejects. Deferring annotation evaluation with the __future__ import fixes collection for the file. --- tests/test_connect_cloud.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index cb9e31534..7f75a11ff 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib import io import json From b9eaa4be2141526cf791394769f3b9b48552189a Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Mon, 17 Aug 2026 15:31:58 -0400 Subject: [PATCH 08/12] fix: restore fixture teardown order broken by the keyring conftest The autouse no_system_keyring fixture requested monkeypatch, hoisting the shared per-test instance ahead of every test-level fixture. Its undo then ran after those fixtures' cleanup, so a test using monkeypatch.chdir into a TemporaryDirectory had the directory deleted while it was still the working directory, which Windows rejects (WinError 32 in test_git_metadata teardown). The fixture now saves and restores sys.modules itself. --- tests/conftest.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9b9f11e8d..a2a25c2b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,12 +4,28 @@ @pytest.fixture(autouse=True) -def no_system_keyring(monkeypatch: pytest.MonkeyPatch): +def no_system_keyring(): """Make the system keyring unavailable to every test. `keyring` is installed in the test environment (twine depends on it), so without this the credential paths would read and write the machine's real keychain. Tests that exercise keyring storage replace `sys.modules["keyring"]` with a mock of their own, or patch the helpers in `rsconnect.oauth`. + + sys.modules is restored by hand rather than through the `monkeypatch` + fixture: an autouse fixture requesting `monkeypatch` hoists the shared + instance ahead of every test-level fixture, so its undo (including any + `monkeypatch.chdir`) would run after those fixtures' cleanup. That order + deletes a still-current working directory on Windows, which fails. """ - monkeypatch.setitem(sys.modules, "keyring", None) + absent = object() + previous = sys.modules.get("keyring", absent) + sys.modules["keyring"] = None # type: ignore[assignment] + try: + yield + finally: + if sys.modules.get("keyring") is None: + if previous is absent: + del sys.modules["keyring"] + else: + sys.modules["keyring"] = previous # type: ignore[assignment] From b0d4eabe733c5eddad0ad2f62e7bac753ae2d3a2 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Mon, 17 Aug 2026 15:40:56 -0400 Subject: [PATCH 09/12] fix: tell the keyring fixture's None marker from a deleted module entry The teardown guard treated a missing sys.modules key the same as the fixture's own None marker, so a test that deleted the entry would raise KeyError during restore. The sentinel default now separates the cases. --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index a2a25c2b3..f2e63d27b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,9 @@ def no_system_keyring(): try: yield finally: - if sys.modules.get("keyring") is None: + # The sentinel default distinguishes the fixture's own None marker from + # a key some test deleted outright, which is left as that test's doing. + if sys.modules.get("keyring", absent) is None: if previous is absent: del sys.modules["keyring"] else: From 8ee4dce70a4415e8c82674e2019045109ec4405d Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Mon, 17 Aug 2026 16:10:27 -0400 Subject: [PATCH 10/12] test: cover the keyring fixture teardown branches Extracts the fixture body into an importable generator and adds tests driving each teardown branch: previous module restored, marker removed when nothing was stored, and a deleted key left deleted. The marker is reinstated through a fixture finalizer so a failing assertion cannot leak state into later tests. --- tests/conftest.py | 34 ++++++++++++++---------- tests/test_conftest.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 tests/test_conftest.py diff --git a/tests/conftest.py b/tests/conftest.py index f2e63d27b..6822ad3ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,28 @@ import sys +from typing import Iterator import pytest +def _no_system_keyring() -> Iterator[None]: + """Generator behind the fixture, importable so tests can drive its teardown.""" + absent = object() + previous = sys.modules.get("keyring", absent) + sys.modules["keyring"] = None # type: ignore[assignment] + try: + yield + finally: + # The sentinel default distinguishes the fixture's own None marker from + # a key some test deleted outright, which is left as that test's doing. + if sys.modules.get("keyring", absent) is None: + if previous is absent: + del sys.modules["keyring"] + else: + sys.modules["keyring"] = previous # type: ignore[assignment] + + @pytest.fixture(autouse=True) -def no_system_keyring(): +def no_system_keyring() -> Iterator[None]: """Make the system keyring unavailable to every test. `keyring` is installed in the test environment (twine depends on it), so @@ -18,16 +36,4 @@ def no_system_keyring(): `monkeypatch.chdir`) would run after those fixtures' cleanup. That order deletes a still-current working directory on Windows, which fails. """ - absent = object() - previous = sys.modules.get("keyring", absent) - sys.modules["keyring"] = None # type: ignore[assignment] - try: - yield - finally: - # The sentinel default distinguishes the fixture's own None marker from - # a key some test deleted outright, which is left as that test's doing. - if sys.modules.get("keyring", absent) is None: - if previous is absent: - del sys.modules["keyring"] - else: - sys.modules["keyring"] = previous # type: ignore[assignment] + yield from _no_system_keyring() diff --git a/tests/test_conftest.py b/tests/test_conftest.py new file mode 100644 index 000000000..1dfde8741 --- /dev/null +++ b/tests/test_conftest.py @@ -0,0 +1,60 @@ +import contextlib +import sys +import types +from typing import Generator, Iterator + +import pytest + +from tests.conftest import _no_system_keyring + + +def _drain(gen: Iterator[None]) -> None: + try: + next(gen) + except StopIteration: + pass + + +@pytest.fixture +def reinstate_marker() -> Iterator[None]: + # Reinstates the autouse fixture's marker even when the test fails, so a + # failure here cannot leak a stand-in module into later tests' teardowns. + # As a test-level fixture it unwinds before the autouse fixture does. + try: + yield + finally: + sys.modules["keyring"] = None # type: ignore[assignment] + + +@pytest.fixture +def fixture_gen(reinstate_marker: None) -> Iterator["Generator[None, None, None]"]: + # closing() finalizes a generator a failed assertion left suspended, so + # its teardown runs before reinstate_marker restores the marker rather + # than at garbage collection, after. + gen = _no_system_keyring() + with contextlib.closing(gen): + yield gen + + +def test_teardown_restores_the_previous_module(fixture_gen: "Generator[None, None, None]") -> None: + stand_in = types.ModuleType("keyring") + sys.modules["keyring"] = stand_in + next(fixture_gen) + assert sys.modules["keyring"] is None + _drain(fixture_gen) + assert sys.modules["keyring"] is stand_in + + +def test_teardown_removes_the_marker_when_nothing_was_stored(fixture_gen: "Generator[None, None, None]") -> None: + del sys.modules["keyring"] + next(fixture_gen) + assert sys.modules["keyring"] is None + _drain(fixture_gen) + assert "keyring" not in sys.modules + + +def test_teardown_leaves_a_deleted_key_deleted(fixture_gen: "Generator[None, None, None]") -> None: + next(fixture_gen) + del sys.modules["keyring"] + _drain(fixture_gen) + assert "keyring" not in sys.modules From 9018786a27554607ec8c79997cce5735d051a319 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Tue, 18 Aug 2026 07:35:42 -0400 Subject: [PATCH 11/12] test: cover the unknown-nickname error from ServerStore.resolve Nothing asserted this raise; the Connect integration suite exercised it only by accident, and the -n/-A test rework there removed even that. --- tests/test_metadata.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 3464c7e57..849c11266 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -155,6 +155,13 @@ def test_resolve_from_args(self): self.assertEqual(server_data.ca_data, None) self.assertFalse(server_data.from_store) + def test_resolve_unknown_name_raises(self): + # A -n naming nothing must fail here rather than fall through to + # argument-style resolution, which would treat the nickname as a URL. + with self.assertRaises(RSConnectException) as context: + self.server_store.resolve("no-such-server", None) + self.assertEqual(context.exception.message, 'The nickname, "no-such-server", does not exist.') + def test_save_and_load(self): temp = tempfile.mkdtemp() server_store = ServerStore(base_dir=temp) From 64650478e5b2c28cad686546e844dd878f7b900d Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Wed, 19 Aug 2026 06:51:41 -0400 Subject: [PATCH 12/12] add support for -V to connect cloud deployments --- rsconnect/api.py | 29 ++++++++--- rsconnect/main.py | 25 +++++++--- tests/test_connect_cloud.py | 95 ++++++++++++++++++++++++++++++++++--- 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 07c3f98fe..7aa3b3d10 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1757,12 +1757,6 @@ def validate_connect_cloud_server(self): 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 ): @@ -2030,6 +2024,7 @@ def deploy_bundle(self, activate: bool = True): env_vars=self.env_vars, update_title=not self.title_is_default, app_id_is_explicit=self.app_id_is_explicit, + visibility=self.visibility, ) self.deployed_info = RSConnectClientDeployResult( app_url=prepared.app_url, @@ -3165,7 +3160,13 @@ def create_content( app_mode: str, primary_file: str, secrets: Optional[list[dict[str, str]]] = None, + access: Optional[str] = None, ) -> ConnectCloudContent: + """Create content to upload a bundle into. + + `access` is the content's visibility ("public" or "private"). Omitted from + the request when None so the server picks its own default. + """ body: dict[str, Any] = { "account_id": account_id, "title": title, @@ -3177,6 +3178,8 @@ def create_content( }, "secrets": secrets or [], } + if access is not None: + body["access"] = access response = cast(Union[ConnectCloudContent, HTTPResponse], self.post("/contents", body=body)) return self._server.handle_bad_response(response) @@ -3189,6 +3192,7 @@ def update_content( secrets: Optional[list[dict[str, str]]] = None, new_bundle: bool = True, title: Optional[str] = None, + access: Optional[str] = None, ) -> ConnectCloudContent: """Update content, optionally minting a fresh revision to upload into. @@ -3200,7 +3204,9 @@ def update_content( `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. + deploy without -E/-t must leave the existing values alone. `access` (the + content's visibility) is omitted the same way, so a redeploy without + -V keeps whatever visibility the content already has. """ body: dict[str, Any] = { "revision_overrides": { @@ -3213,6 +3219,8 @@ def update_content( body["secrets"] = secrets if title is not None: body["title"] = title + if access is not None: + body["access"] = access query_params: dict[str, JsonData] = {"new_bundle": "true"} if new_bundle else {} response = cast( Union[ConnectCloudContent, HTTPResponse], @@ -3400,12 +3408,17 @@ def prepare_deploy( upload: bool = True, update_title: bool = False, app_id_is_explicit: bool = False, + visibility: Optional[str] = None, ) -> 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. + `visibility` is the -V/--visibility value. Its two choices, "public" and + "private", are spelled the same way as the Connect Cloud content access + levels, so the value is sent through as `access` unchanged. + `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 @@ -3463,6 +3476,7 @@ def prepare_deploy( app_mode=app_mode.name(), primary_file=primary_file, secrets=secrets, + access=visibility, ) else: # Existing content: push secrets and entrypoint, and ask for a new @@ -3478,6 +3492,7 @@ def prepare_deploy( secrets=secrets, new_bundle=upload, title=title if update_title and title else None, + access=visibility, ) next_revision = content.get("next_revision") diff --git a/rsconnect/main.py b/rsconnect/main.py index 9a57d2210..2fa0bb79a 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -370,12 +370,14 @@ def wrapper(*args: P.args, **kwargs: P.kwargs): return wrapper -def shinyapps_deploy_args(func: Callable[P, T]) -> Callable[P, T]: +def visibility_arg(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--visibility", "-V", type=click.Choice(["public", "private"]), - help="The visibility of the resource being deployed. (shinyapps.io only; must be public (default) or private)", + help="The visibility of the content being deployed, public (default) or private. \ +(shinyapps.io and Posit Connect Cloud only; on Posit Connect Cloud a redeploy without this \ +option leaves the existing visibility alone.)", ) @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs): @@ -1775,6 +1777,7 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -1811,6 +1814,7 @@ def deploy_notebook( env_management_r: Optional[bool], exclude_renv: bool, draft: bool, + visibility: Optional[str] = None, no_verify: bool = False, package_installer: Optional[PackageInstaller] = None, metadata: tuple[str, ...] = tuple(), @@ -1852,6 +1856,7 @@ def deploy_notebook( new=new, app_id=app_id, title=title, + visibility=visibility, disable_env_management=disable_env_management, env_vars=env_vars, ) @@ -2086,7 +2091,7 @@ def deploy_voila( @cloud_shinyapps_args @connect_cloud_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) -@shinyapps_deploy_args +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -2194,7 +2199,7 @@ def deploy_manifest( @cloud_shinyapps_args @connect_cloud_args @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) -@shinyapps_deploy_args +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -2312,7 +2317,7 @@ def deploy_bundle( ), ) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) -@shinyapps_deploy_args +@visibility_arg @click.option( "--exclude-renv", "exclude_renv", @@ -2686,6 +2691,7 @@ def deploy_git( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -2723,6 +2729,7 @@ def deploy_quarto( no_verify: bool, draft: bool, package_installer: Optional[PackageInstaller], + visibility: Optional[str] = None, metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, ): @@ -2773,6 +2780,7 @@ def deploy_quarto( new=new, app_id=app_id, title=title, + visibility=visibility, disable_env_management=disable_env_management, env_vars=env_vars, quarto_inputs=quarto_inputs_from_inspect(file_or_directory, inspect), @@ -2961,6 +2969,7 @@ def deploy_tensorflow( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -2990,6 +2999,7 @@ def deploy_html( connect_cloud: bool, no_verify: bool, draft: bool, + visibility: Optional[str] = None, connect_server: Optional[api.RSConnectServer] = None, metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, @@ -3032,6 +3042,7 @@ def deploy_html( new=new, app_id=app_id, title=title, + visibility=visibility, env_vars=env_vars, ) @@ -3181,7 +3192,7 @@ def generate_deploy_python( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) - @shinyapps_deploy_args + @visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -3388,7 +3399,7 @@ def deploy_app( nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) -@shinyapps_deploy_args +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 7f75a11ff..9a542160f 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -633,6 +633,28 @@ def test_create_content_payload(self): }, ) + def _create_content(self, **kwargs): + """POST /contents with the always-required arguments; returns the request body.""" + _register_json(httpretty.POST, f"{API}/contents", {"id": "c1", "next_revision": {"id": "r1"}}) + with self.client: + self.client.create_content( + account_id="acct-1", + title="My App", + content_type="shiny", + app_mode="python-shiny", + primary_file="app.py", + **kwargs, + ) + return _json_body(httpretty.last_request()) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_create_content_with_access_sets_the_visibility(self): + self.assertEqual(self._create_content(access="private")["access"], "private") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_create_content_without_access_takes_the_server_default(self): + self.assertNotIn("access", self._create_content()) + 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"}}) @@ -672,6 +694,14 @@ def test_update_content_with_title_replaces_it(self): 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_with_access_sets_the_visibility(self): + self.assertEqual(self._update_content(access="private")["access"], "private") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_without_access_leaves_the_visibility_alone(self): + self.assertNotIn("access", 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) @@ -2163,6 +2193,22 @@ def test_prepare_deploy_patches_content_whose_first_publish_failed(self): self.assertEqual(result.revision_id, "r2") self.assertEqual(result.upload_url, "https://up.example/fresh") + def test_prepare_deploy_sends_the_visibility_as_the_content_access(self): + self._prepare_deploy(visibility="private") + self.assertEqual(self.client.create_content.call_args.kwargs["access"], "private") + + self._prepare_deploy(app_id="c1", visibility="public") + self.assertEqual(self.client.update_content.call_args.kwargs["access"], "public") + + def test_prepare_deploy_without_a_visibility_does_not_send_access(self): + # No -V leaves new content on the server's default and keeps a redeploy + # from overwriting a visibility set in the Connect Cloud interface. + self._prepare_deploy() + self.assertIsNone(self.client.create_content.call_args.kwargs["access"]) + + self._prepare_deploy(app_id="c1") + self.assertIsNone(self.client.update_content.call_args.kwargs["access"]) + 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"]) @@ -2358,11 +2404,12 @@ def setUp(self): self.app_path = os.path.join(tempdir.name, "app.py") self.server = ConnectCloudServer("acme", access_token="at") - def _executor(self, app_id=None): + def _executor(self, app_id=None, visibility=None): executor = RSConnectExecutor.__new__(RSConnectExecutor) executor.remote_server = self.server executor.client = mock.MagicMock(spec=ConnectCloudClient) executor.app_mode = AppModes.PYTHON_SHINY + executor.visibility = visibility executor.app_id = app_id executor.app_id_is_explicit = app_id is not None executor.app_store = AppStore(self.app_path) @@ -2430,6 +2477,16 @@ def test_the_record_is_written_before_the_bundle_is_uploaded(self): self.assertEqual(seen, ["c1"]) + def test_the_visibility_reaches_prepare_deploy(self): + executor = self._executor(visibility="private") + 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["visibility"], "private") + def test_upload_failure_still_records_the_content_id(self): executor = self._executor() service = self._service() @@ -2673,6 +2730,31 @@ def test_notebook_and_quarto_take_connect_cloud_options(self): self.assertIn("--client-id", result.output, command) self.assertIn("-A, --account", result.output, command) + def test_connect_cloud_capable_commands_take_the_visibility_option(self): + # -V sets the content's access level on Connect Cloud, so every command + # that can publish there has to offer it. + for command in ("notebook", "quarto", "html", "manifest", "pyproject", "shiny"): + result = self.runner.invoke(cli, ["deploy", command, "--help"]) + self.assertIn("-V, --visibility", result.output, command) + + def test_the_visibility_option_reaches_the_executor(self): + # notebook, quarto, and html only gained -V for Connect Cloud; the + # commands that also target shinyapps.io have carried it all along. + project_dir = tempfile.mkdtemp() + notebook = os.path.join(project_dir, "notebook.ipynb") + with open(notebook, "w") as f: + f.write("{}") + page = os.path.join(project_dir, "index.html") + with open(page, "w") as f: + f.write("") + + for command, target in (("notebook", notebook), ("html", page)): + with mock.patch("rsconnect.main.Environment.create_python_environment"): + with mock.patch("rsconnect.main.RSConnectExecutor") as executor_cls: + result = self.runner.invoke(cli, ["deploy", command, target, "-V", "private", "--no-verify"]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(executor_cls.call_args.kwargs["visibility"], "private", 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 @@ -3348,7 +3430,7 @@ def test_list_reports_credentials_in_the_keyring(self): self.assertNotIn("Credentials are saved", result.output) -class TestConnectCloudVisibility(unittest.TestCase): +class TestConnectCloudServerValidation(unittest.TestCase): def _executor(self, visibility=None, server=None): executor = RSConnectExecutor.__new__(RSConnectExecutor) executor.remote_server = server or ConnectCloudServer("acme", access_token="at") @@ -3359,12 +3441,11 @@ def _executor(self, visibility=None, server=None): executor.visibility = visibility return executor - def test_visibility_is_rejected(self): - # Connect Cloud has no equivalent setting. R silently ignores it; we do not. + def test_visibility_is_accepted(self): + # Connect Cloud content has an access level, which -V sets. executor = self._executor("private") - with self.assertRaises(RSConnectException) as context: - executor.validate_connect_cloud_server() - self.assertIn("--visibility is not supported", str(context.exception)) + executor.validate_connect_cloud_server() + executor.client.get_current_user.assert_called_once() def test_no_visibility_is_accepted(self): executor = self._executor()