Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Deploying to Posit Connect Cloud with `-E`/`--environment` no longer deletes the
content's other secrets, such as secrets added in the Connect Cloud UI.
The named variables are set and the other secrets are kept.
- `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a
Posit Connect server, so other tools, such as the Connect SDKs, can make API
requests with your login. `--refresh` gets a new token after the server rejects
Expand Down
38 changes: 28 additions & 10 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3298,11 +3298,18 @@ class ConnectCloudRevision(TypedDict):
publish_log_channel: NotRequired[Optional[str]]


class ConnectCloudSecret(TypedDict):
name: str
# The API never returns a secret's value. On update, None keeps the current value.
value: Optional[str]


class ConnectCloudContent(TypedDict):
id: str
account_id: NotRequired[str]
title: NotRequired[str]
state: NotRequired[str]
secrets: NotRequired[list[ConnectCloudSecret]]
next_revision: NotRequired[Optional[ConnectCloudRevision]]
current_revision: NotRequired[Optional[ConnectCloudRevision]]

Expand Down Expand Up @@ -3684,7 +3691,7 @@ def create_content(
content_type: str,
app_mode: str,
primary_file: str,
secrets: Optional[list[dict[str, str]]] = None,
secrets: Optional[list[ConnectCloudSecret]] = None,
access: Optional[str] = None,
python_version: Optional[str] = None,
) -> ConnectCloudContent:
Expand Down Expand Up @@ -3722,7 +3729,7 @@ def update_content(
primary_file: str,
app_mode: str,
content_type: str,
secrets: Optional[list[dict[str, str]]] = None,
secrets: Optional[list[ConnectCloudSecret]] = None,
new_bundle: bool = True,
title: Optional[str] = None,
access: Optional[str] = None,
Expand All @@ -3735,11 +3742,12 @@ def update_content(
changes what kind of content this is (--app-id pointing at content of
another type) does not keep the old content 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.
`secrets` replaces the content's whole set (a secret left out is
deleted, and one with a None value keeps its current value), 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.

`python_version` is omitted when None for the same reason: a redeploy of
content whose bundle carries no Python version keeps the version already
Expand Down Expand Up @@ -3970,9 +3978,10 @@ def prepare_deploy(
)

# 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
# update_content as None and is omitted from the PATCH.
secrets: Optional[list[ConnectCloudSecret]] = None
if env_vars:
secrets = [ConnectCloudSecret(name=name, value=value) for name, value in env_vars.items()]

content: Optional[ConnectCloudContent] = None
if app_id:
Expand Down Expand Up @@ -4019,6 +4028,15 @@ def prepare_deploy(
access=visibility,
)
else:
# The PATCH replaces the whole secret set, so send the content's other
# secrets with a None value to keep them, including ones added in the
# Connect Cloud UI.
if secrets is not None and env_vars:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

secrets is only non-None when env_vars is non-empty, so if secrets is not None: would be enough

secrets += [
ConnectCloudSecret(name=secret["name"], value=None)
for secret in content.get("secrets") or []
if secret["name"] not in env_vars
]
# 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
Expand Down
31 changes: 31 additions & 0 deletions tests/test_connect_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2528,6 +2528,37 @@ def test_prepare_deploy_without_env_vars_does_not_touch_secrets(self):
self._prepare_deploy(app_id="c1", env_vars={})
self.assertIsNone(self.client.update_content.call_args.kwargs["secrets"])

def test_prepare_deploy_with_env_vars_keeps_other_secrets(self):
# The secrets list replaces every secret on the content, so secrets that
# -E does not name (for example ones added in the Connect Cloud UI) must
# be sent with a null value to keep them.
self.client.get_content.return_value = {
"id": "c1",
"account_id": "acct-1",
"current_revision": {"id": "r0"},
"secrets": [{"name": "UI_KEY", "value": None}, {"name": "FOO", "value": None}],
}
self._prepare_deploy(app_id="c1", env_vars={"FOO": "bar"})
self.assertEqual(
self.client.update_content.call_args.kwargs["secrets"],
[{"name": "FOO", "value": "bar"}, {"name": "UI_KEY", "value": None}],
)

def test_prepare_deploy_with_env_vars_and_no_other_secrets(self):
for secrets in (None, []):
with self.subTest(secrets=secrets):
self.client.get_content.return_value = {
"id": "c1",
"account_id": "acct-1",
"current_revision": {"id": "r0"},
"secrets": secrets,
}
self._prepare_deploy(app_id="c1", env_vars={"FOO": "bar"})
self.assertEqual(
self.client.update_content.call_args.kwargs["secrets"],
[{"name": "FOO", "value": "bar"}],
)

def test_prepare_deploy_updates_existing_content(self):
result = self._prepare_deploy(app_id="c1")

Expand Down
Loading