Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Posit Connect Cloud is now a supported deployment target, alongside Posit
Connect and shinyapps.io.
- New `rsconnect content migrate-to-connect-cloud` points a directory's local
deployment record at an existing Posit Connect Cloud content item, so the next
deploy from that directory updates that item instead of creating a second one.
Use it after migrating shinyapps.io content to Connect Cloud:
`rsconnect content migrate-to-connect-cloud ./my-app -n cloud
--content-id <id>`. Nothing is copied and no bundle is uploaded — the content
must already exist in Connect Cloud, and only local files change. A
shinyapps.io record is removed once its content has been migrated, since that
record is then dead; a record for a Posit Connect server is kept, so the
directory can go on deploying to both. Pass `--from-server` to choose which
record to migrate when there are several.
- `rsconnect add` now reports invalid option combinations and unreadable
certificate files as plain error messages. Previously these surfaced as raw
Python tracebacks.
Expand Down
245 changes: 240 additions & 5 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@
create_multipart_form_data,
)
from .log import cls_logged, connect_logger, console_logger, logger
from .metadata import SHINYAPPS_API_URL, SHINYAPPS_SERVER_NAME, AppStore, ServerData, ServerStore
from .metadata import (
SHINYAPPS_API_URL,
SHINYAPPS_SERVER_NAME,
AppMetadata,
AppStore,
ServerData,
ServerStore,
resolve_server_alias,
)
from .models import (
AppMode,
AppModes,
Expand Down Expand Up @@ -1273,6 +1281,49 @@ class ServerDetails(TypedDict):
python: ServerDetailsPython


def _record_server_list(records: list[AppMetadata]) -> str:
"""The servers a set of deployment records covers, for an error message."""
servers = sorted(record.get("server_url", "") for record in records)
if not servers:
return ""
return " Records exist for: %s." % ", ".join(servers)


def _record_server_url(record: AppMetadata) -> str:
"""The server a record's key names, without a Cloud account or trailing slash."""
return record.get("server_url", "").split("#")[0].rstrip("/")


def _names_a_server(value: str) -> bool:
"""Whether a --from-server value identifies a server on its own.

A URL does, and so does a pseudo server name the alias table resolves -- in
either spelling, since a trailing slash is accepted. Nothing validates
nicknames, so one spelled like any of those must not shadow it.
"""
stripped = value.rstrip("/")
return "://" in value or resolve_server_alias(value) != value or resolve_server_alias(stripped) != stripped


def _migration_source_keys(from_server: str) -> tuple[str, str]:
"""The record key a --from-server value names, as given and slash-insensitive.

A saved nickname names a server as well as a URL does, and records are keyed by
the URL, so the store has to be consulted for a bare name.
"""
named = resolve_server_alias(from_server)
if not _names_a_server(from_server):
entry = ServerStore().get_by_name(from_server)
if entry:
named = entry["url"]
return named, resolve_server_alias(named.rstrip("/")).rstrip("/")


def _is_shinyapps_record(record: AppMetadata) -> bool:
"""Whether a deployment record points at shinyapps.io."""
return _record_server_url(record) == SHINYAPPS_API_URL


class RSConnectExecutor:
def __init__(
self,
Expand Down Expand Up @@ -2236,6 +2287,167 @@ def write_deployed_info(self):
self.app_mode,
)

def migration_source_record(self, from_server: Optional[str] = None) -> Optional[AppMetadata]:
"""The deployment record being migrated away from, if there is one.

Supplies the title and app mode for the new record, and is removed only
when it is a shinyapps.io record (see migrate_to_connect_cloud).

Only records for other servers are candidates: a Connect Cloud record is
what this migration produces, so treating one as a source would delete the
result. With no `from_server` a lone record is taken, and several are
reported rather than picked between.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is this safe when the lone record is Connect's deployment record?

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.

i cleaned that up. now we preserve the Connect record and create a Connect Cloud record

"""
candidates = [
record
for record in self.app_store.get_all()
if not connect_cloud.is_connect_cloud_url(_record_server_url(record))
]

if from_server:
named, target = _migration_source_keys(from_server)
# An exact key wins: records can exist for both slash variants of one URL,
# and then only the spelling the user typed tells them apart.
matches = [record for record in candidates if record.get("server_url") == named]
if not matches:
matches = [record for record in candidates if _record_server_url(record) == target]
if not matches:
raise RSConnectException(
'No deployment record for server "%s" in %s.%s'
% (from_server, self.app_store.get_path(), _record_server_list(candidates))
)
if len(matches) > 1:
raise RSConnectException(
'Several deployment records match server "%s" in %s. Pass the record\'s URL '
"exactly.%s" % (from_server, self.app_store.get_path(), _record_server_list(matches))
)
return matches[0]

if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
raise RSConnectException(
"Several deployment records exist in %s. Use --from-server to choose which one to "
"migrate.%s" % (self.app_store.get_path(), _record_server_list(candidates))
)

def migrate_to_connect_cloud(
self,
content_id: str,
from_server: Optional[str] = None,
overwrite: bool = False,
) -> AppMetadata:
"""Point this content's local deployment record at existing Posit Connect Cloud content.

Nothing is copied and no bundle is uploaded: the content must already exist in
Connect Cloud. What changes is the local record, which is the only way back to a
content item — Connect Cloud cannot look content up by name, so deploying
without a record creates a duplicate instead of updating the existing item.

The record migrated from supplies the title and app mode, and is removed
only when it is a shinyapps.io record, whose content has been migrated away
and whose record is therefore dead. A record for a Posit Connect server is
kept: its content still exists and is still deployable.

:param content_id: the id of the Connect Cloud content to point at.
:param from_server: the URL of the deployment record to migrate, needed only
when the local records cover several servers.
:param overwrite: replace an existing Connect Cloud record for this account.
:return: the record that was written.
"""
if not isinstance(self.remote_server, ConnectCloudServer) or not isinstance(self.client, ConnectCloudClient):
raise RSConnectException("Migrating a deployment record requires a Posit Connect Cloud account.")

source = self.migration_source_record(from_server)

# Checked before any request, so a run that cannot write anything makes no
# network calls and leaves the existing record untouched. The name-keyed
# location counts as existing too: a deploy reads it when the id-keyed one is
# missing, so a record there is this account's current target, and writing the
# id-keyed record would silently retarget the deploy and strand a duplicate.
target_key = self.record_server_key()
fallback_key = self.record_server_key_fallback()
existing = self.app_store.get(target_key)
if existing is None and fallback_key:
existing = self.app_store.get(fallback_key)
if existing and not overwrite:
raise RSConnectException(
'A Posit Connect Cloud deployment record for account "%s" already exists in %s, for content %s. '
"Use --overwrite to replace it."
% (self.remote_server.account_name, self.app_store.get_path(), existing.get("app_id"))
)

service = ConnectCloudService(self.client, self.remote_server)
with self.client:
content = self.client.get_content(content_id)
account_id = content.get("account_id")
account = service.account_for_id(account_id) if account_id else None
account_name = account["name"] if account else None
if not account or not account_name:
raise RSConnectException(
"Unable to determine which Posit Connect Cloud account owns content %s. "
"You may not have a role on that account." % content_id
)
# The record is keyed by account, and a deploy only reads the record for the
# account it is publishing to -- and would refuse content owned by another
# account anyway. A record written under the wrong account would therefore
# be silently ignored, so name the account that makes it usable instead.
# Compared by id when one is known, since that is what the key uses and what
# survives a rename; by name otherwise, which is then what the key uses too.
if self.remote_server.account_id:
same_account = account_id == self.remote_server.account_id
else:
same_account = account_name == self.remote_server.account_name
if not same_account:
raise RSConnectException(
'Content %s belongs to the Posit Connect Cloud account "%s", not "%s". '
"Re-run with -A %s." % (content_id, account_name, self.remote_server.account_name, account_name)
)
# An account id saved with a nickname skips get_account_by_name in
# validate_connect_cloud_server, so without this check a viewer role is
# caught only by the deploy -- after the source record has been removed.
if not service.can_publish_to(account):
raise RSConnectException(
'You have access to the Posit Connect Cloud account "%s" but do not have '
"permission to publish to it, so the next deploy from this directory would "
"fail. Ask an account administrator for the publisher role." % account_name
)

title = content.get("title") or (source or {}).get("title") or self.title
self.app_store.set(
target_key,
abspath(self.path),
self.remote_server.urls().content_url(account_name, content_id),
content_id,
None, # Connect Cloud content has no GUID separate from its id.
title,
# Connect Cloud derives the app mode from the content type and primary file,
# so there is nothing to read back from it; the source record's mode is kept
# when there is one. "unknown" does not block a later deploy of any mode.
(source or {}).get("app_mode") or AppModes.UNKNOWN.name(),
)

# Only a shinyapps.io record is removed, and only after the new record is
# safely written: leaving both is recoverable, losing both is not. Its
# content is what was migrated away, so the record is dead. A record for
# any other server still points at live content that is still deployable,
# and deploying one directory to both Connect and Connect Cloud is a
# supported setup, so that record is kept.
if source and _is_shinyapps_record(source):
self.app_store.remove(source["server_url"])
# The name-keyed record is the same account's, now superseded by the id-keyed
# one -- the same migration a deploy's write performs. The keys coincide when
# the account's name equals its id, and removing it then would delete the
# record just written.
if fallback_key and fallback_key != target_key:
self.app_store.remove(fallback_key)

record = self.app_store.get(target_key)
if record is None: # pragma: no cover - just written above
raise RSConnectException("The deployment record could not be saved.")
return record

@property
def supports_verify_before_activate(self) -> bool:
"""Whether the target server supports deploying a bundle as a draft and
Expand Down Expand Up @@ -3529,6 +3741,32 @@ def account_id(self) -> str:
account = self._client.get_account_by_name(self._server.account_name)
return account["id"]

def account_for_id(self, account_id: str) -> Optional[ConnectCloudAccount]:
"""The account with this id, among those the caller has a role on.

None means the account is not one of them, which for content the caller is
working with means they likely cannot publish to it either.
"""
for account in self._client.get_accounts():
if account.get("id") == account_id:
return account
return None

def account_name_for_id(self, account_id: str) -> Optional[str]:
"""The name of the account with this id, or None if the caller has no role on it."""
account = self.account_for_id(account_id)
return account["name"] if account else None

@staticmethod
def can_publish_to(account: ConnectCloudAccount) -> bool:
"""Whether the caller may publish to this account, by the same rule as
get_account_by_name, so a viewer role is judged identically either way.

Called on the class, not on self._client, so the rule holds for a stubbed
client too.
"""
return ConnectCloudClient._can_publish(account)

def content_url(self, content_id: str, account_id: Optional[str]) -> str:
"""Build the browsable URL for a content item.

Expand All @@ -3541,10 +3779,7 @@ def content_url(self, content_id: str, account_id: Optional[str]) -> str:
# 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
account_name = self.account_name_for_id(account_id) or account_name
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.
Expand Down
Loading
Loading