Skip to content
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
from a project's `pyproject.toml` with a `[tool.rsconnect]` table. Also
writes the environment file the manifest references (e.g. `requirements.txt`),
regenerating it on each run unless it is itself the requirements source.
- `rsconnect deploy bundle` command for deploying a previously built content
bundle (a `.tar.gz`, such as one downloaded from a Connect server) directly to
a server. The bundle is uploaded as-is and its existing `manifest.json`
determines the content type and dependencies, making it easy to copy content
from one server to another.

### Changed

Expand Down
20 changes: 20 additions & 0 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,26 @@ library(rsconnect)
?rsconnect::writeManifest
```

### Deploying a Downloaded Bundle

If you already have a content bundle as a `.tar.gz` file—for example, one
downloaded from a Posit Connect server—you can deploy it directly without
unpacking it. This is handy for copying content from one server to another.

```bash
rsconnect deploy bundle /path/to/bundle.tar.gz
```

The bundle is uploaded as-is; its existing `manifest.json` determines the content
type and dependencies. The options are the same as for `deploy manifest`; see
`rsconnect deploy bundle --help` for details.

> **Note**
> Bundles do not contain environment variables or secrets, so any that the
> content relied on must be set on the target server (you can pass them with
> `-E NAME=VALUE`). Dependencies are pinned in the manifest, so the target
> server needs a compatible Python or R version available.

### Deploying from a pyproject.toml

`rsconnect deploy pyproject` reads a `[tool.rsconnect]` table from a project's
Expand Down
111 changes: 110 additions & 1 deletion rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,107 @@ def read_manifest_file(manifest_path: str | Path) -> tuple[ManifestData, str]:
return manifest, raw_manifest


def _find_manifest_member(tar: tarfile.TarFile) -> Optional[tarfile.TarInfo]:
"""
Locate the manifest.json member within a bundle tarball, mirroring the way
Connect collapses single-subdirectory bundles when it extracts them.

Connect repeatedly descends while the extraction root contains exactly one
entry and that entry is a directory, moving its contents up a level. So a
downloaded bundle may legitimately store manifest.json under a nested
directory (e.g. "bundle/manifest.json") rather than at the top level.

:return: the manifest.json member, or None if no manifest could be located.
"""
file_names = {(m.name[2:] if m.name.startswith("./") else m.name): m for m in tar.getmembers() if m.isfile()}

prefix = ""
while True:
member = file_names.get(prefix + "manifest.json")
if member is not None:
return member

# Look at the entries directly under the current prefix.
remaining = [name[len(prefix) :] for name in file_names if name.startswith(prefix)]
entries = {name.split("/", 1)[0] for name in remaining}

# Only descend when this level holds exactly one entry and it is a
# directory (i.e. no file is named exactly that entry).
if len(entries) != 1:
return None
entry = next(iter(entries))
if entry in remaining:
return None
prefix = prefix + entry + "/"


def read_bundle_manifest(bundle_path: str | Path) -> ManifestData:
"""
Read and parse the manifest.json contained in a bundle tarball without
extracting the whole bundle.

:param bundle_path: the path to a bundle .tar.gz file.
:return: the parsed manifest data.
"""
with tarfile.open(name=str(bundle_path), mode="r:gz") as tar:
member = _find_manifest_member(tar)
extracted = tar.extractfile(member) if member is not None else None
if extracted is None:
raise RSConnectException('Bundle "%s" does not contain a manifest.json file.' % bundle_path)
raw_manifest = extracted.read().decode("utf-8")

return json.loads(raw_manifest)


def read_bundle_app_mode(bundle_path: str | Path) -> AppMode:
source_manifest = read_bundle_manifest(bundle_path)
# noinspection SpellCheckingInspection
return AppModes.get_by_name(source_manifest["metadata"]["appmode"])


def default_title_from_bundle(bundle_path: str | Path) -> str:
source_manifest = read_bundle_manifest(bundle_path)

# Prefer the manifest's entry point / primary file, mirroring how a manifest
# deployment derives its title.
filename = None
metadata = source_manifest.get("metadata")
if metadata:
# noinspection SpellCheckingInspection
filename = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html")
# If the manifest is for a module-style API entry point, there is no
# useful filename to derive a title from.
if filename and _module_pattern.match(filename):
filename = None

# When the manifest has no usable filename, fall back to the bundle's own
# file name (e.g. "mycontent" from "mycontent.tar.gz") rather than the
# directory the bundle happens to live in, which is unrelated to the content.
# Connect always produces .tar.gz bundles, and the name may legitimately
# contain dots (e.g. "my.cool.api"), so only the .tar.gz extension is stripped
# rather than splitting off a second "extension".
if not filename:
name = basename(str(bundle_path))
if name.lower().endswith(".tar.gz"):
name = name[: -len(".tar.gz")]
return _enforce_title_length(name)

return _default_title(filename)


def open_bundle(bundle_path: str | Path) -> typing.IO[bytes]:
"""Open an existing bundle tarball so it can be uploaded as-is.

This exists to plug into ``RSConnectExecutor.make_bundle``, which expects a
callable that returns the bundle as a file-like object (e.g.
``make_manifest_bundle``, which builds a tarball). For ``deploy bundle`` we
already have a finished ``.tar.gz`` on disk, so the "builder" is just an
open() — no tarball is constructed. Routing through ``make_bundle`` keeps the
deployment-name setup and upload flow identical to the other deploy commands.
"""
return open(bundle_path, "rb")


def make_manifest_bundle(manifest_path: str | Path) -> typing.IO[bytes]:
"""Create a bundle, given a manifest.

Expand Down Expand Up @@ -1635,6 +1736,14 @@ def make_quarto_manifest(
return manifest, relevant_files


def _enforce_title_length(title: str) -> str:
"""
Pad or truncate a title so it is between 3 and 1024 characters long, as
required by Posit Connect.
"""
return title[:1024].rjust(3, "0")


def _default_title(file_name: str | Path) -> str:
"""
Produce a default content title from the given file path. The result is
Expand All @@ -1647,7 +1756,7 @@ def _default_title(file_name: str | Path) -> str:
# Make sure we have enough of a path to derive text from.
file_name = abspath(file_name)
# noinspection PyTypeChecker
return basename(file_name).rsplit(".", 1)[0][:1024].rjust(3, "0")
return _enforce_title_length(basename(file_name).rsplit(".", 1)[0])


def validate_file_is_notebook(file_name: str | Path) -> None:
Expand Down
104 changes: 100 additions & 4 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
server_supports_git_metadata,
)
from .bundle import (
default_title_from_bundle,
default_title_from_manifest,
make_api_bundle,
make_html_bundle,
Expand All @@ -99,6 +100,8 @@
make_notebook_source_bundle,
make_tensorflow_bundle,
make_voila_bundle,
open_bundle,
read_bundle_app_mode,
read_manifest_app_mode,
resolve_shiny_express_entrypoint,
validate_entry_point,
Expand Down Expand Up @@ -304,15 +307,18 @@ def validate_env_vars(ctx: click.Context, param: click.Parameter, all_values: tu


def prepare_deploy_metadata(
directory: str,
directory: Optional[str],
metadata_overrides: tuple[str, ...],
no_metadata: bool,
server_version: Optional[str] = None,
) -> Optional[dict[str, str]]:
"""
Prepare metadata for bundle upload.

:param directory: Directory to detect git metadata from
:param directory: Directory to auto-detect git metadata from. Pass None to
skip auto-detection and send only the CLI overrides (e.g. for bundle
deployments, where the bundle's location on disk is unrelated to the
content's source).
:param metadata_overrides: CLI metadata overrides (key=value pairs)
:param no_metadata: Flag to disable all metadata
:param server_version: Optional server version to check support
Expand All @@ -334,8 +340,8 @@ def prepare_deploy_metadata(
else: # Empty value clears the key
cli_metadata[key] = ""

# Auto-detect git metadata
detected_metadata = detect_git_metadata(directory)
Comment thread
nealrichardson marked this conversation as resolved.
# Auto-detect git metadata, unless the caller opted out by passing None.
detected_metadata = detect_git_metadata(directory) if directory is not None else {}

# Merge: CLI overrides take precedence, then remove empty values
final_metadata = {**detected_metadata, **cli_metadata}
Expand Down Expand Up @@ -1794,6 +1800,96 @@ def deploy_manifest(
ce.verify_deployment()


@deploy.command(
name="bundle",
short_help="Deploy a previously downloaded bundle to Posit Connect, Posit 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 "
"the content type and dependencies. This is useful for copying content from one server to "
"another."
),
no_args_is_help=True,
)
@server_args
@spcs_args
@content_args
@cloud_shinyapps_args
@click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True))
@shinyapps_deploy_args
@cli_exception_handler
@click.pass_context
def deploy_bundle(
ctx: click.Context,
name: Optional[str],
server: Optional[str],
api_key: Optional[str],
snowflake_connection_name: Optional[str],
insecure: bool,
cacert: Optional[str],
account: Optional[str],
token: Optional[str],
secret: Optional[str],
new: bool,
app_id: Optional[str],
title: Optional[str],
verbose: int,
file: str,
env_vars: dict[str, str],
visibility: Optional[str],
no_verify: bool,
draft: bool,
metadata: tuple[str, ...] = tuple(),
no_metadata: bool = False,
):
set_verbosity(verbose)
output_params(ctx, locals().items())

app_mode = read_bundle_app_mode(file)
title = title or default_title_from_bundle(file)

ce = RSConnectExecutor(
ctx=ctx,
name=name,
api_key=api_key,
snowflake_connection_name=snowflake_connection_name,
insecure=insecure,
cacert=cacert,
account=account,
token=token,
secret=secret,
path=file,
server=server,
new=new,
app_id=app_id,
title=title,
visibility=visibility,
env_vars=env_vars,
)

# 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
# explicit --metadata overrides are sent.
server_version = None
if isinstance(ce.client, RSConnectClient):
server_version = ce.client.server_settings().get("version", "")
ce.metadata = prepare_deploy_metadata(None, metadata, no_metadata, server_version)

(
ce.validate_server()
.validate_app_mode(app_mode=app_mode)
.make_bundle(
open_bundle,
file,
)
.deploy_bundle(activate=not draft)
.save_deployed_info()
.emit_task_log()
)
if not no_verify:
ce.verify_deployment()


@deploy.command(
name="pyproject",
short_help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io by pyproject.",
Expand Down
Loading
Loading