Skip to content

Commit 0721cce

Browse files
authored
feat: Add an environment subcommand for managing environments (#778)
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 <aaron.jacobs@posit.co>
1 parent 03c506f commit 0721cce

12 files changed

Lines changed: 1204 additions & 0 deletions

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
When neither `-n/--name` nor `-s/--server` is provided, the default server is
1616
used automatically. `rsconnect login` sets the server as default unless
1717
`--no-set-default` is passed. `CONNECT_SERVER` still takes precedence.
18+
- New `environment` subcommand for managing execution environments on Connect.
1819

1920
## [1.29.0] - 2026-04-29
2021

docs/commands/environment.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
::: mkdocs-click
2+
:module: rsconnect.main
3+
:command: environment

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ nav:
4444
- content: commands/content.md
4545
- deploy: commands/deploy.md
4646
- details: commands/details.md
47+
- environment: commands/environment.md
4748
- info: commands/info.md
4849
- list: commands/list.md
4950
- login: commands/login.md

rsconnect/actions_environment.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""
2+
Public API for managing execution environments on Posit Connect.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional, Union
8+
9+
from .api import RSConnectClient, RSConnectServer, SPCSConnectServer
10+
from .models import (
11+
EnvironmentCreateInput,
12+
EnvironmentInstallation,
13+
EnvironmentInstallations,
14+
EnvironmentPermissionInput,
15+
EnvironmentPermissionV1,
16+
EnvironmentUpdateInput,
17+
EnvironmentV1,
18+
EnvironmentVolumeMount,
19+
)
20+
21+
22+
def list_environments(
23+
connect_server: Union[RSConnectServer, SPCSConnectServer],
24+
) -> list[EnvironmentV1]:
25+
with RSConnectClient(connect_server) as client:
26+
return client.environment_list()
27+
28+
29+
def get_environment(
30+
connect_server: Union[RSConnectServer, SPCSConnectServer],
31+
guid: str,
32+
) -> EnvironmentV1:
33+
with RSConnectClient(connect_server) as client:
34+
return client.environment_get(guid)
35+
36+
37+
def create_environment(
38+
connect_server: Union[RSConnectServer, SPCSConnectServer],
39+
image: str,
40+
title: Optional[str] = None,
41+
description: Optional[str] = None,
42+
matching: Optional[str] = None,
43+
supervisor: Optional[str] = None,
44+
python: Optional[list[EnvironmentInstallation]] = None,
45+
quarto: Optional[list[EnvironmentInstallation]] = None,
46+
r: Optional[list[EnvironmentInstallation]] = None,
47+
tensorflow: Optional[list[EnvironmentInstallation]] = None,
48+
volume_mounts: Optional[list[EnvironmentVolumeMount]] = None,
49+
user_guids: Optional[list[str]] = None,
50+
group_guids: Optional[list[str]] = None,
51+
) -> EnvironmentV1:
52+
body: EnvironmentCreateInput = {
53+
"cluster_name": "Kubernetes",
54+
"name": image,
55+
}
56+
if title is not None:
57+
body["title"] = title
58+
if description is not None:
59+
body["description"] = description
60+
if matching is not None:
61+
body["matching"] = matching
62+
if supervisor is not None:
63+
body["supervisor"] = supervisor
64+
if python is not None:
65+
body["python"] = _make_installations(python)
66+
if quarto is not None:
67+
body["quarto"] = _make_installations(quarto)
68+
if r is not None:
69+
body["r"] = _make_installations(r)
70+
if tensorflow is not None:
71+
body["tensorflow"] = _make_installations(tensorflow)
72+
if volume_mounts is not None:
73+
body["volume_mounts"] = volume_mounts
74+
75+
with RSConnectClient(connect_server) as client:
76+
result = client.environment_create(body)
77+
if user_guids is not None or group_guids is not None:
78+
_sync_permissions(client, result["guid"], user_guids, group_guids)
79+
return client.environment_get(result["guid"])
80+
81+
82+
def update_environment(
83+
connect_server: Union[RSConnectServer, SPCSConnectServer],
84+
guid: str,
85+
title: Optional[str] = None,
86+
description: Optional[str] = None,
87+
matching: Optional[str] = None,
88+
supervisor: Optional[str] = None,
89+
python: Optional[list[EnvironmentInstallation]] = None,
90+
quarto: Optional[list[EnvironmentInstallation]] = None,
91+
r: Optional[list[EnvironmentInstallation]] = None,
92+
tensorflow: Optional[list[EnvironmentInstallation]] = None,
93+
volume_mounts: Optional[list[EnvironmentVolumeMount]] = None,
94+
user_guids: Optional[list[str]] = None,
95+
group_guids: Optional[list[str]] = None,
96+
) -> EnvironmentV1:
97+
with RSConnectClient(connect_server) as client:
98+
existing = client.environment_get(guid)
99+
100+
body: EnvironmentUpdateInput = {
101+
"title": title if title is not None else existing["title"],
102+
"description": description if description is not None else existing["description"],
103+
"matching": matching if matching is not None else existing["matching"],
104+
"supervisor": supervisor if supervisor is not None else existing["supervisor"],
105+
"python": _make_installations(python) if python is not None else existing["python"],
106+
"quarto": _make_installations(quarto) if quarto is not None else existing["quarto"],
107+
"r": _make_installations(r) if r is not None else existing["r"],
108+
"tensorflow": _make_installations(tensorflow) if tensorflow is not None else existing["tensorflow"],
109+
"volume_mounts": volume_mounts if volume_mounts is not None else existing["volume_mounts"],
110+
}
111+
112+
result = client.environment_update(guid, body)
113+
114+
if user_guids is not None or group_guids is not None:
115+
_sync_permissions(client, guid, user_guids, group_guids)
116+
return client.environment_get(guid)
117+
118+
return result
119+
120+
121+
def delete_environment(
122+
connect_server: Union[RSConnectServer, SPCSConnectServer],
123+
guid: str,
124+
) -> None:
125+
with RSConnectClient(connect_server) as client:
126+
client.environment_delete(guid)
127+
128+
129+
def _make_installations(items: list[EnvironmentInstallation]) -> EnvironmentInstallations:
130+
return {"installations": items}
131+
132+
133+
def _sync_permissions(
134+
client: RSConnectClient,
135+
env_guid: str,
136+
user_guids: Optional[list[str]],
137+
group_guids: Optional[list[str]],
138+
) -> list[EnvironmentPermissionV1]:
139+
existing = client.environment_permission_list(env_guid)
140+
141+
desired_users = set(user_guids or [])
142+
desired_groups = set(group_guids or [])
143+
144+
existing_users = {p["user_guid"]: p for p in existing if p["user_guid"] is not None}
145+
existing_groups = {p["group_guid"]: p for p in existing if p["group_guid"] is not None}
146+
147+
results: list[EnvironmentPermissionV1] = []
148+
for g in desired_users - set(existing_users.keys()):
149+
body: EnvironmentPermissionInput = {"user_guid": g}
150+
results.append(client.environment_permission_add(env_guid, body))
151+
for g in desired_groups - set(existing_groups.keys()):
152+
body = {"group_guid": g}
153+
results.append(client.environment_permission_add(env_guid, body))
154+
155+
for g in set(existing_users.keys()) - desired_users:
156+
client.environment_permission_delete(env_guid, existing_users[g]["guid"])
157+
for g in set(existing_groups.keys()) - desired_groups:
158+
client.environment_permission_delete(env_guid, existing_groups[g]["guid"])
159+
160+
return results

rsconnect/api.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@
7676
ContentItemV1,
7777
DeleteInputDTO,
7878
DeleteOutputDTO,
79+
EnvironmentCreateInput,
80+
EnvironmentPermissionInput,
81+
EnvironmentPermissionV1,
82+
EnvironmentUpdateInput,
83+
EnvironmentV1,
7984
ListEntryOutputDTO,
8085
PyInfo,
8186
ServerSettings,
@@ -730,6 +735,53 @@ def system_caches_runtime_delete(self, target: DeleteInputDTO) -> DeleteOutputDT
730735
response = self._server.handle_bad_response(response)
731736
return response
732737

738+
def environment_list(self) -> list[EnvironmentV1]:
739+
response = cast(Union[List[EnvironmentV1], HTTPResponse], self.get("v1/environments"))
740+
response = self._server.handle_bad_response(response)
741+
return response
742+
743+
def environment_get(self, guid: str) -> EnvironmentV1:
744+
response = cast(Union[EnvironmentV1, HTTPResponse], self.get(f"v1/environments/{guid}"))
745+
response = self._server.handle_bad_response(response)
746+
return response
747+
748+
def environment_create(self, body: EnvironmentCreateInput) -> EnvironmentV1:
749+
response = cast(Union[EnvironmentV1, HTTPResponse], self.post("v1/environments", body=body))
750+
response = self._server.handle_bad_response(response)
751+
return response
752+
753+
def environment_update(self, guid: str, body: EnvironmentUpdateInput) -> EnvironmentV1:
754+
response = cast(Union[EnvironmentV1, HTTPResponse], self.put(f"v1/environments/{guid}", body=body))
755+
response = self._server.handle_bad_response(response)
756+
return response
757+
758+
def environment_delete(self, guid: str) -> None:
759+
response = cast(HTTPResponse, self.delete(f"v1/environments/{guid}", decode_response=False))
760+
self._server.handle_bad_response(response, is_httpresponse=True)
761+
762+
def environment_permission_list(self, env_guid: str) -> list[EnvironmentPermissionV1]:
763+
response = cast(
764+
Union[List[EnvironmentPermissionV1], HTTPResponse],
765+
self.get(f"v1/environments/{env_guid}/permissions"),
766+
)
767+
response = self._server.handle_bad_response(response)
768+
return response
769+
770+
def environment_permission_add(self, env_guid: str, body: EnvironmentPermissionInput) -> EnvironmentPermissionV1:
771+
response = cast(
772+
Union[EnvironmentPermissionV1, HTTPResponse],
773+
self.post(f"v1/environments/{env_guid}/permissions", body=body),
774+
)
775+
response = self._server.handle_bad_response(response)
776+
return response
777+
778+
def environment_permission_delete(self, env_guid: str, permission_guid: str) -> None:
779+
response = cast(
780+
HTTPResponse,
781+
self.delete(f"v1/environments/{env_guid}/permissions/{permission_guid}", decode_response=False),
782+
)
783+
self._server.handle_bad_response(response, is_httpresponse=True)
784+
733785
def task_get(
734786
self,
735787
task_id: str,

0 commit comments

Comments
 (0)