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

## Unreleased

- 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. The record
it was migrated from is removed, so the directory is left with one deployment
target rather than two; pass `--from-server` to choose which record to migrate
when there are several.

## Unreleased

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks like this heading is duplicated


- 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 <nickname> --connect-cloud
Expand Down
162 changes: 157 additions & 5 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,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 @@ -1302,6 +1310,14 @@ 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)


class RSConnectExecutor:
def __init__(
self,
Expand Down Expand Up @@ -2256,6 +2272,134 @@ 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.

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?

"""
candidates = [
record
for record in self.app_store.get_all()
if not connect_cloud.is_connect_cloud_url(record.get("server_url", "").split("#")[0])
]

if from_server:
target = resolve_server_alias(from_server)
matches = [record for record in candidates if record.get("server_url") == 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))
)
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.

: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_name = service.account_name_for_id(account_id) if account_id else None
if 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)
)

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(),
)

# Removed only after the new record is safely written: leaving both is
# recoverable, losing both is not.
if 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.
if fallback_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 @@ -3449,6 +3593,17 @@ def account_id(self) -> str:
account = self._client.get_account_by_name(self._server.account_name)
return account["id"]

def account_name_for_id(self, account_id: str) -> Optional[str]:
"""The name of 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["name"]
return None

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

Expand All @@ -3461,10 +3616,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
96 changes: 96 additions & 0 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
)
from .models import EnvironmentInstallation, EnvironmentVolumeMount
from .api import (
ConnectCloudServer,
RSConnectClient,
RSConnectExecutor,
RSConnectServer,
Expand Down Expand Up @@ -4852,6 +4853,101 @@ def _guid_for_current_server(server_url: str) -> Optional[str]:
logger.info("Environment ready. Activate with: source %s/bin/activate" % env_path)


@content.command(
name="migrate-to-connect-cloud",
short_help="Point a local deployment record at existing Posit Connect Cloud content.",
help=(
"Rewrite the local deployment record for FILE_OR_DIRECTORY so the next deploy updates an "
"existing Posit Connect Cloud content item instead of creating a new one. Use this after "
"migrating shinyapps.io content in Connect Cloud."
"\n\n"
"Nothing is copied and no bundle is uploaded: the content must already exist in Connect "
"Cloud, and only local files are changed. The record it was migrated from is removed, so "
"afterwards the directory has one deployment target rather than two."
"\n\n"
"FILE_OR_DIRECTORY is the same path you pass to `rsconnect deploy`. It defaults to the "
"current directory."
),
no_args_is_help=True,
)
@click.option("--name", "-n", help="The nickname of the saved Posit Connect Cloud account.")
@click.option(
"--server",
"-s",
envvar="CONNECT_SERVER",
help="The Posit Connect Cloud server, `connect.posit.cloud`. \
(Also settable via CONNECT_SERVER environment variable.)",
)
@connect_cloud_args
@connect_cloud_account_arg
@click.option(
"--content-id",
required=True,
type=StrippedStringParamType(),
metavar="TEXT",
help="The id of the Posit Connect Cloud content to point at. It is the last part of the \
content URL: https://connect.posit.cloud/{account}/content/{content-id}.",
)
@click.option(
"--from-server",
help="The URL of the deployment record to migrate, such as `shinyapps.io`. Only needed when \
the local deployment records cover more than one server.",
)
@click.option(
"--overwrite",
"-o",
is_flag=True,
help="Replace an existing Posit Connect Cloud deployment record for this account.",
)
@click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.")
@click.argument(
"file_or_directory",
type=click.Path(exists=True, dir_okay=True, file_okay=True),
default=os.curdir,
)
@cli_exception_handler
@click.pass_context
def content_migrate_to_connect_cloud(
ctx: click.Context,
name: Optional[str],
server: Optional[str],
account: Optional[str],
client_id: Optional[str],
client_secret: Optional[str],
connect_cloud: bool,
content_id: str,
from_server: Optional[str],
overwrite: bool,
file_or_directory: str,
verbose: int,
):
set_verbosity(verbose)
output_params(ctx, locals().items())

ce = RSConnectExecutor(
ctx=ctx,
name=name,
server=server,
account=account,
client_id=client_id,
client_secret=client_secret,
use_connect_cloud=connect_cloud,
path=file_or_directory,
).validate_server()
if not isinstance(ce.remote_server, ConnectCloudServer):
raise RSConnectException(
"`rsconnect content migrate-to-connect-cloud` requires a Posit Connect Cloud account. "
"Pass --connect-cloud with -A/--account, or -n with a saved Connect Cloud nickname."
)

with cli_feedback("Migrating the deployment record"):
record = ce.migrate_to_connect_cloud(content_id, from_server=from_server, overwrite=overwrite)

click.echo('Found "%s" in Posit Connect Cloud.' % record["title"])
click.echo("The deployment record for %s now points at %s" % (file_or_directory, record["app_url"]))
click.echo("The next deploy will update that content item rather than create a new one.")


@content.group(no_args_is_help=True, help="Manage git repository configuration for content items.")
def repository():
pass
Expand Down
15 changes: 13 additions & 2 deletions rsconnect/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,8 @@ class AppMetadata(TypedDict):
filename: str
app_url: str
app_id: str
app_guid: str
# Posit Connect Cloud and shinyapps.io identify content by id alone.
app_guid: Optional[str]
title: str
app_mode: str
app_store_version: int
Expand Down Expand Up @@ -757,7 +758,7 @@ def set(
filename: str,
app_url: str,
app_id: str,
app_guid: str,
app_guid: Optional[str],
title: str,
app_mode: AppMode | str,
):
Expand Down Expand Up @@ -786,6 +787,16 @@ def set(
},
)

def remove(self, server_url: str) -> bool:
"""
Forget the metadata for the app deployed to the given server.

:param server_url: the key the record is stored under, as reported by
`get_all()`.
:return: whether a record was removed.
"""
return self._remove_by_key(server_url)

def resolve(self, server: str, app_id: Optional[str], app_mode: Optional[AppMode]):
metadata = self.get(server)
if metadata is None:
Expand Down
Loading
Loading