diff --git a/README.md b/README.md index a6b4ed63..2a4b9828 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 46a3e871..6c0b4604 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,44 @@ 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 + 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. 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 + 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. - Added support for Python 3.14. The test suite now runs on Python 3.14 in CI. - `rsconnect deploy` subcommands now accept `--quiet`, which suppresses the step-by-step progress lines and the streamed server build log, printing only diff --git a/docs/deploying.md b/docs/deploying.md index 6b05a26a..079a0917 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/actions.py b/rsconnect/actions.py index 46e2d155..84eeb656 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 29b0f238..7aa3b3d1 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -12,7 +12,9 @@ import json import os import re +import shlex import sys +import tarfile import time import typing import webbrowser @@ -47,25 +49,28 @@ # 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 from .http_support import ( + BearerTokenHTTPServer, CookieJar, 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 +153,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 +173,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 +182,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 +197,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 +225,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 +397,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 +440,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): @@ -441,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 @@ -473,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 ( @@ -512,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: @@ -534,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) @@ -570,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) @@ -639,7 +669,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 +1291,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 +1308,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 +1362,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 +1461,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 +1507,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) + except RSConnectException: + # 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 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) + else: + raise if server_data.from_store: url = server_data.url if self.logger: @@ -1472,10 +1585,113 @@ 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 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 \ +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 +1724,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 +1743,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 +1752,29 @@ 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 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 +1884,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 +2004,53 @@ 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, + visibility=self.visibility, + ) + 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 +2096,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 +2183,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 +2230,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 +2323,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 +2338,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 +2359,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 +2818,482 @@ 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] + + +class ConnectCloudAuthorization(TypedDict): + token: NotRequired[str] + + +# 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(BearerTokenHTTPServer): + """ + 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 _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. + + 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 .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( + 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 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.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 + # 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() + 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: + entry = ServerStore().get_by_name(server.server_name) + if entry: + 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 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. + """ + from .metadata import ServerStore + + server = self._server + if not server.server_name: + return + + store = ServerStore() + entry = store.get_by_name(server.server_name) + 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 + # 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, + 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=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: + 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, + 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, + "next_revision": { + "source_type": "bundle", + "content_type": content_type, + "app_mode": app_mode, + "primary_file": primary_file, + }, + "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) + + 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, + access: 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. `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": { + "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 + 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], + 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 = 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.") + 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 +3362,259 @@ 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, + 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 + 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, + access=visibility, + ) + 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, + access=visibility, + ) + + 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 +3785,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 1639b149..95b98714 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 00000000..f32ed146 --- /dev/null +++ b/rsconnect/connect_cloud.py @@ -0,0 +1,315 @@ +"""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 ( + 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. +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, + ) + + +_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/http_support.py b/rsconnect/http_support.py index 707eb0ea..34be9bc7 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( @@ -542,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]): @@ -573,13 +734,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 cfb564b7..6b9e3bdc 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 2202872f..2fa0bb79 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,11 @@ 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.) 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", @@ -287,12 +312,72 @@ def wrapper(*args: P.args, **kwargs: P.kwargs): return wrapper -def shinyapps_deploy_args(func: Callable[P, T]) -> Callable[P, T]: +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.) 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): + return func(*args, **kwargs) + + return wrapper + + +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): @@ -571,7 +656,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 +670,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 +723,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 +848,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 +856,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 +879,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 +910,63 @@ 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) + + # 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=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) secret = cast(str, secret) @@ -859,6 +1053,17 @@ 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"]) + 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") if server.get("oauth_client_id"): @@ -980,6 +1185,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: @@ -1229,7 +1439,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 +1662,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 +1706,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", @@ -1563,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 @@ -1575,6 +1790,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], @@ -1595,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(), @@ -1627,11 +1847,16 @@ 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, app_id=app_id, title=title, + visibility=visibility, disable_env_management=disable_env_management, env_vars=env_vars, ) @@ -1852,9 +2077,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,8 +2089,9 @@ 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 +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -1881,6 +2107,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 +2127,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 +2142,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 +2153,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 +2184,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,8 +2197,9 @@ 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 +@visibility_arg @quiet_arg @cli_exception_handler @click.pass_context @@ -1978,6 +2215,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 +2234,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 +2249,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 +2260,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 +2291,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 +2303,7 @@ def deploy_bundle( @spcs_args @content_args @cloud_shinyapps_args +@connect_cloud_args @click.option( "--requirements-file", "-r", @@ -2069,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", @@ -2093,6 +2341,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 +2366,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 +2390,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 +2451,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 +2482,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 +2492,7 @@ def quickstart_hint() -> str: title=effective_title, visibility=visibility, env_vars=env_vars, + quarto_inputs=quarto_inputs, ) server_version = None @@ -2356,11 +2616,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 +2629,8 @@ def deploy_git( @server_args @spcs_args @content_args +@connect_cloud_args +@connect_cloud_account_arg @runtime_environment_args @click.option( "--exclude", @@ -2429,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 @@ -2441,6 +2704,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], @@ -2462,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, ): @@ -2502,14 +2770,20 @@ 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, 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), ) # Prepare metadata for upload @@ -2662,14 +2936,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", @@ -2691,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 @@ -2715,8 +2994,12 @@ 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, + visibility: Optional[str] = None, connect_server: Optional[api.RSConnectServer] = None, metadata: tuple[str, ...] = tuple(), no_metadata: bool = False, @@ -2750,12 +3033,16 @@ 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, new=new, app_id=app_id, title=title, + visibility=visibility, env_vars=env_vars, ) @@ -2820,23 +3107,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", @@ -2899,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 @@ -2934,6 +3227,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 +3265,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, @@ -3100,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/rsconnect/metadata.py b/rsconnect/metadata.py index c3b67ec6..f08783dc 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): """ @@ -328,9 +374,99 @@ 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. + :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. + :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) + 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. """ - return self._get_by_value_attr("url", url) + 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. + + Callers use this to decide whether the account has to be supplied on the + command line or can come from a saved credential's default target. + """ + 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): + """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 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 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" (account %s)' % (entry.get("name"), entry.get("connect_cloud_account_name")) for entry in candidates + ) + raise RSConnectException( + "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 _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): """ @@ -376,6 +512,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 +548,31 @@ 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 + # 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: + 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: @@ -428,6 +593,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. @@ -440,9 +612,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 credentials + 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, @@ -499,6 +680,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"], @@ -514,6 +696,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=cloud_client_secret, + connect_cloud_access_token=cloud_access_token, + connect_cloud_refresh_token=cloud_refresh_token, ) else: return ServerData( diff --git a/rsconnect/models.py b/rsconnect/models.py index 3d7ba06f..85be2386 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 a2f44d97..3ce8bc60 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 @@ -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]: @@ -291,11 +307,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 +334,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 +357,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 +378,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 +392,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 +453,78 @@ 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. + client_id has been deleted server-side, or InvalidGrantError if the refresh + token has expired or been revoked. """ - 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" @@ -564,60 +639,175 @@ 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 _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. + + 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] + except ImportError: + return False - 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 + 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 ImportError: + 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 + # 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, or none + with a usable backend, knowably has nothing, but a backend that failed 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 _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 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"): + no_keyring = _no_keyring_errors() + 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 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 + + +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/rsconnect/shiny_express.py b/rsconnect/shiny_express.py index 01ee3ab9..c4e0b3ff 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 12d03275..43791b9d 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,23 @@ 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. + # + # 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. -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({"-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) @@ -99,9 +203,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 +215,96 @@ 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 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). + # 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(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 +323,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_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/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..6822ad3e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +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() -> Iterator[None]: + """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. + """ + yield from _no_system_keyring() diff --git a/tests/test_actions.py b/tests/test_actions.py index 91212b6c..17068038 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 15a0fa52..97cbed9d 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 737c7f2b..0d0d7759 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_conftest.py b/tests/test_conftest.py new file mode 100644 index 00000000..1dfde874 --- /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 diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py new file mode 100644 index 00000000..9a542160 --- /dev/null +++ b/tests/test_connect_cloud.py @@ -0,0 +1,3482 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +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.http_support import HTTPResponse, HTTPServer +from rsconnect.log import VERBOSE +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 + +from .utils import failing_keyring + +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 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.""" + + 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 _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"}}) + 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_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) + 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 _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(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)) + 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") + + @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 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`.""" + + 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_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_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. + 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_token_still_conflicts_with_a_nickname(self): + with self.assertRaises(RSConnectException) as context: + _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 + 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_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"]) + + 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, 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) + 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_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() + 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_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 + # --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 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()) + 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_url_with_several_credentials_is_rejected(self): + store = self._store() + with self.assertRaises(RSConnectException) as context: + store.get_by_url("connect.posit.cloud") + 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) + 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): + """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) + 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_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") + 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_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_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 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 + # 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 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") + assert entry is not None + 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 TestConnectCloudServerValidation(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_accepted(self): + # Connect Cloud content has an access level, which -V sets. + executor = self._executor("private") + executor.validate_connect_cloud_server() + executor.client.get_current_user.assert_called_once() + + 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 f7c2be1d..f51822c5 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 2ad2e666..76665b8e 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 92037947..849c1126 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 @@ -154,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) @@ -352,6 +360,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 8d0ce7e3..2f2c29cb 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -10,15 +10,20 @@ 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, _exchange_code_for_token, _poll_for_device_token, discover_oauth_metadata, 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, @@ -217,6 +222,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 = [ @@ -258,6 +295,40 @@ 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 _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): @@ -286,6 +357,92 @@ 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 = _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") + + 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 = _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") + + 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 = _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") + + 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() @@ -295,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 274c62e3..e89c4c0d 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: