Skip to content

add support for deploying to Posit Connect Cloud - #840

Open
samperman wants to merge 13 commits into
mainfrom
connect-cloud-auth-refactor
Open

add support for deploying to Posit Connect Cloud#840
samperman wants to merge 13 commits into
mainfrom
connect-cloud-auth-refactor

Conversation

@samperman

@samperman samperman commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Intent

Add support for deploying to Posit Connect Cloud: OAuth device-code and service-account (client-credentials) authentication, credential management, content create/update/publish with log streaming, and deployment records — for the content types Connect Cloud supports.

Resolves #817. Supersedes #837 and #839, merging both into one reviewable unit; see #837 for the earlier review history and manual test-plan results (staging and production).

Type of Change

  • Bug Fix
  • New Feature
  • Breaking Change

Approach

Beyond the base feature, this incorporates the target-resolution and OAuth feedback from #837 (comment), one commit per item:

  • Actionable refresh failures — typed invalid_grant/invalid_client errors; an expired session clears its dead stored tokens and the error names the exact rsconnect add command to re-authenticate (preserving a non-production -s URL and the saved entry's account); transient failures still surface the original 401.
  • Nickname-based credential selection — entries are credentials, accounts are publish targets: one saved credential is used regardless of -A, several require -n, and -A always selects the account to publish to (a Cloud login can publish to every account its user has rights on). Deployment records stay keyed by URL + account id. For non-Cloud targets, -n with -A still fails, but after nickname resolution and with a new message.
  • Keyring-backed credential storage — Cloud tokens and stored client secrets live in the system keyring keyed <url>#<nickname>, with servers.json as the documented fallback when no usable keyring exists (CI, headless). The existing Connect keyring key format is frozen, with a test guarding it.
  • Shared 401 → refresh → retry machineryBearerTokenHTTPServer owns the retry-once skeleton (including request-body rewind) for both clients; token minting stays per-target (Connect keeps OAuth discovery + client re-registration, Cloud keeps client-credentials vs refresh-token and the typed errors above).

Two follow-ups from the same review land separately after this merges: redeploy target inference from a directory's deployment record (changes target-resolution precedence for all server types, so it warrants its own review) and the shinyapps.io deployment migration command (#838).

Companion: posit-dev/connect#42351 updates two nightly bats assertions that pinned the old -n/-A validation message. Each repo's CI installs the other's main, so the original changes were red in isolation; the assertions are now version-agnostic and that PR merges independently, in either order.

Automated Tests

Full suite: 1081 passed / 12 skipped; CI green across Python 3.8–3.14 on ubuntu/macos/windows plus the Connect integration suites. Each behavior above has dedicated unit tests (refresh error paths, credential selection rules, keyring fallback/migration incl. the NoKeyringError CI case, retry-once with body rewind), and every commit was individually machine-reviewed.

Directions for Reviewers

To validate manually:

uvx --from "git+https://github.com/posit-dev/rsconnect-python.git@connect-cloud-auth-refactor" rsconnect add --connect-cloud -A <account> -n cc
# device-code login; then from an app directory:
uvx --from "git+https://github.com/posit-dev/rsconnect-python.git@connect-cloud-auth-refactor" rsconnect deploy shiny .

Worth poking at: -n cc -A <other-account> (publish-target selection), a second add under another nickname then a bare deploy (must demand -n), rsconnect list (reports keyring vs file storage), and rsconnect remove (cleans up keyring entries). Set PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring to exercise the servers.json fallback.

Checklist

  • I have updated CHANGELOG.md to cover notable changes.
  • I have updated all related GitHub issues to reflect their current state.
  • I have run the rsconnect-python-tests-at-night workflow in Connect against this feature branch. (Two bats message assertions failed as expected; addressed by posit-dev/connect#42351.)

Adds Posit Connect Cloud as a deployment target alongside Posit Connect
and shinyapps.io, mirroring the R rsconnect package's support:

- Select the target with --connect-cloud or -s connect.posit.cloud.
- Authenticate with an interactive OAuth device-code login or a service
  account client ID/secret (client credentials grant), with automatic
  token refresh and write-back to the credential store.
- Register credentials with `rsconnect add`, verifying the account
  exists and grants the content:create permission before storing.
- Deploy through the Connect Cloud revision model: create or update
  content, upload the bundle to a presigned URL, publish, poll the
  revision, and print the publish log from the logs service on failure.
- Record deployments locally before publishing, since Connect Cloud
  cannot look content up by name.
- Support the production, staging, and development environments via
  CONNECT_CLOUD_ENVIRONMENT, pinned to the saved server's URL.

Fixes #817
`cast(dict[str, Any], ...)` evaluates its first argument at runtime, so
`from __future__ import annotations` does not cover it and `dict[...]`
raises TypeError on Python 3.8. Describe the response with a TypedDict
instead, matching the other ConnectCloudClient methods.
ConnectCloudClient._attempt_token_refresh logged every failure at debug and
returned False, so an expired session or a revoked service account secret
surfaced only as the original opaque 401.

oauth.py now raises a typed InvalidGrantError when the token endpoint returns
error=invalid_grant, carrying the server's error_description.

The Cloud client acts on the two credential rejections it can explain:

- invalid_grant on the refresh-token path clears connect_cloud_access_token
  and connect_cloud_refresh_token on the saved servers.json entry (account
  name/id and nickname are kept) and raises "Your Posit Connect Cloud session
  has expired and could not be renewed. Authenticate again with
  `rsconnect add --connect-cloud -n <name> -A <account>`."
- invalid_client on the client-credentials path raises a message saying the
  service account credential was revoked or rotated, pointing at
  <auth host>/identity/credentials and the `rsconnect add` command with
  --client-id/--client-secret. The stored entry is left alone.

Everything else — network failures, a rejected CLI OAuth client, unexpected
responses — still returns False so the original 401 surfaces, but logs at
warning instead of debug, matching the Connect refresh path.

The servers.json write-back moved into _persist_tokens so the clearing path
reuses the field-preserving update. Connect's refresh is unchanged: its
generic `except Exception` already covers the new error type.
A saved Connect Cloud entry is a credential, not an account binding: the
login behind it can publish to every account its user has rights on. So
-A/--account no longer picks which saved credential to use, only where to
publish. With one credential saved it is used whatever account is named;
with several, -n/--name is now required and the error lists the saved
nicknames with the account each publishes to by default. This drops the
account-filter branch and its two error paths from
ServerStore._get_connect_cloud_server, along with the now-dead account_name
argument to get_by_url and resolve.

Behavior change: With several saved credentials, -n selects the credential;
-A no longer matches against entries and instead always selects the account
to publish to, so `-n cred -A other-account` publishes there with that
credential.
Connect Cloud tokens and service account client secrets now go to the system
keyring, keyed "<url>#<nickname>" because every Connect Cloud entry records
the same API URL. `rsconnect add` and token refresh write there when a keyring
is available and leave the matching servers.json fields out, which moves the
secrets of an entry saved before this change out of the file on its next add
or refresh. Reads prefer the keyring and fall back to those fields, so a
machine without a usable keyring (a CI runner) keeps working as before, and
`rsconnect server remove` deletes the entries for the removed nickname.
`rsconnect list` reports which of the two holds the credentials.

The keyring helpers in oauth.py now take the entry key explicitly. Posit
Connect keeps passing the bare server URL, so its "<url>:access_token" and
"<url>:refresh_token" usernames are unchanged and existing logins are
untouched. Tests get a conftest fixture that makes the keyring unavailable by
default, since the module is installed in the test environment and would
otherwise reach the machine's real keychain.
RSConnectClient and ConnectCloudClient each had their own copy of "send the
request, on 401 mint a new token, send it once more". Both now inherit it from
BearerTokenHTTPServer, which calls the subclass's _attempt_token_refresh to
mint and apply the token and asks _can_refresh_token whether there is anything
to mint from -- false for an API key, a bootstrap JWT, or a Snowflake token
exchange. Connect Cloud gains the seekable-body rewind that only the Connect
copy had, so a streamed body is not sent empty on the retry.

The minting stays per-target, unchanged: Connect keeps discovery against its
registered client and the InvalidClientError re-registration recovery, and
Connect Cloud keeps the client-credentials-versus-refresh choice and its typed
error handling. The keyring-with-servers.json-fallback load and write-back is
already the same code on both sides, differing only in the key it is given;
what remains target-specific is Connect's token expiry tracking and Connect
Cloud's field-preserving write-back, which it skips for a run with no saved
entry. Connect's three copies of "find the entry this server came from" become
ServerStore.saved_entry.

No behavior change other than the added rewind; every existing test passes
unmodified.
The stream-body retry tests annotate returns as list[Any], which 3.8
evaluates at class-definition time and rejects. Deferring annotation
evaluation with the __future__ import fixes collection for the file.
The autouse no_system_keyring fixture requested monkeypatch, hoisting the
shared per-test instance ahead of every test-level fixture. Its undo then
ran after those fixtures' cleanup, so a test using monkeypatch.chdir into
a TemporaryDirectory had the directory deleted while it was still the
working directory, which Windows rejects (WinError 32 in
test_git_metadata teardown). The fixture now saves and restores
sys.modules itself.
The teardown guard treated a missing sys.modules key the same as the
fixture's own None marker, so a test that deleted the entry would raise
KeyError during restore. The sentinel default now separates the cases.
Extracts the fixture body into an importable generator and adds tests
driving each teardown branch: previous module restored, marker removed
when nothing was stored, and a deleted key left deleted. The marker is
reinstated through a fixture finalizer so a failing assertion cannot
leak state into later tests.
Nothing asserted this raise; the Connect integration suite exercised it
only by accident, and the -n/-A test rework there removed even that.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://posit-dev.github.io/rsconnect-python/pr-preview/pr-840/

Built to branch gh-pages at 2026-08-19 10:52 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
8608 7333 85% 0% 🟢

New Files

File Coverage Status
rsconnect/connect_cloud.py 100% 🟢
TOTAL 100% 🟢

Modified Files

File Coverage Status
rsconnect/actions.py 62% 🟢
rsconnect/api.py 85% 🟢
rsconnect/certificates.py 100% 🟢
rsconnect/http_support.py 84% 🟢
rsconnect/json_web_token.py 97% 🟢
rsconnect/main.py 83% 🟢
rsconnect/metadata.py 89% 🟢
rsconnect/models.py 93% 🟢
rsconnect/oauth.py 84% 🟢
rsconnect/shiny_express.py 93% 🟢
rsconnect/validation.py 85% 🟢
TOTAL 87% 🟢

updated for commit: 6465047 by action🐍

@samperman
samperman marked this pull request as ready for review August 18, 2026 17:52
@samperman
samperman requested a review from vrsarah August 18, 2026 18:12
@karawoo
karawoo self-requested a review August 18, 2026 18:19
@samperman samperman changed the title feat: add support for deploying to Posit Connect Cloud add support for deploying to Posit Connect Cloud Aug 18, 2026
Comment thread rsconnect/api.py
Comment on lines +3420 to +3423
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading this right, a redeploy with a secret (-E NEW_SECRET=xyz) will wipe any existing secrets deployed before the redeploy. Might be worth documenting that in the -E help text

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already is? too subtle?

A non-empty -E set replaces the whole collection

Comment thread rsconnect/api.py Outdated
if not isinstance(self.client, ConnectCloudClient):
raise RSConnectException("client must be a ConnectCloudClient.")
if self.visibility is not None:
# Connect Cloud has no equivalent setting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connect Cloud has visibility concept (content.access). Are we intentionally not covering resource access here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops! good catch. i will add that in

@karawoo karawoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this locally and was able to deploy to connect cloud successfully. Added a few comments below but they're pretty minor. For next time, if we could get independent changes in separate PRs that would help a lot with reviewing. I think the credential redaction and some of the refactoring could have been their own PRs and made the main feature easier to review.

Comment thread docs/CHANGELOG.md
Comment on lines +10 to +11
- Posit Connect Cloud is now a supported deployment target, alongside Posit
Connect and shinyapps.io, mirroring the R rsconnect package's support.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Posit Connect Cloud is now a supported deployment target, alongside Posit
Connect and shinyapps.io, mirroring the R rsconnect package's support.
- Posit Connect Cloud is now a supported deployment target, alongside Posit
Connect and shinyapps.io.

Comment thread docs/CHANGELOG.md
Comment on lines +14 to +25
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 <account>` (or the
`CONNECT_CLOUD_ACCOUNT` environment variable; a `SHINYAPPS_ACCOUNT` variable
exported for shinyapps.io is ignored here), or `-n <nickname>` 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some of this detail is better left to the documentation rather than the changelog

Comment thread rsconnect/api.py
response.json_data["error"],
)
raise RSConnectException(error, status=response.status)
if response.status < 200 or response.status > 299:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should include response.status is None like we do at 405

Suggested change
if response.status is None or response.status < 200 or response.status > 299:

Comment thread rsconnect/api.py
Comment on lines +3046 to +3055
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rewrites servers.json on every refresh even if the contents haven't changed (i.e. if the keyring is in use). What do you think about rewriting only if the file has actually changed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea

Comment thread rsconnect/api.py
Comment on lines +3195 to +3199
`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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow this

Comment thread rsconnect/certificates.py
Comment on lines +26 to +32
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a pretty hard time following this comment, I think the code itself is clearer

Comment thread rsconnect/api.py Outdated
if self.visibility is not None:
# Connect Cloud has no equivalent setting.
raise RSConnectException(
"-V/--visibility is not supported by Posit Connect Cloud. "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there other flags that are supported for connect but should be disabled for connect cloud? thinking about --image, --disable-env-management/--disable-env-management-py/--disable-env-management-r/--disable-env-management-node, --node, --hide-all-input / --hide-tagged-input -- I'm not sure which ones connect cloud supports.

Comment thread rsconnect/api.py
Comment on lines 2261 to 2265
if draft:
if not self.supports_verify_before_activate:
# We can't honor --draft without the activate field: silently activating
# would be the opposite of what the user asked for, so fail loudly.
raise RSConnectException("Deploying as a draft requires Posit Connect 2025.06.0 or later.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to adjust this, "Deploying as a draft requires Posit Connect 2025.06.0 or later" doesn't make sense if you're trying to deploy to Connect Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for publishing to Posit Connect Cloud

3 participants