From 1d6fb0db484e8d49f3f8a9114f5dc7e10db41bcf Mon Sep 17 00:00:00 2001 From: Aaron Jacobs Date: Mon, 1 Jun 2026 16:24:43 -0400 Subject: [PATCH] feat: Add an `environment` subcommand for managing environments. This commit adds CLI commands for managing execution environments on Posit Connect: $ rsconnect environment list $ rsconnect environment show GUID $ rsconnect environment add IMAGE [--title T] [--matching M] ... $ rsconnect environment edit GUID [--title T] [--matching M] ... $ rsconnect environment remove GUID The `add` and `edit` commands allow specifying Python, R, Quarto, and TensorFlow installations, plus volume mounts. The `edit` command uses GET-merge-PUT semantics (since the API uses PUT for full replacement), so users only need to specify the fields they want to change. Permissions are managed via `--allow-user` and `--allow-group` flags on both `add` and `edit`. On edit, specifying these flags fully replaces existing permissions. Unit tests are included, as is autogenerated documentation. Closes #706. Signed-off-by: Aaron Jacobs --- docs/CHANGELOG.md | 1 + docs/commands/environment.md | 3 + mkdocs.yml | 1 + rsconnect/actions_environment.py | 160 +++++++ rsconnect/api.py | 52 +++ rsconnect/main.py | 332 +++++++++++++++ rsconnect/models.py | 85 ++++ tests/test_main_environment.py | 401 ++++++++++++++++++ .../get-environment-permission.json | 7 + .../connect-responses/get-environment.json | 19 + .../list-environment-permissions.json | 16 + .../connect-responses/list-environments.json | 40 ++ 12 files changed, 1117 insertions(+) create mode 100644 docs/commands/environment.md create mode 100644 rsconnect/actions_environment.py create mode 100644 tests/test_main_environment.py create mode 100644 tests/testdata/connect-responses/get-environment-permission.json create mode 100644 tests/testdata/connect-responses/get-environment.json create mode 100644 tests/testdata/connect-responses/list-environment-permissions.json create mode 100644 tests/testdata/connect-responses/list-environments.json diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9669c760..246724f2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 When neither `-n/--name` nor `-s/--server` is provided, the default server is used automatically. `rsconnect login` sets the server as default unless `--no-set-default` is passed. `CONNECT_SERVER` still takes precedence. +- New `environment` subcommand for managing execution environments on Connect. ## [1.29.0] - 2026-04-29 diff --git a/docs/commands/environment.md b/docs/commands/environment.md new file mode 100644 index 00000000..34f78e98 --- /dev/null +++ b/docs/commands/environment.md @@ -0,0 +1,3 @@ +::: mkdocs-click + :module: rsconnect.main + :command: environment diff --git a/mkdocs.yml b/mkdocs.yml index cc4b1692..0d3213af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - content: commands/content.md - deploy: commands/deploy.md - details: commands/details.md + - environment: commands/environment.md - info: commands/info.md - list: commands/list.md - login: commands/login.md diff --git a/rsconnect/actions_environment.py b/rsconnect/actions_environment.py new file mode 100644 index 00000000..0c9b0c93 --- /dev/null +++ b/rsconnect/actions_environment.py @@ -0,0 +1,160 @@ +""" +Public API for managing execution environments on Posit Connect. +""" + +from __future__ import annotations + +from typing import Optional, Union + +from .api import RSConnectClient, RSConnectServer, SPCSConnectServer +from .models import ( + EnvironmentCreateInput, + EnvironmentInstallation, + EnvironmentInstallations, + EnvironmentPermissionInput, + EnvironmentPermissionV1, + EnvironmentUpdateInput, + EnvironmentV1, + EnvironmentVolumeMount, +) + + +def list_environments( + connect_server: Union[RSConnectServer, SPCSConnectServer], +) -> list[EnvironmentV1]: + with RSConnectClient(connect_server) as client: + return client.environment_list() + + +def get_environment( + connect_server: Union[RSConnectServer, SPCSConnectServer], + guid: str, +) -> EnvironmentV1: + with RSConnectClient(connect_server) as client: + return client.environment_get(guid) + + +def create_environment( + connect_server: Union[RSConnectServer, SPCSConnectServer], + image: str, + title: Optional[str] = None, + description: Optional[str] = None, + matching: Optional[str] = None, + supervisor: Optional[str] = None, + python: Optional[list[EnvironmentInstallation]] = None, + quarto: Optional[list[EnvironmentInstallation]] = None, + r: Optional[list[EnvironmentInstallation]] = None, + tensorflow: Optional[list[EnvironmentInstallation]] = None, + volume_mounts: Optional[list[EnvironmentVolumeMount]] = None, + user_guids: Optional[list[str]] = None, + group_guids: Optional[list[str]] = None, +) -> EnvironmentV1: + body: EnvironmentCreateInput = { + "cluster_name": "Kubernetes", + "name": image, + } + if title is not None: + body["title"] = title + if description is not None: + body["description"] = description + if matching is not None: + body["matching"] = matching + if supervisor is not None: + body["supervisor"] = supervisor + if python is not None: + body["python"] = _make_installations(python) + if quarto is not None: + body["quarto"] = _make_installations(quarto) + if r is not None: + body["r"] = _make_installations(r) + if tensorflow is not None: + body["tensorflow"] = _make_installations(tensorflow) + if volume_mounts is not None: + body["volume_mounts"] = volume_mounts + + with RSConnectClient(connect_server) as client: + result = client.environment_create(body) + if user_guids is not None or group_guids is not None: + _sync_permissions(client, result["guid"], user_guids, group_guids) + return client.environment_get(result["guid"]) + + +def update_environment( + connect_server: Union[RSConnectServer, SPCSConnectServer], + guid: str, + title: Optional[str] = None, + description: Optional[str] = None, + matching: Optional[str] = None, + supervisor: Optional[str] = None, + python: Optional[list[EnvironmentInstallation]] = None, + quarto: Optional[list[EnvironmentInstallation]] = None, + r: Optional[list[EnvironmentInstallation]] = None, + tensorflow: Optional[list[EnvironmentInstallation]] = None, + volume_mounts: Optional[list[EnvironmentVolumeMount]] = None, + user_guids: Optional[list[str]] = None, + group_guids: Optional[list[str]] = None, +) -> EnvironmentV1: + with RSConnectClient(connect_server) as client: + existing = client.environment_get(guid) + + body: EnvironmentUpdateInput = { + "title": title if title is not None else existing["title"], + "description": description if description is not None else existing["description"], + "matching": matching if matching is not None else existing["matching"], + "supervisor": supervisor if supervisor is not None else existing["supervisor"], + "python": _make_installations(python) if python is not None else existing["python"], + "quarto": _make_installations(quarto) if quarto is not None else existing["quarto"], + "r": _make_installations(r) if r is not None else existing["r"], + "tensorflow": _make_installations(tensorflow) if tensorflow is not None else existing["tensorflow"], + "volume_mounts": volume_mounts if volume_mounts is not None else existing["volume_mounts"], + } + + result = client.environment_update(guid, body) + + if user_guids is not None or group_guids is not None: + _sync_permissions(client, guid, user_guids, group_guids) + return client.environment_get(guid) + + return result + + +def delete_environment( + connect_server: Union[RSConnectServer, SPCSConnectServer], + guid: str, +) -> None: + with RSConnectClient(connect_server) as client: + client.environment_delete(guid) + + +def _make_installations(items: list[EnvironmentInstallation]) -> EnvironmentInstallations: + return {"installations": items} + + +def _sync_permissions( + client: RSConnectClient, + env_guid: str, + user_guids: Optional[list[str]], + group_guids: Optional[list[str]], +) -> list[EnvironmentPermissionV1]: + existing = client.environment_permission_list(env_guid) + + desired_users = set(user_guids or []) + desired_groups = set(group_guids or []) + + existing_users = {p["user_guid"]: p for p in existing if p["user_guid"] is not None} + existing_groups = {p["group_guid"]: p for p in existing if p["group_guid"] is not None} + + results: list[EnvironmentPermissionV1] = [] + for g in desired_users - set(existing_users.keys()): + body: EnvironmentPermissionInput = {"user_guid": g} + results.append(client.environment_permission_add(env_guid, body)) + for g in desired_groups - set(existing_groups.keys()): + body = {"group_guid": g} + results.append(client.environment_permission_add(env_guid, body)) + + for g in set(existing_users.keys()) - desired_users: + client.environment_permission_delete(env_guid, existing_users[g]["guid"]) + for g in set(existing_groups.keys()) - desired_groups: + client.environment_permission_delete(env_guid, existing_groups[g]["guid"]) + + return results diff --git a/rsconnect/api.py b/rsconnect/api.py index 436217e8..7a93fa93 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -76,6 +76,11 @@ ContentItemV1, DeleteInputDTO, DeleteOutputDTO, + EnvironmentCreateInput, + EnvironmentPermissionInput, + EnvironmentPermissionV1, + EnvironmentUpdateInput, + EnvironmentV1, ListEntryOutputDTO, PyInfo, ServerSettings, @@ -730,6 +735,53 @@ def system_caches_runtime_delete(self, target: DeleteInputDTO) -> DeleteOutputDT response = self._server.handle_bad_response(response) return response + def environment_list(self) -> list[EnvironmentV1]: + response = cast(Union[List[EnvironmentV1], HTTPResponse], self.get("v1/environments")) + response = self._server.handle_bad_response(response) + return response + + def environment_get(self, guid: str) -> EnvironmentV1: + response = cast(Union[EnvironmentV1, HTTPResponse], self.get(f"v1/environments/{guid}")) + response = self._server.handle_bad_response(response) + return response + + def environment_create(self, body: EnvironmentCreateInput) -> EnvironmentV1: + response = cast(Union[EnvironmentV1, HTTPResponse], self.post("v1/environments", body=body)) + response = self._server.handle_bad_response(response) + return response + + def environment_update(self, guid: str, body: EnvironmentUpdateInput) -> EnvironmentV1: + response = cast(Union[EnvironmentV1, HTTPResponse], self.put(f"v1/environments/{guid}", body=body)) + response = self._server.handle_bad_response(response) + return response + + def environment_delete(self, guid: str) -> None: + response = cast(HTTPResponse, self.delete(f"v1/environments/{guid}", decode_response=False)) + self._server.handle_bad_response(response, is_httpresponse=True) + + def environment_permission_list(self, env_guid: str) -> list[EnvironmentPermissionV1]: + response = cast( + Union[List[EnvironmentPermissionV1], HTTPResponse], + self.get(f"v1/environments/{env_guid}/permissions"), + ) + response = self._server.handle_bad_response(response) + return response + + def environment_permission_add(self, env_guid: str, body: EnvironmentPermissionInput) -> EnvironmentPermissionV1: + response = cast( + Union[EnvironmentPermissionV1, HTTPResponse], + self.post(f"v1/environments/{env_guid}/permissions", body=body), + ) + response = self._server.handle_bad_response(response) + return response + + def environment_permission_delete(self, env_guid: str, permission_guid: str) -> None: + response = cast( + HTTPResponse, + self.delete(f"v1/environments/{env_guid}/permissions/{permission_guid}", decode_response=False), + ) + self._server.handle_bad_response(response, is_httpresponse=True) + def task_get( self, task_id: str, diff --git a/rsconnect/main.py b/rsconnect/main.py index 2362f916..bf523b9c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -63,6 +63,14 @@ get_content, search_content, ) +from .actions_environment import ( + create_environment, + delete_environment, + get_environment, + list_environments, + update_environment, +) +from .models import EnvironmentInstallation, EnvironmentVolumeMount from .api import ( RSConnectClient, RSConnectExecutor, @@ -4146,6 +4154,330 @@ def system_caches_delete( ce.delete_runtime_cache(language, version, image_name, dry_run) +def _parse_installations(values: tuple[str, ...]) -> list[EnvironmentInstallation] | None: + if not values: + return None + results: list[EnvironmentInstallation] = [] + for v in values: + if "=" not in v: + raise click.BadParameter(f"Expected VERSION=PATH format, got '{v}'") + version, path = v.split("=", 1) + results.append(EnvironmentInstallation(version=version, path=path)) + return results + + +def _parse_mounts(values: tuple[str, ...]) -> list[EnvironmentVolumeMount] | None: + if not values: + return None + results: list[EnvironmentVolumeMount] = [] + for v in values: + parts = dict(p.split("=", 1) if "=" in p else (p, "") for p in v.split(",")) + volume_type = parts.get("type", "") + if not volume_type: + raise click.BadParameter(f"Mount must include 'type=nfs' or 'type=pvc': '{v}'") + target_path = parts.get("target", "") + if not target_path: + raise click.BadParameter(f"Mount must include 'target=/path': '{v}'") + if volume_type not in ("nfs", "pvc"): + raise click.BadParameter(f"Unsupported mount type '{volume_type}'. Must be 'nfs' or 'pvc'.") + source: dict[str, str | None] = {"volume_type": volume_type} + if volume_type == "nfs": + source["nfs_host"] = parts.get("nfs_host") + source["nfs_export_path"] = parts.get("nfs_export_path") + elif volume_type == "pvc": + source["pvc_name"] = parts.get("pvc_name") + target: dict[str, str | bool | None] = {"path": target_path} + read_only = "readonly" in parts or parts.get("read_only") == "true" + target["read_only"] = read_only if read_only else None + results.append({"source": source, "target": target}) # type: ignore[arg-type] + return results + + +@cli.group(no_args_is_help=True, help="Manage execution environments on Posit Connect.") +def environment(): + pass + + +@environment.command(name="list", short_help="List execution environments.") +@server_args +@spcs_args +@cli_exception_handler +@click.pass_context +def environment_list( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect environment list` requires a Posit Connect server.") + result = list_environments(ce.remote_server) + json.dump(result, sys.stdout, indent=2) + + +@environment.command(name="show", short_help="Show a single execution environment.") +@server_args +@spcs_args +@click.argument("guid", type=StrippedStringParamType()) +@cli_exception_handler +@click.pass_context +def environment_show( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + guid: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect environment show` requires a Posit Connect server.") + result = get_environment(ce.remote_server, guid) + json.dump(result, sys.stdout, indent=2) + + +@environment.command(name="add", short_help="Create a new execution environment.") +@server_args +@spcs_args +@click.argument("image") +@click.option("--title", "-T", default=None, help="A human-readable title for the environment.") +@click.option("--description", "-d", default=None, help="A description for the environment.") +@click.option( + "--matching", + "-m", + default=None, + type=click.Choice(["any", "exact", "none"]), + help="The image selection strategy.", +) +@click.option("--supervisor", default=None, help="Path to the per-image supervisor script.") +@click.option("--python", "python_installations", multiple=True, help="A Python installation as VERSION=PATH.") +@click.option("--quarto", "quarto_installations", multiple=True, help="A Quarto installation as VERSION=PATH.") +@click.option("--r", "r_installations", multiple=True, help="An R installation as VERSION=PATH.") +@click.option( + "--tensorflow", "tensorflow_installations", multiple=True, help="A TensorFlow installation as VERSION=PATH." +) +@click.option( + "--mount", + "mounts", + multiple=True, + help="A volume mount as comma-separated key=value pairs (type,target required).", +) +@click.option("--allow-user", multiple=True, type=StrippedStringParamType(), help="A user GUID to grant access.") +@click.option("--allow-group", multiple=True, type=StrippedStringParamType(), help="A group GUID to grant access.") +@click.option("--clear-permissions", is_flag=True, default=False, help="Remove all existing permissions.") +@cli_exception_handler +@click.pass_context +def environment_add( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + image: str, + title: Optional[str], + description: Optional[str], + matching: Optional[str], + supervisor: Optional[str], + python_installations: tuple[str, ...], + quarto_installations: tuple[str, ...], + r_installations: tuple[str, ...], + tensorflow_installations: tuple[str, ...], + mounts: tuple[str, ...], + allow_user: tuple[str, ...], + allow_group: tuple[str, ...], + clear_permissions: bool, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect environment add` requires a Posit Connect server.") + result = create_environment( + ce.remote_server, + image=image, + title=title, + description=description, + matching=matching, + supervisor=supervisor, + python=_parse_installations(python_installations), + quarto=_parse_installations(quarto_installations), + r=_parse_installations(r_installations), + tensorflow=_parse_installations(tensorflow_installations), + volume_mounts=_parse_mounts(mounts), + user_guids=list(allow_user) if (allow_user or clear_permissions) else None, + group_guids=list(allow_group) if (allow_group or clear_permissions) else None, + ) + json.dump(result, sys.stdout, indent=2) + + +@environment.command(name="edit", short_help="Update an existing execution environment.") +@server_args +@spcs_args +@click.argument("guid", type=StrippedStringParamType()) +@click.option("--title", "-T", default=None, help="A new title for the environment.") +@click.option("--description", "-d", default=None, help="A new description for the environment.") +@click.option( + "--matching", + "-m", + default=None, + type=click.Choice(["any", "exact", "none"]), + help="The image selection strategy.", +) +@click.option("--supervisor", default=None, help="Path to the per-image supervisor script.") +@click.option("--python", "python_installations", multiple=True, help="A Python installation as VERSION=PATH.") +@click.option("--quarto", "quarto_installations", multiple=True, help="A Quarto installation as VERSION=PATH.") +@click.option("--r", "r_installations", multiple=True, help="An R installation as VERSION=PATH.") +@click.option( + "--tensorflow", "tensorflow_installations", multiple=True, help="A TensorFlow installation as VERSION=PATH." +) +@click.option( + "--mount", + "mounts", + multiple=True, + help="A volume mount as comma-separated key=value pairs (type,target required).", +) +@click.option("--allow-user", multiple=True, type=StrippedStringParamType(), help="A user GUID to grant access.") +@click.option("--allow-group", multiple=True, type=StrippedStringParamType(), help="A group GUID to grant access.") +@click.option("--clear-permissions", is_flag=True, default=False, help="Remove all existing permissions.") +@cli_exception_handler +@click.pass_context +def environment_edit( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + guid: str, + title: Optional[str], + description: Optional[str], + matching: Optional[str], + supervisor: Optional[str], + python_installations: tuple[str, ...], + quarto_installations: tuple[str, ...], + r_installations: tuple[str, ...], + tensorflow_installations: tuple[str, ...], + mounts: tuple[str, ...], + allow_user: tuple[str, ...], + allow_group: tuple[str, ...], + clear_permissions: bool, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect environment edit` requires a Posit Connect server.") + result = update_environment( + ce.remote_server, + guid=guid, + title=title, + description=description, + matching=matching, + supervisor=supervisor, + python=_parse_installations(python_installations), + quarto=_parse_installations(quarto_installations), + r=_parse_installations(r_installations), + tensorflow=_parse_installations(tensorflow_installations), + volume_mounts=_parse_mounts(mounts), + user_guids=list(allow_user) if (allow_user or clear_permissions) else None, + group_guids=list(allow_group) if (allow_group or clear_permissions) else None, + ) + json.dump(result, sys.stdout, indent=2) + + +@environment.command(name="remove", short_help="Delete an execution environment.") +@server_args +@spcs_args +@click.argument("guid", type=StrippedStringParamType()) +@cli_exception_handler +@click.pass_context +def environment_remove( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + guid: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect environment remove` requires a Posit Connect server.") + delete_environment(ce.remote_server, guid) + click.echo("Deleted environment %s." % guid) + + if __name__ == "__main__": cli() click.echo() diff --git a/rsconnect/models.py b/rsconnect/models.py index fc23f02b..83a4eb3c 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -621,3 +621,88 @@ class UserRecord(TypedDict): guid: str preferences: dict[str, object] privileges: list[str] + + +class EnvironmentInstallation(TypedDict): + version: str + path: str + + +class EnvironmentInstallations(TypedDict): + installations: list[EnvironmentInstallation] + + +class EnvironmentVolumeSource(TypedDict, total=False): + volume_type: str + nfs_host: str | None + nfs_export_path: str | None + pvc_name: str | None + + +class EnvironmentVolumeTarget(TypedDict): + path: str + read_only: bool | None + + +class EnvironmentVolumeMount(TypedDict): + source: EnvironmentVolumeSource + target: EnvironmentVolumeTarget + + +class EnvironmentV1(TypedDict): + id: str + guid: str + created_time: str + updated_time: str + title: str | None + description: str | None + cluster_name: str + name: str + environment_type: str + matching: str + supervisor: str | None + managed_by: str | None + python: EnvironmentInstallations + quarto: EnvironmentInstallations + r: EnvironmentInstallations + tensorflow: EnvironmentInstallations + volume_mounts: list[EnvironmentVolumeMount] + + +class EnvironmentCreateInput(TypedDict, total=False): + title: str | None + description: str | None + cluster_name: str + name: str + matching: str | None + supervisor: str | None + python: EnvironmentInstallations + quarto: EnvironmentInstallations + r: EnvironmentInstallations + tensorflow: EnvironmentInstallations + volume_mounts: list[EnvironmentVolumeMount] + + +class EnvironmentUpdateInput(TypedDict, total=False): + title: str | None + description: str | None + matching: str | None + supervisor: str | None + python: EnvironmentInstallations + quarto: EnvironmentInstallations + r: EnvironmentInstallations + tensorflow: EnvironmentInstallations + volume_mounts: list[EnvironmentVolumeMount] + + +class EnvironmentPermissionV1(TypedDict): + id: str + guid: str + environment_guid: str + user_guid: str | None + group_guid: str | None + + +class EnvironmentPermissionInput(TypedDict, total=False): + user_guid: str | None + group_guid: str | None diff --git a/tests/test_main_environment.py b/tests/test_main_environment.py new file mode 100644 index 00000000..f672fc8c --- /dev/null +++ b/tests/test_main_environment.py @@ -0,0 +1,401 @@ +import json +import unittest + +import httpretty +from click.testing import CliRunner + +from rsconnect.main import cli + +from .utils import apply_common_args + +ENVIRONMENT_GUID = "f1e2d3c4-b5a6-7890-abcd-ef1234567890" + + +def register_uris(connect_server: str): + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/server_settings", + body=open("tests/testdata/connect-responses/server_settings.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/environments", + body=open("tests/testdata/connect-responses/list-environments.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}", + body=open("tests/testdata/connect-responses/get-environment.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.POST, + f"{connect_server}/__api__/v1/environments", + body=open("tests/testdata/connect-responses/get-environment.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.PUT, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}", + body=open("tests/testdata/connect-responses/get-environment.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.DELETE, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}", + status=204, + body="", + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}/permissions", + body=open("tests/testdata/connect-responses/list-environment-permissions.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.POST, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}/permissions", + body=open("tests/testdata/connect-responses/get-environment-permission.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.DELETE, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}/permissions/perm-1111-2222-3333-444444444444", + status=204, + body="", + ) + httpretty.register_uri( + httpretty.DELETE, + f"{connect_server}/__api__/v1/environments/{ENVIRONMENT_GUID}/permissions/perm-5555-6666-7777-888888888888", + status=204, + body="", + ) + + +class TestEnvironmentSubcommand(unittest.TestCase): + def setUp(self): + self.connect_server = "http://localhost:3939" + self.api_key = "testapikey123" + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_list(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args(["environment", "list"], server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + output = json.loads(result.output) + self.assertEqual(len(output), 2) + self.assertEqual(output[0]["guid"], ENVIRONMENT_GUID) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_show(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["environment", "show", ENVIRONMENT_GUID], server=self.connect_server, key=self.api_key + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + output = json.loads(result.output) + self.assertEqual(output["guid"], ENVIRONMENT_GUID) + self.assertEqual(output["title"], "Python 3.11 Base") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_add(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "add", + "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy", + "--title", + "Python 3.11 Base", + "--description", + "Base image with Python 3.11", + "--matching", + "any", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + output = json.loads(result.output) + self.assertEqual(output["guid"], ENVIRONMENT_GUID) + # Verify a POST to environments was made with correct body + post_requests = [ + r + for r in httpretty.latest_requests() + if r.method == "POST" and "/v1/environments" in r.path and "/permissions" not in r.path + ] + self.assertGreaterEqual(len(post_requests), 1) + body = json.loads(post_requests[0].body) + self.assertEqual(body["name"], "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy") + self.assertEqual(body["cluster_name"], "Kubernetes") + self.assertEqual(body["title"], "Python 3.11 Base") + self.assertEqual(body["description"], "Base image with Python 3.11") + self.assertEqual(body["matching"], "any") + # Verify no permission endpoints were called when no --allow-* flags are set + perm_requests = [r for r in httpretty.latest_requests() if "/permissions" in r.path] + self.assertEqual(len(perm_requests), 0) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_add_with_installations(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "add", + "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy", + "--python", + "3.11.9=/opt/python/3.11.9/bin/python3", + "--python", + "3.10.4=/opt/python/3.10.4/bin/python3", + "--r", + "4.4.1=/opt/R/4.4.1/bin/R", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + post_requests = [ + r + for r in httpretty.latest_requests() + if r.method == "POST" and "/v1/environments" in r.path and "/permissions" not in r.path + ] + self.assertGreaterEqual(len(post_requests), 1) + body = json.loads(post_requests[0].body) + self.assertEqual(body["python"]["installations"], [ + {"version": "3.11.9", "path": "/opt/python/3.11.9/bin/python3"}, + {"version": "3.10.4", "path": "/opt/python/3.10.4/bin/python3"}, + ]) + self.assertEqual(body["r"]["installations"], [ + {"version": "4.4.1", "path": "/opt/R/4.4.1/bin/R"}, + ]) + self.assertNotIn("quarto", body) + self.assertNotIn("tensorflow", body) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_add_with_mounts(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "add", + "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy", + "--mount", + "type=nfs,nfs_host=nas.local,nfs_export_path=/data,target=/mnt/data,readonly", + "--mount", + "type=pvc,pvc_name=my-claim,target=/mnt/pvc", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + post_requests = [ + r + for r in httpretty.latest_requests() + if r.method == "POST" and "/v1/environments" in r.path and "/permissions" not in r.path + ] + self.assertGreaterEqual(len(post_requests), 1) + body = json.loads(post_requests[0].body) + self.assertEqual(len(body["volume_mounts"]), 2) + self.assertEqual(body["volume_mounts"][0]["source"]["volume_type"], "nfs") + self.assertEqual(body["volume_mounts"][0]["source"]["nfs_host"], "nas.local") + self.assertEqual(body["volume_mounts"][0]["source"]["nfs_export_path"], "/data") + self.assertEqual(body["volume_mounts"][0]["target"]["path"], "/mnt/data") + self.assertEqual(body["volume_mounts"][0]["target"]["read_only"], True) + self.assertEqual(body["volume_mounts"][1]["source"]["volume_type"], "pvc") + self.assertEqual(body["volume_mounts"][1]["source"]["pvc_name"], "my-claim") + self.assertEqual(body["volume_mounts"][1]["target"]["path"], "/mnt/pvc") + self.assertIsNone(body["volume_mounts"][1]["target"]["read_only"]) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_edit_with_installations(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "edit", + ENVIRONMENT_GUID, + "--python", + "3.12.0=/opt/python/3.12.0/bin/python3", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + put_requests = [r for r in httpretty.latest_requests() if r.method == "PUT"] + self.assertGreaterEqual(len(put_requests), 1) + body = json.loads(put_requests[0].body) + self.assertEqual(body["python"]["installations"], [ + {"version": "3.12.0", "path": "/opt/python/3.12.0/bin/python3"}, + ]) + # R should be preserved from existing + self.assertIn("r", body) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_edit_with_mounts(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "edit", + ENVIRONMENT_GUID, + "--mount", + "type=nfs,nfs_host=nas.local,nfs_export_path=/share,target=/mnt/share", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + put_requests = [r for r in httpretty.latest_requests() if r.method == "PUT"] + self.assertGreaterEqual(len(put_requests), 1) + body = json.loads(put_requests[0].body) + self.assertEqual(len(body["volume_mounts"]), 1) + self.assertEqual(body["volume_mounts"][0]["source"]["volume_type"], "nfs") + self.assertEqual(body["volume_mounts"][0]["source"]["nfs_host"], "nas.local") + self.assertEqual(body["volume_mounts"][0]["target"]["path"], "/mnt/share") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_add_with_permissions(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "add", + "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy", + "--allow-user", + "user-guid-1", + "--allow-group", + "group-guid-1", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + # Verify permission POSTs happened (check bodies, not count — httpretty may duplicate) + perm_posts = [r for r in httpretty.latest_requests() if r.method == "POST" and "/permissions" in r.path] + self.assertGreaterEqual(len(perm_posts), 2) + bodies = [json.loads(r.body) for r in perm_posts] + user_bodies = [b for b in bodies if b.get("user_guid") == "user-guid-1"] + group_bodies = [b for b in bodies if b.get("group_guid") == "group-guid-1"] + self.assertGreaterEqual(len(user_bodies), 1) + self.assertGreaterEqual(len(group_bodies), 1) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_edit(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "edit", + ENVIRONMENT_GUID, + "--title", + "Updated Title", + "--matching", + "exact", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + # Verify the PUT body merges with existing + put_requests = [r for r in httpretty.latest_requests() if r.method == "PUT"] + self.assertGreaterEqual(len(put_requests), 1) + body = json.loads(put_requests[0].body) + self.assertEqual(body["title"], "Updated Title") + self.assertEqual(body["matching"], "exact") + # Existing fields preserved + self.assertEqual(body["description"], "Base image with Python 3.11") + self.assertIn("python", body) + self.assertIn("r", body) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_edit_with_permissions(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + [ + "environment", + "edit", + ENVIRONMENT_GUID, + "--allow-user", + "new-user-guid", + ], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + # Verify existing permissions were deleted + perm_deletes = [r for r in httpretty.latest_requests() if r.method == "DELETE" and "/permissions/" in r.path] + self.assertGreaterEqual(len(perm_deletes), 2) + # Verify new permission was added + perm_posts = [r for r in httpretty.latest_requests() if r.method == "POST" and "/permissions" in r.path] + self.assertGreaterEqual(len(perm_posts), 1) + body = json.loads(perm_posts[0].body) + self.assertEqual(body["user_guid"], "new-user-guid") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_edit_no_permissions_untouched(self): + """Editing without --allow-user/--allow-group should not touch permissions.""" + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["environment", "edit", ENVIRONMENT_GUID, "--title", "New Title"], + server=self.connect_server, + key=self.api_key, + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + # Verify no permission-related requests + perm_requests = [r for r in httpretty.latest_requests() if "/permissions" in r.path] + self.assertEqual(len(perm_requests), 0) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_environment_remove(self): + register_uris(self.connect_server) + runner = CliRunner() + args = apply_common_args( + ["environment", "remove", ENVIRONMENT_GUID], server=self.connect_server, key=self.api_key + ) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("Deleted environment", result.output) + + def test_environment_add_missing_image(self): + runner = CliRunner() + args = apply_common_args(["environment", "add"], server="http://localhost:3939", key="testapikey123") + result = runner.invoke(cli, args) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("IMAGE", result.output) + + def test_environment_show_missing_guid(self): + runner = CliRunner() + args = apply_common_args(["environment", "show"], server="http://localhost:3939", key="testapikey123") + result = runner.invoke(cli, args) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("GUID", result.output) diff --git a/tests/testdata/connect-responses/get-environment-permission.json b/tests/testdata/connect-responses/get-environment-permission.json new file mode 100644 index 00000000..9b3ee66c --- /dev/null +++ b/tests/testdata/connect-responses/get-environment-permission.json @@ -0,0 +1,7 @@ +{ + "id": "3", + "guid": "perm-9999-aaaa-bbbb-cccccccccccc", + "environment_guid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "user_guid": "user-guid-1", + "group_guid": null +} diff --git a/tests/testdata/connect-responses/get-environment.json b/tests/testdata/connect-responses/get-environment.json new file mode 100644 index 00000000..ff168129 --- /dev/null +++ b/tests/testdata/connect-responses/get-environment.json @@ -0,0 +1,19 @@ +{ + "id": "42", + "guid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "created_time": "2024-01-15T10:00:00Z", + "updated_time": "2024-01-15T10:00:00Z", + "title": "Python 3.11 Base", + "description": "Base image with Python 3.11", + "cluster_name": "Kubernetes", + "name": "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy", + "environment_type": "Kubernetes", + "matching": "any", + "supervisor": null, + "managed_by": null, + "python": {"installations": [{"version": "3.11.9", "path": "/opt/python/3.11.9/bin/python3"}]}, + "quarto": {"installations": [{"version": "1.4.557", "path": "/opt/quarto/1.4.557/bin/quarto"}]}, + "r": {"installations": [{"version": "4.4.1", "path": "/opt/R/4.4.1/bin/R"}]}, + "tensorflow": {"installations": []}, + "volume_mounts": [] +} diff --git a/tests/testdata/connect-responses/list-environment-permissions.json b/tests/testdata/connect-responses/list-environment-permissions.json new file mode 100644 index 00000000..7e9d77f6 --- /dev/null +++ b/tests/testdata/connect-responses/list-environment-permissions.json @@ -0,0 +1,16 @@ +[ + { + "id": "1", + "guid": "perm-1111-2222-3333-444444444444", + "environment_guid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "user_guid": "820f092f-d564-4ab5-819a-0f4d2f03d11e", + "group_guid": null + }, + { + "id": "2", + "guid": "perm-5555-6666-7777-888888888888", + "environment_guid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "user_guid": null, + "group_guid": "group-aaaa-bbbb-cccc-dddddddddddd" + } +] diff --git a/tests/testdata/connect-responses/list-environments.json b/tests/testdata/connect-responses/list-environments.json new file mode 100644 index 00000000..d9fb9084 --- /dev/null +++ b/tests/testdata/connect-responses/list-environments.json @@ -0,0 +1,40 @@ +[ + { + "id": "42", + "guid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "created_time": "2024-01-15T10:00:00Z", + "updated_time": "2024-01-15T10:00:00Z", + "title": "Python 3.11 Base", + "description": "Base image with Python 3.11", + "cluster_name": "Kubernetes", + "name": "ghcr.io/rstudio/content-base:r4.4.1-py3.11.9-jammy", + "environment_type": "Kubernetes", + "matching": "any", + "supervisor": null, + "managed_by": null, + "python": {"installations": [{"version": "3.11.9", "path": "/opt/python/3.11.9/bin/python3"}]}, + "quarto": {"installations": [{"version": "1.4.557", "path": "/opt/quarto/1.4.557/bin/quarto"}]}, + "r": {"installations": [{"version": "4.4.1", "path": "/opt/R/4.4.1/bin/R"}]}, + "tensorflow": {"installations": []}, + "volume_mounts": [] + }, + { + "id": "43", + "guid": "a2b3c4d5-e6f7-8901-bcde-f12345678901", + "created_time": "2024-02-01T12:00:00Z", + "updated_time": "2024-02-01T12:00:00Z", + "title": "R 4.3 Only", + "description": null, + "cluster_name": "Kubernetes", + "name": "ghcr.io/rstudio/content-base:r4.3.3-jammy", + "environment_type": "Kubernetes", + "matching": "exact", + "supervisor": "/scripts/supervisor.sh", + "managed_by": null, + "python": {"installations": []}, + "quarto": {"installations": []}, + "r": {"installations": [{"version": "4.3.3", "path": "/opt/R/4.3.3/bin/R"}]}, + "tensorflow": {"installations": []}, + "volume_mounts": [] + } +]