anyscale 0.26.50__py3-none-any.whl → 0.26.52__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anyscale/_private/anyscale_client/README.md +1 -1
- anyscale/_private/anyscale_client/anyscale_client.py +178 -46
- anyscale/_private/anyscale_client/common.py +61 -2
- anyscale/_private/anyscale_client/fake_anyscale_client.py +145 -8
- anyscale/_private/docgen/__main__.py +34 -23
- anyscale/_private/docgen/generator.py +15 -18
- anyscale/_private/docgen/models.md +4 -2
- anyscale/_private/workload/workload_sdk.py +103 -8
- anyscale/client/README.md +5 -0
- anyscale/client/openapi_client/__init__.py +1 -0
- anyscale/client/openapi_client/api/default_api.py +538 -0
- anyscale/client/openapi_client/models/__init__.py +1 -0
- anyscale/client/openapi_client/models/baseimagesenum.py +83 -1
- anyscale/client/openapi_client/models/cloud_resource.py +59 -3
- anyscale/client/openapi_client/models/cloud_resource_gcp.py +59 -3
- anyscale/client/openapi_client/models/clouddeployment_response.py +121 -0
- anyscale/client/openapi_client/models/create_cloud_resource.py +59 -3
- anyscale/client/openapi_client/models/create_cloud_resource_gcp.py +59 -3
- anyscale/client/openapi_client/models/object_storage.py +2 -2
- anyscale/client/openapi_client/models/ray_runtime_env_config.py +57 -1
- anyscale/client/openapi_client/models/supportedbaseimagesenum.py +80 -1
- anyscale/cloud/models.py +1 -1
- anyscale/commands/cloud_commands.py +73 -70
- anyscale/commands/command_examples.py +28 -40
- anyscale/commands/project_commands.py +377 -106
- anyscale/commands/workspace_commands_v2.py +62 -29
- anyscale/controllers/cloud_controller.py +91 -91
- anyscale/job/_private/job_sdk.py +38 -20
- anyscale/project/__init__.py +101 -1
- anyscale/project/_private/project_sdk.py +90 -2
- anyscale/project/commands.py +188 -1
- anyscale/project/models.py +198 -2
- anyscale/sdk/anyscale_client/models/baseimagesenum.py +83 -1
- anyscale/sdk/anyscale_client/models/ray_runtime_env_config.py +57 -1
- anyscale/sdk/anyscale_client/models/supportedbaseimagesenum.py +80 -1
- anyscale/service/_private/service_sdk.py +2 -1
- anyscale/shared_anyscale_utils/latest_ray_version.py +1 -1
- anyscale/util.py +3 -0
- anyscale/utils/cloud_utils.py +20 -0
- anyscale/utils/runtime_env.py +3 -1
- anyscale/version.py +1 -1
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/METADATA +1 -1
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/RECORD +48 -47
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/WHEEL +0 -0
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/entry_points.txt +0 -0
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/licenses/LICENSE +0 -0
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/licenses/NOTICE +0 -0
- {anyscale-0.26.50.dist-info → anyscale-0.26.52.dist-info}/top_level.txt +0 -0
@@ -1,22 +1,99 @@
|
|
1
|
-
from
|
1
|
+
from io import StringIO
|
2
|
+
from json import dumps as json_dumps
|
3
|
+
import sys
|
4
|
+
from typing import Dict, Optional, Tuple
|
2
5
|
|
3
6
|
import click
|
7
|
+
from rich.console import Console
|
8
|
+
from rich.table import Table
|
9
|
+
import yaml
|
4
10
|
|
5
11
|
import anyscale
|
6
12
|
from anyscale.cli_logger import BlockLogger
|
7
13
|
from anyscale.commands import command_examples
|
8
|
-
from anyscale.commands.
|
14
|
+
from anyscale.commands.list_util import display_list
|
15
|
+
from anyscale.commands.util import AnyscaleCommand, NotRequiredIf
|
9
16
|
from anyscale.controllers.project_controller import ProjectController
|
10
17
|
from anyscale.project.models import (
|
11
18
|
CreateProjectCollaborator,
|
12
19
|
CreateProjectCollaborators,
|
20
|
+
Project,
|
21
|
+
ProjectMinimal,
|
22
|
+
ProjectSortField,
|
23
|
+
ProjectSortOrder,
|
13
24
|
)
|
14
25
|
from anyscale.project_utils import validate_project_name
|
15
|
-
from anyscale.util import
|
26
|
+
from anyscale.util import (
|
27
|
+
AnyscaleJSONEncoder,
|
28
|
+
get_endpoint,
|
29
|
+
)
|
16
30
|
|
17
31
|
|
18
32
|
log = BlockLogger()
|
19
33
|
|
34
|
+
MAX_PAGE_SIZE = 50
|
35
|
+
NON_INTERACTIVE_DEFAULT_MAX_ITEMS = 10
|
36
|
+
|
37
|
+
|
38
|
+
def _create_project_list_table(show_header: bool) -> Table:
|
39
|
+
table = Table(show_header=show_header, expand=True)
|
40
|
+
# NAME and ID: larger ratios, can wrap but never truncate
|
41
|
+
table.add_column(
|
42
|
+
"NAME", no_wrap=False, overflow="fold", ratio=3, min_width=15,
|
43
|
+
)
|
44
|
+
table.add_column(
|
45
|
+
"ID", no_wrap=False, overflow="fold", ratio=2, min_width=12,
|
46
|
+
)
|
47
|
+
# all other columns will wrap as needed
|
48
|
+
for heading in (
|
49
|
+
"DESCRIPTION",
|
50
|
+
"CREATED AT",
|
51
|
+
"CREATOR",
|
52
|
+
"PARENT CLOUD ID",
|
53
|
+
):
|
54
|
+
table.add_column(
|
55
|
+
heading, no_wrap=False, overflow="fold", ratio=1, min_width=8,
|
56
|
+
)
|
57
|
+
return table
|
58
|
+
|
59
|
+
|
60
|
+
def _format_project_output_data(project: Project) -> Dict[str, str]:
|
61
|
+
return {
|
62
|
+
"name": project.name,
|
63
|
+
"id": project.id,
|
64
|
+
"description": project.description,
|
65
|
+
"created_at": project.created_at,
|
66
|
+
"creator": str(project.creator_id or ""),
|
67
|
+
"parent_cloud_id": str(project.parent_cloud_id or ""),
|
68
|
+
}
|
69
|
+
|
70
|
+
|
71
|
+
def _parse_sort_option(
|
72
|
+
sort: Optional[str],
|
73
|
+
) -> Tuple[Optional[ProjectSortField], ProjectSortOrder]:
|
74
|
+
if not sort:
|
75
|
+
return None, ProjectSortOrder.ASC
|
76
|
+
|
77
|
+
# build case-insensitive map of allowed fields
|
78
|
+
allowed = {f.value.lower(): f.value for f in ProjectSortField.__members__.values()}
|
79
|
+
|
80
|
+
# detect leading '-' for descending
|
81
|
+
if sort.startswith("-"):
|
82
|
+
raw = sort[1:]
|
83
|
+
order = ProjectSortOrder.DESC
|
84
|
+
else:
|
85
|
+
raw = sort
|
86
|
+
order = ProjectSortOrder.ASC
|
87
|
+
|
88
|
+
key = raw.lower()
|
89
|
+
if key not in allowed:
|
90
|
+
allowed_names = ", ".join(sorted(allowed.values()))
|
91
|
+
raise click.BadParameter(
|
92
|
+
f"Invalid sort field '{raw}'. Allowed fields: {allowed_names}"
|
93
|
+
)
|
94
|
+
|
95
|
+
return ProjectSortField(allowed[key]), order
|
96
|
+
|
20
97
|
|
21
98
|
@click.group(
|
22
99
|
"project",
|
@@ -28,49 +105,316 @@ def project_cli() -> None:
|
|
28
105
|
|
29
106
|
|
30
107
|
@project_cli.command(
|
31
|
-
name="
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
is_limited_support=True,
|
36
|
-
legacy_prefix="anyscale project",
|
108
|
+
name="get",
|
109
|
+
help="Get details of a project.",
|
110
|
+
cls=AnyscaleCommand,
|
111
|
+
example=command_examples.PROJECT_GET_EXAMPLE,
|
37
112
|
)
|
38
113
|
@click.option(
|
39
|
-
"--
|
114
|
+
"--id", "-i", type=str, required=True, help="ID of the project.",
|
40
115
|
)
|
41
|
-
@click.option("--json", help="Format output as JSON.", is_flag=True)
|
42
116
|
@click.option(
|
43
|
-
"--
|
44
|
-
"-
|
45
|
-
help="[Deprecated] List projects created by any user.",
|
117
|
+
"--json",
|
118
|
+
"-j",
|
46
119
|
is_flag=True,
|
47
|
-
default=
|
48
|
-
|
120
|
+
default=False,
|
121
|
+
help="Output the details in a structured JSON format.",
|
122
|
+
)
|
123
|
+
def get(id: str, json: bool = False): # noqa: A002
|
124
|
+
try:
|
125
|
+
project: Project = anyscale.project.get(project_id=id)
|
126
|
+
except ValueError as e:
|
127
|
+
log.error(f"Error getting project details: {e}")
|
128
|
+
sys.exit(1)
|
129
|
+
|
130
|
+
console = Console()
|
131
|
+
if json:
|
132
|
+
json_str = json_dumps(project.to_dict(), indent=2, cls=AnyscaleJSONEncoder)
|
133
|
+
console.print_json(json=json_str)
|
134
|
+
else:
|
135
|
+
stream = StringIO()
|
136
|
+
yaml.dump(project.to_dict(), stream, sort_keys=False)
|
137
|
+
console.print(stream.getvalue(), end="")
|
138
|
+
|
139
|
+
|
140
|
+
@project_cli.command(
|
141
|
+
name="list",
|
142
|
+
help="List all projects with optional filters.",
|
143
|
+
cls=AnyscaleCommand,
|
144
|
+
example=command_examples.PROJECT_LIST_EXAMPLE,
|
49
145
|
)
|
50
|
-
@click.option("--created-by-me", help="List projects created by me only.", is_flag=True)
|
51
146
|
@click.option(
|
52
|
-
"--
|
53
|
-
|
54
|
-
|
147
|
+
"--name", "-n", type=str, help="A string to filter projects by name.",
|
148
|
+
)
|
149
|
+
@click.option(
|
150
|
+
"--creator", "-u", type=str, help="The ID of a creator to filter projects.",
|
151
|
+
)
|
152
|
+
@click.option(
|
153
|
+
"--cloud", "-c", type=str, help="The ID of a parent cloud to filter projects.",
|
154
|
+
)
|
155
|
+
@click.option(
|
156
|
+
"--include-defaults/--exclude-defaults",
|
157
|
+
default=True,
|
158
|
+
show_default=True,
|
159
|
+
help="Whether to include default projects.",
|
160
|
+
)
|
161
|
+
@click.option(
|
162
|
+
"--max-items", type=int, help="The maximum number of projects to return.",
|
163
|
+
)
|
164
|
+
@click.option(
|
165
|
+
"--page-size",
|
55
166
|
type=int,
|
56
|
-
|
57
|
-
|
167
|
+
default=10,
|
168
|
+
show_default=True,
|
169
|
+
help="The number of projects to return per page.",
|
170
|
+
)
|
171
|
+
@click.option(
|
172
|
+
"--sort",
|
173
|
+
help=(
|
174
|
+
"Sort by FIELD (prefix with '-' for desc). "
|
175
|
+
f"Allowed: {', '.join(ProjectSortField.__members__.values())}"
|
176
|
+
),
|
177
|
+
)
|
178
|
+
@click.option(
|
179
|
+
"--interactive/--no-interactive",
|
180
|
+
default=True,
|
181
|
+
show_default=True,
|
182
|
+
help="Use interactive paging.",
|
183
|
+
)
|
184
|
+
@click.option(
|
185
|
+
"--json",
|
186
|
+
"-j",
|
187
|
+
is_flag=True,
|
188
|
+
default=False,
|
189
|
+
help="Output the list in a structured JSON format.",
|
58
190
|
)
|
59
191
|
def list( # noqa: A001
|
192
|
+
*,
|
193
|
+
name: Optional[str] = None,
|
194
|
+
creator: Optional[str] = None,
|
195
|
+
cloud: Optional[str] = None,
|
196
|
+
include_defaults: bool = True,
|
197
|
+
max_items: Optional[int] = None,
|
198
|
+
page_size: Optional[int] = None,
|
199
|
+
sort: Optional[str] = None,
|
200
|
+
interactive: bool = True,
|
201
|
+
json: bool = False,
|
202
|
+
):
|
203
|
+
|
204
|
+
if max_items is not None and interactive:
|
205
|
+
raise click.UsageError("--max-items only allowed with --no-interactive")
|
206
|
+
|
207
|
+
sort_field, sort_order = _parse_sort_option(sort)
|
208
|
+
|
209
|
+
# normalize max_items
|
210
|
+
effective_max = max_items
|
211
|
+
if not interactive and effective_max is None:
|
212
|
+
stderr = Console(stderr=True)
|
213
|
+
stderr.print(
|
214
|
+
f"Defaulting to {NON_INTERACTIVE_DEFAULT_MAX_ITEMS} items in batch mode; "
|
215
|
+
"use --max-items to override."
|
216
|
+
)
|
217
|
+
effective_max = NON_INTERACTIVE_DEFAULT_MAX_ITEMS
|
218
|
+
|
219
|
+
console = Console()
|
220
|
+
stderr = Console(stderr=True)
|
221
|
+
|
222
|
+
# diagnostics
|
223
|
+
stderr.print("[bold]Listing projects with:[/]")
|
224
|
+
stderr.print(f"• name_contains = {name or '<any>'}")
|
225
|
+
stderr.print(f"• creator_id = {creator or '<any>'}")
|
226
|
+
stderr.print(f"• parent_cloud_id = {cloud or '<any>'}")
|
227
|
+
stderr.print(f"• include defaults = {include_defaults}")
|
228
|
+
stderr.print(f"• sort-field = {sort_field or '<none>'}")
|
229
|
+
stderr.print(f"• sort-order = {sort_order or '<none>'}")
|
230
|
+
stderr.print(f"• mode = {'interactive' if interactive else 'batch'}")
|
231
|
+
stderr.print(f"• per-page limit = {page_size}")
|
232
|
+
stderr.print(f"• max-items total = {effective_max or 'all'}")
|
233
|
+
stderr.print(f"\nView your Projects in the UI at {get_endpoint('/projects')}\n")
|
234
|
+
|
235
|
+
# choose formatter
|
236
|
+
if json:
|
237
|
+
|
238
|
+
def formatter(project):
|
239
|
+
return ProjectMinimal.from_dict(project.to_dict()).to_dict()
|
240
|
+
|
241
|
+
else:
|
242
|
+
formatter = _format_project_output_data
|
243
|
+
|
244
|
+
total = 0
|
245
|
+
try:
|
246
|
+
iterator = anyscale.project.list(
|
247
|
+
name_contains=name,
|
248
|
+
creator_id=creator,
|
249
|
+
parent_cloud_id=cloud,
|
250
|
+
include_defaults=include_defaults,
|
251
|
+
max_items=effective_max,
|
252
|
+
page_size=page_size,
|
253
|
+
sort_field=sort_field,
|
254
|
+
sort_order=sort_order,
|
255
|
+
)
|
256
|
+
total = display_list(
|
257
|
+
iterator=iter(iterator),
|
258
|
+
item_formatter=formatter,
|
259
|
+
table_creator=_create_project_list_table,
|
260
|
+
json_output=json,
|
261
|
+
page_size=page_size or MAX_PAGE_SIZE,
|
262
|
+
interactive=interactive,
|
263
|
+
max_items=effective_max,
|
264
|
+
console=console,
|
265
|
+
)
|
266
|
+
|
267
|
+
if not json:
|
268
|
+
if total > 0:
|
269
|
+
stderr.print(f"\nFetched {total} projects.")
|
270
|
+
else:
|
271
|
+
stderr.print("\nNo projects found.")
|
272
|
+
except Exception as e: # noqa: BLE001
|
273
|
+
log.error(f"Failed to list projects: {e}")
|
274
|
+
sys.exit(1)
|
275
|
+
|
276
|
+
|
277
|
+
@project_cli.command(
|
278
|
+
name="create",
|
279
|
+
help="Create a new project.",
|
280
|
+
cls=AnyscaleCommand,
|
281
|
+
example=command_examples.PROJECT_CREATE_EXAMPLE,
|
282
|
+
)
|
283
|
+
@click.option(
|
284
|
+
"--name", "-n", type=str, required=True, help="Name of the project.",
|
285
|
+
)
|
286
|
+
@click.option(
|
287
|
+
"--cloud", "-c", type=str, required=True, help="Parent cloud ID for the project.",
|
288
|
+
)
|
289
|
+
@click.option(
|
290
|
+
"--description", "-d", type=str, help="Description of the project.",
|
291
|
+
)
|
292
|
+
@click.option(
|
293
|
+
"--initial-cluster-config",
|
294
|
+
"-f",
|
295
|
+
type=str,
|
296
|
+
help="Initial cluster config for the project.",
|
297
|
+
)
|
298
|
+
def create(
|
60
299
|
name: str,
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
300
|
+
cloud: str,
|
301
|
+
*,
|
302
|
+
description: Optional[str] = None,
|
303
|
+
initial_cluster_config: Optional[str] = None,
|
65
304
|
) -> None:
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
305
|
+
try:
|
306
|
+
project_id: str = anyscale.project.create(
|
307
|
+
name,
|
308
|
+
cloud,
|
309
|
+
description=description or "",
|
310
|
+
initial_cluster_config=initial_cluster_config,
|
71
311
|
)
|
72
|
-
|
73
|
-
|
312
|
+
except ValueError as e:
|
313
|
+
log.error(f"Error creating project: {e}")
|
314
|
+
sys.exit(1)
|
315
|
+
|
316
|
+
log.info(f"Created project '{name}' with ID: {project_id}")
|
317
|
+
|
318
|
+
|
319
|
+
@project_cli.command(
|
320
|
+
name="delete",
|
321
|
+
help="Delete a project.",
|
322
|
+
cls=AnyscaleCommand,
|
323
|
+
example=command_examples.PROJECT_DELETE_EXAMPLE,
|
324
|
+
)
|
325
|
+
@click.option(
|
326
|
+
"--id", "-i", type=str, required=True, help="ID of the project to delete.",
|
327
|
+
)
|
328
|
+
def delete(id: str): # noqa: A002
|
329
|
+
try:
|
330
|
+
anyscale.project.delete(id)
|
331
|
+
except ValueError as e:
|
332
|
+
log.error(f"Error deleting project: {e}")
|
333
|
+
sys.exit(1)
|
334
|
+
|
335
|
+
log.info(f"Deleted project '{id}'")
|
336
|
+
|
337
|
+
|
338
|
+
@project_cli.command(
|
339
|
+
name="get-default",
|
340
|
+
help="Get the default project for a cloud.",
|
341
|
+
cls=AnyscaleCommand,
|
342
|
+
example=command_examples.PROJECT_GET_DEFAULT_EXAMPLE,
|
343
|
+
)
|
344
|
+
@click.option(
|
345
|
+
"--cloud", "-c", type=str, required=True, help="Parent cloud ID for the project.",
|
346
|
+
)
|
347
|
+
@click.option(
|
348
|
+
"--json",
|
349
|
+
"-j",
|
350
|
+
is_flag=True,
|
351
|
+
default=False,
|
352
|
+
help="Output the project in a structured JSON format.",
|
353
|
+
)
|
354
|
+
def get_default(cloud: str, json: bool = False):
|
355
|
+
try:
|
356
|
+
project: Project = anyscale.project.get_default(cloud)
|
357
|
+
except ValueError as e:
|
358
|
+
log.error(f"Error getting default project for cloud '{cloud}': {e}")
|
359
|
+
sys.exit(1)
|
360
|
+
|
361
|
+
console = Console()
|
362
|
+
if json:
|
363
|
+
json_str = json_dumps(project.to_dict(), indent=2, cls=AnyscaleJSONEncoder)
|
364
|
+
console.print_json(json=json_str)
|
365
|
+
else:
|
366
|
+
stream = StringIO()
|
367
|
+
yaml.dump(project.to_dict(), stream, sort_keys=False)
|
368
|
+
console.print(stream.getvalue(), end="")
|
369
|
+
|
370
|
+
|
371
|
+
@project_cli.command(
|
372
|
+
name="add-collaborators",
|
373
|
+
help="Add collaborators to the project.",
|
374
|
+
cls=AnyscaleCommand,
|
375
|
+
example=command_examples.PROJECT_ADD_COLLABORATORS_EXAMPLE,
|
376
|
+
)
|
377
|
+
@click.option(
|
378
|
+
"--cloud",
|
379
|
+
"-c",
|
380
|
+
help="Name of the cloud that the project belongs to.",
|
381
|
+
required=True,
|
382
|
+
)
|
383
|
+
@click.option(
|
384
|
+
"--project",
|
385
|
+
"-p",
|
386
|
+
help="Name of the project to add collaborators to.",
|
387
|
+
required=True,
|
388
|
+
)
|
389
|
+
@click.option(
|
390
|
+
"--users-file",
|
391
|
+
help="Path to a YAML file containing a list of users to add to the project.",
|
392
|
+
required=True,
|
393
|
+
)
|
394
|
+
def add_collaborators(cloud: str, project: str, users_file: str) -> None:
|
395
|
+
collaborators = CreateProjectCollaborators.from_yaml(users_file)
|
396
|
+
|
397
|
+
try:
|
398
|
+
anyscale.project.add_collaborators(
|
399
|
+
cloud=cloud,
|
400
|
+
project=project,
|
401
|
+
collaborators=[
|
402
|
+
CreateProjectCollaborator(**collaborator)
|
403
|
+
for collaborator in collaborators.collaborators
|
404
|
+
],
|
405
|
+
)
|
406
|
+
except ValueError as e:
|
407
|
+
log.error(f"Error adding collaborators to project: {e}")
|
408
|
+
return
|
409
|
+
|
410
|
+
log.info(
|
411
|
+
f"Successfully added {len(collaborators.collaborators)} collaborators to project {project}."
|
412
|
+
)
|
413
|
+
|
414
|
+
|
415
|
+
# ================================================
|
416
|
+
# LEGACY CODE
|
417
|
+
# ================================================
|
74
418
|
|
75
419
|
|
76
420
|
def _validate_project_name(ctx, param, value) -> str: # noqa: ARG001
|
@@ -178,76 +522,3 @@ def init(project_id: Optional[str], name: Optional[str],) -> None:
|
|
178
522
|
|
179
523
|
project_controller = ProjectController()
|
180
524
|
project_controller.init(project_id, name, None, None)
|
181
|
-
|
182
|
-
|
183
|
-
@project_cli.command(
|
184
|
-
name="create",
|
185
|
-
help="Create a new project.",
|
186
|
-
cls=LegacyAnyscaleCommand,
|
187
|
-
is_limited_support=True,
|
188
|
-
legacy_prefix="anyscale project",
|
189
|
-
)
|
190
|
-
@click.option(
|
191
|
-
"--name",
|
192
|
-
"-n",
|
193
|
-
help="Project name.",
|
194
|
-
callback=_validate_project_name,
|
195
|
-
prompt=True,
|
196
|
-
default=_default_project_name(),
|
197
|
-
)
|
198
|
-
@click.option(
|
199
|
-
"--parent-cloud-id",
|
200
|
-
required=False,
|
201
|
-
default=None,
|
202
|
-
help=(
|
203
|
-
"Cloud id that this project is associated with. This argument "
|
204
|
-
"is only relevant if cloud isolation is enabled."
|
205
|
-
),
|
206
|
-
)
|
207
|
-
def create(name: str, parent_cloud_id: str) -> None:
|
208
|
-
project_controller = ProjectController()
|
209
|
-
project_controller.create(name, parent_cloud_id)
|
210
|
-
|
211
|
-
|
212
|
-
@project_cli.command(
|
213
|
-
name="add-collaborators",
|
214
|
-
help="Add collaborators to the project.",
|
215
|
-
cls=AnyscaleCommand,
|
216
|
-
example=command_examples.PROJECT_ADD_COLLABORATORS_EXAMPLE,
|
217
|
-
)
|
218
|
-
@click.option(
|
219
|
-
"--cloud",
|
220
|
-
"-c",
|
221
|
-
help="Name of the cloud that the project belongs to.",
|
222
|
-
required=True,
|
223
|
-
)
|
224
|
-
@click.option(
|
225
|
-
"--project",
|
226
|
-
"-p",
|
227
|
-
help="Name of the project to add collaborators to.",
|
228
|
-
required=True,
|
229
|
-
)
|
230
|
-
@click.option(
|
231
|
-
"--users-file",
|
232
|
-
help="Path to a YAML file containing a list of users to add to the project.",
|
233
|
-
required=True,
|
234
|
-
)
|
235
|
-
def add_collaborators(cloud: str, project: str, users_file: str,) -> None:
|
236
|
-
collaborators = CreateProjectCollaborators.from_yaml(users_file)
|
237
|
-
|
238
|
-
try:
|
239
|
-
anyscale.project.add_collaborators(
|
240
|
-
cloud=cloud,
|
241
|
-
project=project,
|
242
|
-
collaborators=[
|
243
|
-
CreateProjectCollaborator(**collaborator)
|
244
|
-
for collaborator in collaborators.collaborators
|
245
|
-
],
|
246
|
-
)
|
247
|
-
except ValueError as e:
|
248
|
-
log.error(f"Error adding collaborators to project: {e}")
|
249
|
-
return
|
250
|
-
|
251
|
-
log.info(
|
252
|
-
f"Successfully added {len(collaborators.collaborators)} collaborators to project {project}."
|
253
|
-
)
|
@@ -34,7 +34,7 @@ log = BlockLogger() # CLI Logger
|
|
34
34
|
|
35
35
|
# Constants for SSH configuration
|
36
36
|
HTTPS_PORT = "443"
|
37
|
-
|
37
|
+
SSH_TEST_TIMEOUT_SECONDS = 8
|
38
38
|
WSS_PATH = "/sshws"
|
39
39
|
PREFERRED_AUTH_METHOD = "PreferredAuthentications=publickey"
|
40
40
|
|
@@ -319,15 +319,10 @@ def _parse_user_args(user_args: List[str]) -> Tuple[List[str], List[str]]:
|
|
319
319
|
return user_options, user_command
|
320
320
|
|
321
321
|
|
322
|
-
def
|
323
|
-
workspace_obj: Workspace,
|
324
|
-
workspace_private_sdk,
|
325
|
-
host_name: str,
|
326
|
-
config_file: str,
|
327
|
-
ctx_args: List[str],
|
328
|
-
shell_command: str,
|
322
|
+
def _test_https_connectivity(
|
323
|
+
workspace_obj: Workspace, workspace_private_sdk, host_name: str, config_file: str,
|
329
324
|
) -> bool:
|
330
|
-
"""
|
325
|
+
"""Test HTTPS SSH connectivity with a quick command. Returns True if available."""
|
331
326
|
try:
|
332
327
|
cluster = workspace_private_sdk.client.get_workspace_cluster(workspace_obj.id)
|
333
328
|
if not cluster:
|
@@ -337,35 +332,62 @@ def _try_https_connection(
|
|
337
332
|
workspace_obj, workspace_private_sdk, host_name, config_file
|
338
333
|
)
|
339
334
|
|
340
|
-
|
335
|
+
# Build a test command using the same logic as the actual connection
|
336
|
+
# but with a simple echo command that exits immediately
|
337
|
+
test_args = [
|
338
|
+
"-o",
|
339
|
+
"ConnectTimeout=5",
|
340
|
+
"-o",
|
341
|
+
"BatchMode=yes",
|
342
|
+
"echo",
|
343
|
+
"connectivity_test",
|
344
|
+
]
|
345
|
+
test_cmd = _build_ssh_command(ssh_config, test_args, "")
|
341
346
|
|
342
|
-
#
|
347
|
+
# Run test with a timeout
|
343
348
|
result = subprocess.run(
|
344
|
-
|
349
|
+
test_cmd,
|
345
350
|
check=False,
|
346
|
-
timeout=
|
347
|
-
|
351
|
+
timeout=SSH_TEST_TIMEOUT_SECONDS,
|
352
|
+
capture_output=True,
|
353
|
+
text=True,
|
348
354
|
)
|
349
355
|
|
350
|
-
|
356
|
+
# Check if we got the expected output
|
357
|
+
if result.returncode == 0 and "connectivity_test" in result.stdout:
|
358
|
+
return True
|
359
|
+
|
360
|
+
# Connection failed - no need to show error code to user
|
361
|
+
return False
|
351
362
|
|
352
363
|
except subprocess.TimeoutExpired:
|
353
|
-
|
354
|
-
"HTTPS connection timed out or failed (SSH proxy may not be available), "
|
355
|
-
"falling back to Legacy SSH connection..."
|
356
|
-
)
|
364
|
+
# Silent failure - the main code will show a user-friendly message
|
357
365
|
return False
|
358
366
|
except OSError:
|
359
|
-
|
360
|
-
"HTTPS connection timed out or failed (SSH proxy may not be available), "
|
361
|
-
"falling back to Legacy SSH connection..."
|
362
|
-
)
|
367
|
+
# Silent failure
|
363
368
|
return False
|
364
369
|
except (click.ClickException, ValueError, AttributeError, KeyError, TypeError):
|
365
|
-
|
370
|
+
# Silent failure
|
366
371
|
return False
|
367
372
|
|
368
373
|
|
374
|
+
def _execute_https_ssh(
|
375
|
+
workspace_obj: Workspace,
|
376
|
+
workspace_private_sdk,
|
377
|
+
host_name: str,
|
378
|
+
config_file: str,
|
379
|
+
ctx_args: List[str],
|
380
|
+
shell_command: str,
|
381
|
+
) -> None:
|
382
|
+
"""Execute HTTPS SSH connection without timeout."""
|
383
|
+
ssh_config = _setup_https_connection(
|
384
|
+
workspace_obj, workspace_private_sdk, host_name, config_file
|
385
|
+
)
|
386
|
+
ssh_cmd = _build_ssh_command(ssh_config, ctx_args, shell_command)
|
387
|
+
# Run the actual SSH session without any timeout
|
388
|
+
subprocess.run(ssh_cmd, check=False)
|
389
|
+
|
390
|
+
|
369
391
|
def _execute_legacy_ssh(
|
370
392
|
ssh_target_host: str, config_file: str, ctx_args: List[str], shell_command: str,
|
371
393
|
) -> None:
|
@@ -857,21 +879,32 @@ id should be used, specifying both will result in an error.
|
|
857
879
|
)
|
858
880
|
|
859
881
|
if cluster:
|
860
|
-
https_connection_successful =
|
882
|
+
https_connection_successful = _test_https_connectivity(
|
861
883
|
workspace_obj,
|
862
884
|
workspace_private_sdk,
|
863
885
|
host_name,
|
864
886
|
config_file,
|
865
|
-
ctx.args,
|
866
|
-
shell_command,
|
867
887
|
)
|
868
888
|
|
869
889
|
except (ValueError, AttributeError, KeyError, TypeError):
|
870
890
|
# If we can't get workspace/cluster info, proceed with legacy SSH
|
871
891
|
pass
|
872
892
|
|
873
|
-
#
|
874
|
-
if
|
893
|
+
# Execute the appropriate SSH connection based on test results
|
894
|
+
if https_connection_successful:
|
895
|
+
# HTTPS connectivity test passed, run actual SSH session without timeout
|
896
|
+
_execute_https_ssh(
|
897
|
+
workspace_obj,
|
898
|
+
workspace_private_sdk,
|
899
|
+
host_name,
|
900
|
+
config_file,
|
901
|
+
ctx.args,
|
902
|
+
shell_command,
|
903
|
+
)
|
904
|
+
else:
|
905
|
+
# HTTPS test failed or --legacy was specified, use legacy SSH
|
906
|
+
if not legacy: # Only show message if we tried HTTPS first
|
907
|
+
print("Connecting via standard SSH...")
|
875
908
|
_execute_legacy_ssh(
|
876
909
|
ssh_target_host, config_file, ctx.args, shell_command
|
877
910
|
)
|