anyscale 0.26.69__py3-none-any.whl → 0.26.71__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/anyscale_client.py +126 -3
- anyscale/_private/anyscale_client/common.py +51 -2
- anyscale/_private/anyscale_client/fake_anyscale_client.py +103 -11
- anyscale/client/README.md +43 -4
- anyscale/client/openapi_client/__init__.py +30 -4
- anyscale/client/openapi_client/api/default_api.py +1769 -27
- anyscale/client/openapi_client/models/__init__.py +30 -4
- anyscale/client/openapi_client/models/api_key_info.py +29 -3
- anyscale/client/openapi_client/models/apply_autoscaling_config_update_model.py +350 -0
- anyscale/client/openapi_client/models/apply_multi_version_update_weights_update_model.py +152 -0
- anyscale/client/openapi_client/models/apply_production_service_multi_version_v2_model.py +207 -0
- anyscale/client/openapi_client/models/apply_production_service_v2_model.py +31 -3
- anyscale/client/openapi_client/models/apply_version_weight_update_model.py +181 -0
- anyscale/client/openapi_client/models/backend_server_api_product_models_catalog_client_models_table_metadata.py +546 -0
- anyscale/client/openapi_client/models/backend_server_api_product_models_data_catalogs_table_metadata.py +178 -0
- anyscale/client/openapi_client/models/baseimagesenum.py +139 -1
- anyscale/client/openapi_client/models/catalog_metadata.py +150 -0
- anyscale/client/openapi_client/models/cloud_data_bucket_file_type.py +2 -1
- anyscale/client/openapi_client/models/{oauthconnectionresponse_response.py → clouddeployment_response.py} +11 -11
- anyscale/client/openapi_client/models/column_info.py +265 -0
- anyscale/client/openapi_client/models/compute_node_type.py +29 -1
- anyscale/client/openapi_client/models/connection_metadata.py +206 -0
- anyscale/client/openapi_client/models/create_experimental_workspace.py +29 -1
- anyscale/client/openapi_client/models/create_workspace_from_template.py +29 -1
- anyscale/client/openapi_client/models/create_workspace_template_version.py +59 -3
- anyscale/client/openapi_client/models/data_catalog.py +45 -31
- anyscale/client/openapi_client/models/data_catalog_connection.py +74 -58
- anyscale/client/openapi_client/models/{ha_job_event_level.py → data_catalog_object_type.py} +7 -8
- anyscale/client/openapi_client/models/data_catalog_schema.py +324 -0
- anyscale/client/openapi_client/models/data_catalog_table.py +437 -0
- anyscale/client/openapi_client/models/data_catalog_volume.py +437 -0
- anyscale/client/openapi_client/models/datacatalogschema_list_response.py +147 -0
- anyscale/client/openapi_client/models/datacatalogtable_list_response.py +147 -0
- anyscale/client/openapi_client/models/datacatalogvolume_list_response.py +147 -0
- anyscale/client/openapi_client/models/decorated_list_service_api_model.py +58 -1
- anyscale/client/openapi_client/models/decorated_production_service_v2_api_model.py +60 -3
- anyscale/client/openapi_client/models/decorated_serve_deployment.py +27 -1
- anyscale/client/openapi_client/models/decorated_service_event_api_model.py +3 -3
- anyscale/client/openapi_client/models/decoratedproductionservicev2_versionapimodel_response.py +121 -0
- anyscale/client/openapi_client/models/describe_machine_pool_machines_filters.py +33 -5
- anyscale/client/openapi_client/models/describe_machine_pool_requests_filters.py +33 -5
- anyscale/client/openapi_client/models/describe_machine_pool_workloads_filters.py +33 -5
- anyscale/client/openapi_client/models/{service_event_level.py → entity_type.py} +9 -9
- anyscale/client/openapi_client/models/event_level.py +2 -1
- anyscale/client/openapi_client/models/job_event_fields.py +206 -0
- anyscale/client/openapi_client/models/machine_type_partition_filter.py +152 -0
- anyscale/client/openapi_client/models/partition_info.py +30 -1
- anyscale/client/openapi_client/models/physical_resources.py +178 -0
- anyscale/client/openapi_client/models/production_job_event.py +3 -3
- anyscale/client/openapi_client/models/rollout_strategy.py +2 -1
- anyscale/client/openapi_client/models/schema_metadata.py +150 -0
- anyscale/client/openapi_client/models/service_event_fields.py +318 -0
- anyscale/client/openapi_client/models/sso_config.py +18 -18
- anyscale/client/openapi_client/models/supportedbaseimagesenum.py +139 -1
- anyscale/client/openapi_client/models/table_data_preview.py +209 -0
- anyscale/client/openapi_client/models/task_summary_config.py +29 -3
- anyscale/client/openapi_client/models/task_table_config.py +29 -3
- anyscale/client/openapi_client/models/unified_event.py +377 -0
- anyscale/client/openapi_client/models/unified_origin_filter.py +113 -0
- anyscale/client/openapi_client/models/unifiedevent_list_response.py +147 -0
- anyscale/client/openapi_client/models/volume_metadata.py +150 -0
- anyscale/client/openapi_client/models/worker_node_type.py +29 -1
- anyscale/client/openapi_client/models/workspace_event_fields.py +122 -0
- anyscale/client/openapi_client/models/workspace_template_version.py +58 -1
- anyscale/client/openapi_client/models/workspace_template_version_data_object.py +58 -1
- anyscale/cloud/models.py +2 -2
- anyscale/commands/cloud_commands.py +133 -2
- anyscale/commands/job_commands.py +121 -1
- anyscale/commands/job_queue_commands.py +99 -2
- anyscale/commands/service_commands.py +267 -67
- anyscale/commands/setup_k8s.py +546 -31
- anyscale/commands/util.py +104 -1
- anyscale/commands/workspace_commands.py +123 -5
- anyscale/commands/workspace_commands_v2.py +17 -1
- anyscale/compute_config/_private/compute_config_sdk.py +25 -12
- anyscale/compute_config/models.py +15 -0
- anyscale/controllers/cloud_controller.py +15 -2
- anyscale/controllers/job_controller.py +12 -0
- anyscale/controllers/kubernetes_verifier.py +80 -66
- anyscale/controllers/workspace_controller.py +67 -5
- anyscale/job/_private/job_sdk.py +50 -2
- anyscale/job/commands.py +3 -0
- anyscale/job/models.py +16 -0
- anyscale/job_queue/__init__.py +37 -1
- anyscale/job_queue/_private/job_queue_sdk.py +28 -1
- anyscale/job_queue/commands.py +61 -1
- anyscale/sdk/anyscale_client/__init__.py +1 -0
- anyscale/sdk/anyscale_client/api/default_api.py +12 -2
- anyscale/sdk/anyscale_client/models/__init__.py +1 -0
- anyscale/sdk/anyscale_client/models/apply_production_service_v2_model.py +31 -3
- anyscale/sdk/anyscale_client/models/apply_service_model.py +31 -3
- anyscale/sdk/anyscale_client/models/baseimagesenum.py +139 -1
- anyscale/sdk/anyscale_client/models/compute_node_type.py +29 -1
- anyscale/sdk/anyscale_client/models/physical_resources.py +178 -0
- anyscale/sdk/anyscale_client/models/rollout_strategy.py +2 -1
- anyscale/sdk/anyscale_client/models/supportedbaseimagesenum.py +139 -1
- anyscale/sdk/anyscale_client/models/worker_node_type.py +29 -1
- anyscale/service/__init__.py +51 -3
- anyscale/service/_private/service_sdk.py +481 -58
- anyscale/service/commands.py +90 -4
- anyscale/service/models.py +56 -0
- anyscale/shared_anyscale_utils/latest_ray_version.py +1 -1
- anyscale/version.py +1 -1
- anyscale/workspace/_private/workspace_sdk.py +1 -0
- anyscale/workspace/models.py +19 -0
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/METADATA +1 -1
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/RECORD +112 -85
- anyscale/client/openapi_client/models/o_auth_connection_response.py +0 -229
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/WHEEL +0 -0
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/entry_points.txt +0 -0
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/licenses/LICENSE +0 -0
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/licenses/NOTICE +0 -0
- {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/top_level.txt +0 -0
anyscale/commands/util.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from copy import deepcopy
|
|
2
2
|
from datetime import date, datetime
|
|
3
3
|
import sys
|
|
4
|
-
from typing import Dict, Optional, Tuple, TypeVar
|
|
4
|
+
from typing import Dict, Iterable, List, Optional, Tuple, TypeVar
|
|
5
5
|
|
|
6
6
|
import click
|
|
7
7
|
import colorama
|
|
8
|
+
from rich.table import Table
|
|
8
9
|
|
|
9
10
|
from anyscale._private.workload import WorkloadConfig
|
|
10
11
|
from anyscale.cli_logger import BlockLogger
|
|
@@ -160,6 +161,108 @@ def override_env_vars(config: T, overrides: Dict[str, str]) -> T:
|
|
|
160
161
|
return config.options(env_vars=final_env_vars)
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
def parse_repeatable_tags_to_dict(strings: Iterable[str]) -> Dict[str, List[str]]:
|
|
165
|
+
"""Parse repeatable --tag args into dict[key] -> list[values].
|
|
166
|
+
|
|
167
|
+
Accepts both "key:value" and "key=value". Ignores malformed entries.
|
|
168
|
+
Values for the same key are ORed; different keys are ANDed by the backend.
|
|
169
|
+
"""
|
|
170
|
+
result: Dict[str, List[str]] = {}
|
|
171
|
+
for raw in strings or []:
|
|
172
|
+
if ":" in raw:
|
|
173
|
+
key, value = raw.split(":", 1)
|
|
174
|
+
elif "=" in raw:
|
|
175
|
+
key, value = raw.split("=", 1)
|
|
176
|
+
else:
|
|
177
|
+
continue
|
|
178
|
+
key = key.strip()
|
|
179
|
+
value = value.strip()
|
|
180
|
+
if not key or not value:
|
|
181
|
+
continue
|
|
182
|
+
result.setdefault(key, []).append(value)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def normalize_tags_to_api_list(strings: Iterable[str]) -> List[str]:
|
|
187
|
+
"""Normalize repeatable --tag args into API wire format list[str] "key:value".
|
|
188
|
+
|
|
189
|
+
Accepts both "key:value" and "key=value". Ignores malformed entries.
|
|
190
|
+
"""
|
|
191
|
+
flattened: List[str] = []
|
|
192
|
+
for raw in strings or []:
|
|
193
|
+
if ":" in raw:
|
|
194
|
+
key, value = raw.split(":", 1)
|
|
195
|
+
elif "=" in raw:
|
|
196
|
+
key, value = raw.split("=", 1)
|
|
197
|
+
else:
|
|
198
|
+
continue
|
|
199
|
+
key = key.strip()
|
|
200
|
+
value = value.strip()
|
|
201
|
+
if not key or not value:
|
|
202
|
+
continue
|
|
203
|
+
flattened.append(f"{key}:{value}")
|
|
204
|
+
return flattened
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def flatten_tag_dict_to_api_list(
|
|
208
|
+
tags: Optional[Dict[str, List[str]]]
|
|
209
|
+
) -> Optional[List[str]]:
|
|
210
|
+
"""Flatten dict[key] -> list[values] into list[str] "key:value" for API.
|
|
211
|
+
|
|
212
|
+
Returns None if input is None or empty after normalization.
|
|
213
|
+
"""
|
|
214
|
+
if not tags:
|
|
215
|
+
return None
|
|
216
|
+
out: List[str] = []
|
|
217
|
+
for key, values in tags.items():
|
|
218
|
+
if not key:
|
|
219
|
+
continue
|
|
220
|
+
for value in values or []:
|
|
221
|
+
if value:
|
|
222
|
+
out.append(f"{key}:{value}")
|
|
223
|
+
return out if out else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def parse_tags_kv_to_str_map(pairs: Iterable[str]) -> Dict[str, str]:
|
|
227
|
+
"""Parse repeatable key=value (or key:value) into a simple {key: value} map.
|
|
228
|
+
|
|
229
|
+
Last occurrence wins for duplicate keys. Malformed entries are ignored.
|
|
230
|
+
"""
|
|
231
|
+
result: Dict[str, str] = {}
|
|
232
|
+
for raw in pairs or []:
|
|
233
|
+
if ":" in raw:
|
|
234
|
+
key, value = raw.split(":", 1)
|
|
235
|
+
elif "=" in raw:
|
|
236
|
+
key, value = raw.split("=", 1)
|
|
237
|
+
else:
|
|
238
|
+
continue
|
|
239
|
+
key = key.strip()
|
|
240
|
+
value = value.strip()
|
|
241
|
+
if not key or not value:
|
|
242
|
+
continue
|
|
243
|
+
result[key] = value
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def build_kv_table(
|
|
248
|
+
pairs: Iterable[Tuple[str, str]], *, title: Optional[str] = None
|
|
249
|
+
) -> Table:
|
|
250
|
+
"""Build a Rich table for key/value pairs.
|
|
251
|
+
|
|
252
|
+
- Sorts rows by key then value for stable output
|
|
253
|
+
- Columns wrap to fit smaller terminals
|
|
254
|
+
"""
|
|
255
|
+
table = Table(show_header=True, header_style="bold", expand=False, title=title)
|
|
256
|
+
table.add_column("KEY", overflow="fold")
|
|
257
|
+
table.add_column("VALUE", overflow="fold")
|
|
258
|
+
sorted_pairs = sorted(
|
|
259
|
+
[(str(k), str(v)) for k, v in (pairs or [])], key=lambda kv: (kv[0], kv[1])
|
|
260
|
+
)
|
|
261
|
+
for key, value in sorted_pairs:
|
|
262
|
+
table.add_row(key, value)
|
|
263
|
+
return table
|
|
264
|
+
|
|
265
|
+
|
|
163
266
|
class DeprecatedAnyscaleCommand(click.Command):
|
|
164
267
|
"""
|
|
165
268
|
DeprecatedAnyscaleCommand is a subclass of click.Command that shows deprecation warnings.
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from json import dumps as json_dumps
|
|
2
3
|
import os
|
|
3
4
|
import sys
|
|
4
|
-
from typing import Any, Tuple
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
5
6
|
|
|
6
7
|
import click
|
|
7
8
|
import requests
|
|
9
|
+
from rich.console import Console
|
|
8
10
|
|
|
9
11
|
from anyscale.authenticate import get_auth_api_client
|
|
10
12
|
from anyscale.cli_logger import BlockLogger
|
|
11
13
|
from anyscale.client.openapi_client.models.execute_interactive_command_options import (
|
|
12
14
|
ExecuteInteractiveCommandOptions,
|
|
13
15
|
)
|
|
14
|
-
from anyscale.
|
|
16
|
+
from anyscale.client.openapi_client.models.resource_tag_resource_type import (
|
|
17
|
+
ResourceTagResourceType,
|
|
18
|
+
)
|
|
19
|
+
from anyscale.commands.util import (
|
|
20
|
+
build_kv_table,
|
|
21
|
+
DeprecatedAnyscaleCommand,
|
|
22
|
+
LegacyAnyscaleCommand,
|
|
23
|
+
parse_repeatable_tags_to_dict,
|
|
24
|
+
parse_tags_kv_to_str_map,
|
|
25
|
+
)
|
|
15
26
|
from anyscale.controllers.cluster_controller import ClusterController
|
|
16
27
|
from anyscale.controllers.workspace_controller import WorkspaceController
|
|
17
28
|
from anyscale.project_utils import find_project_root
|
|
@@ -373,7 +384,9 @@ def run(command: str, web_terminal: bool, as_job: bool, no_push: bool,) -> None:
|
|
|
373
384
|
dir_name = workspace_controller.get_workspace_dir_name()
|
|
374
385
|
if web_terminal:
|
|
375
386
|
cluster_id = workspace_controller.get_activated_workspace().cluster_id
|
|
376
|
-
|
|
387
|
+
if not cluster_id:
|
|
388
|
+
raise click.ClickException("Workspace is not running; no active cluster.")
|
|
389
|
+
_execute_shell_command(str(cluster_id), f"cd ~/{dir_name} && {command}")
|
|
377
390
|
# TODO(ekl) show the workspace URL here and also block on completion.
|
|
378
391
|
print()
|
|
379
392
|
print(
|
|
@@ -412,9 +425,114 @@ def ssh(args: Tuple[str]) -> None:
|
|
|
412
425
|
@workspace_cli.command(
|
|
413
426
|
name="list", help="prints information about existing workspaces", hidden=True,
|
|
414
427
|
)
|
|
415
|
-
|
|
428
|
+
@click.option(
|
|
429
|
+
"--tag",
|
|
430
|
+
"tags",
|
|
431
|
+
multiple=True,
|
|
432
|
+
help=(
|
|
433
|
+
"This option can be repeated to filter by multiple tags. "
|
|
434
|
+
"Tags with the same key are ORed, whereas tags with different keys are ANDed. "
|
|
435
|
+
"Example: --tag team:mlops --tag team:infra --tag env:prod. "
|
|
436
|
+
"Filters with team: (mlops OR infra) AND env:prod."
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
def list_command(tags: Tuple[str]) -> None:
|
|
416
440
|
workspace_controller = WorkspaceController()
|
|
417
|
-
workspace_controller.list(
|
|
441
|
+
workspace_controller.list(
|
|
442
|
+
tags_filter=parse_repeatable_tags_to_dict(tags) if tags else None
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@workspace_cli.group("tags", help="Manage tags for workspaces.")
|
|
447
|
+
def workspace_tags_cli() -> None:
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@workspace_tags_cli.command(name="add", help="Add or update tags on a workspace.")
|
|
452
|
+
@click.option(
|
|
453
|
+
"--id", "workspace_id", required=False, help="Unique ID of the workspace."
|
|
454
|
+
)
|
|
455
|
+
@click.option("--name", "-n", required=False, help="Name of the workspace.")
|
|
456
|
+
@click.option(
|
|
457
|
+
"--tag",
|
|
458
|
+
"tags",
|
|
459
|
+
multiple=True,
|
|
460
|
+
help="Tag in key=value (or key:value) format. Repeat to add multiple.",
|
|
461
|
+
)
|
|
462
|
+
def workspace_tags_add(
|
|
463
|
+
workspace_id: Optional[str], name: Optional[str], tags: Tuple[str]
|
|
464
|
+
) -> None:
|
|
465
|
+
if not workspace_id and not name:
|
|
466
|
+
raise click.ClickException("Provide either --id or --name.")
|
|
467
|
+
tag_map = parse_tags_kv_to_str_map(tags)
|
|
468
|
+
if not tag_map:
|
|
469
|
+
raise click.ClickException("Provide at least one --tag key=value.")
|
|
470
|
+
WorkspaceController().add_tags(workspace_id=workspace_id, name=name, tags=tag_map)
|
|
471
|
+
stderr = Console(stderr=True)
|
|
472
|
+
ident = workspace_id or name or "<unknown>"
|
|
473
|
+
stderr.print(f"Tags updated for workspace '{ident}'.")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@workspace_tags_cli.command(name="remove", help="Remove tags by key from a workspace.")
|
|
477
|
+
@click.option(
|
|
478
|
+
"--id", "workspace_id", required=False, help="Unique ID of the workspace."
|
|
479
|
+
)
|
|
480
|
+
@click.option("--name", "-n", required=False, help="Name of the workspace.")
|
|
481
|
+
@click.option("--key", "keys", multiple=True, help="Tag key to remove. Repeatable.")
|
|
482
|
+
def workspace_tags_remove(
|
|
483
|
+
workspace_id: Optional[str], name: Optional[str], keys: Tuple[str]
|
|
484
|
+
) -> None:
|
|
485
|
+
if not workspace_id and not name:
|
|
486
|
+
raise click.ClickException("Provide either --id or --name.")
|
|
487
|
+
key_list = [k for k in keys if k and k.strip()]
|
|
488
|
+
if not key_list:
|
|
489
|
+
raise click.ClickException("Provide at least one --key to remove.")
|
|
490
|
+
WorkspaceController().remove_tags(
|
|
491
|
+
workspace_id=workspace_id, name=name, keys=key_list
|
|
492
|
+
)
|
|
493
|
+
stderr = Console(stderr=True)
|
|
494
|
+
ident = workspace_id or name or "<unknown>"
|
|
495
|
+
stderr.print(f"Removed tag keys {key_list} from workspace '{ident}'.")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@workspace_tags_cli.command(name="list", help="List tags for a workspace.")
|
|
499
|
+
@click.option(
|
|
500
|
+
"--id", "workspace_id", required=False, help="Unique ID of the workspace."
|
|
501
|
+
)
|
|
502
|
+
@click.option("--name", "-n", required=False, help="Name of the workspace.")
|
|
503
|
+
@click.option("--json", "json_output", is_flag=True, default=False)
|
|
504
|
+
def workspace_tags_list(
|
|
505
|
+
workspace_id: Optional[str], name: Optional[str], json_output: bool
|
|
506
|
+
) -> None:
|
|
507
|
+
if not workspace_id and not name:
|
|
508
|
+
raise click.ClickException("Provide either --id or --name.")
|
|
509
|
+
if not workspace_id:
|
|
510
|
+
# Resolve via name
|
|
511
|
+
auth = get_auth_api_client()
|
|
512
|
+
results = auth.api_client.list_workspaces_api_v2_experimental_workspaces_get(
|
|
513
|
+
name=name
|
|
514
|
+
).results
|
|
515
|
+
if len(results) == 0:
|
|
516
|
+
raise click.ClickException(f"No workspace with name '{name}' found.")
|
|
517
|
+
if len(results) > 1:
|
|
518
|
+
raise click.ClickException(
|
|
519
|
+
f"Multiple workspaces with name '{name}' found. Please use --id."
|
|
520
|
+
)
|
|
521
|
+
workspace_id = results[0].id
|
|
522
|
+
auth = get_auth_api_client()
|
|
523
|
+
resp = auth.api_client.get_tags_for_resource_api_v2_tags_resource_get(
|
|
524
|
+
ResourceTagResourceType.WORKSPACE, workspace_id
|
|
525
|
+
)
|
|
526
|
+
tags = getattr(resp.result, "tags", [])
|
|
527
|
+
if json_output:
|
|
528
|
+
Console().print_json(json=json_dumps([t.to_dict() for t in tags], indent=2))
|
|
529
|
+
else:
|
|
530
|
+
stderr = Console(stderr=True)
|
|
531
|
+
if not tags:
|
|
532
|
+
stderr.print("No tags found.")
|
|
533
|
+
return
|
|
534
|
+
pairs = [(t.key, t.value) for t in tags]
|
|
535
|
+
stderr.print(build_kv_table(pairs, title="Tags"))
|
|
418
536
|
|
|
419
537
|
|
|
420
538
|
# TODO(vigneshka): Migrate to v2 if there is usage, then deprecate
|
|
@@ -18,7 +18,11 @@ from anyscale._private.models.image_uri import ImageURI
|
|
|
18
18
|
from anyscale._private.sdk import _LAZY_SDK_SINGLETONS
|
|
19
19
|
from anyscale.cli_logger import BlockLogger
|
|
20
20
|
from anyscale.commands import command_examples
|
|
21
|
-
from anyscale.commands.util import
|
|
21
|
+
from anyscale.commands.util import (
|
|
22
|
+
AnyscaleCommand,
|
|
23
|
+
convert_kv_strings_to_dict,
|
|
24
|
+
parse_tags_kv_to_str_map,
|
|
25
|
+
)
|
|
22
26
|
import anyscale.workspace
|
|
23
27
|
from anyscale.workspace._private.workspace_sdk import ANYSCALE_WORKSPACES_SSH_OPTIONS
|
|
24
28
|
from anyscale.workspace.commands import _WORKSPACE_SDK_SINGLETON_KEY
|
|
@@ -491,6 +495,12 @@ def workspace_cli() -> None:
|
|
|
491
495
|
type=str,
|
|
492
496
|
help="Environment variables to set for the workspace. The format is 'key=value'. This argument can be specified multiple times. When the same key is also specified in the config file, the value from the command-line flag will overwrite the value from the config file.",
|
|
493
497
|
)
|
|
498
|
+
@click.option(
|
|
499
|
+
"--tag",
|
|
500
|
+
"tags",
|
|
501
|
+
multiple=True,
|
|
502
|
+
help="Tag in key=value (or key:value) format. Repeat to add multiple.",
|
|
503
|
+
)
|
|
494
504
|
def create( # noqa: PLR0913, PLR0912, C901
|
|
495
505
|
config_file: Optional[str],
|
|
496
506
|
name: Optional[str],
|
|
@@ -503,6 +513,7 @@ def create( # noqa: PLR0913, PLR0912, C901
|
|
|
503
513
|
project: Optional[str],
|
|
504
514
|
requirements: Optional[str],
|
|
505
515
|
env: Optional[Tuple[str]],
|
|
516
|
+
tags: Optional[Tuple[str]],
|
|
506
517
|
) -> None:
|
|
507
518
|
"""Creates a new workspace.
|
|
508
519
|
|
|
@@ -576,6 +587,11 @@ def create( # noqa: PLR0913, PLR0912, C901
|
|
|
576
587
|
if env_dict:
|
|
577
588
|
config = config.options(env_vars=env_dict)
|
|
578
589
|
|
|
590
|
+
if tags:
|
|
591
|
+
tag_map = parse_tags_kv_to_str_map(tags)
|
|
592
|
+
if tag_map:
|
|
593
|
+
config = config.options(tags=tag_map)
|
|
594
|
+
|
|
579
595
|
anyscale.workspace.create(config,)
|
|
580
596
|
|
|
581
597
|
|
|
@@ -53,12 +53,21 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
53
53
|
config: Union[None, Dict, HeadNodeConfig],
|
|
54
54
|
*,
|
|
55
55
|
cloud: Cloud,
|
|
56
|
+
cloud_resource_name: Optional[str] = None,
|
|
56
57
|
schedulable_by_default: bool,
|
|
57
58
|
) -> ComputeNodeType:
|
|
58
59
|
if config is None:
|
|
59
|
-
|
|
60
|
+
cloud_resource_id = None
|
|
61
|
+
if cloud_resource_name:
|
|
62
|
+
cloud_resource = self._client.get_cloud_resource_by_name(
|
|
63
|
+
cloud_id=cloud.id, cloud_resource_name=cloud_resource_name
|
|
64
|
+
)
|
|
65
|
+
if cloud_resource:
|
|
66
|
+
cloud_resource_id = cloud_resource.cloud_resource_id
|
|
67
|
+
|
|
68
|
+
# If no head node config is provided, use the cloud/cloud resource default.
|
|
60
69
|
default: ClusterComputeConfig = self._client.get_default_compute_config(
|
|
61
|
-
cloud_id=cloud.id
|
|
70
|
+
cloud_id=cloud.id, cloud_resource_id=cloud_resource_id,
|
|
62
71
|
).config
|
|
63
72
|
|
|
64
73
|
api_model = ComputeNodeType(
|
|
@@ -125,7 +134,7 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
125
134
|
return api_models
|
|
126
135
|
|
|
127
136
|
def _convert_single_deployment_compute_config_to_api_model(
|
|
128
|
-
self, compute_config: ComputeConfig
|
|
137
|
+
self, compute_config: ComputeConfig, base_flags: Optional[Dict[str, Any]] = None
|
|
129
138
|
) -> CloudDeploymentComputeConfig:
|
|
130
139
|
# We should only make the head node schedulable when it's the *only* node in the cluster.
|
|
131
140
|
# `worker_nodes=None` uses the default serverless config, so this only happens if `worker_nodes`
|
|
@@ -139,9 +148,10 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
139
148
|
"This should never happen; please reach out to Anyscale support."
|
|
140
149
|
)
|
|
141
150
|
|
|
142
|
-
flags
|
|
143
|
-
|
|
144
|
-
) if
|
|
151
|
+
# Merge the MultiResourceComputeConfig flags (which apply to all cloud resources) with the individual compute config flags.
|
|
152
|
+
# The individual compute config flags will override the base flags.
|
|
153
|
+
flags = deepcopy(base_flags) if base_flags else {}
|
|
154
|
+
flags.update(compute_config.flags or {})
|
|
145
155
|
flags["allow-cross-zone-autoscaling"] = compute_config.enable_cross_zone_scaling
|
|
146
156
|
|
|
147
157
|
if compute_config.min_resources:
|
|
@@ -155,6 +165,7 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
155
165
|
head_node_type=self._convert_head_node_config_to_api_model(
|
|
156
166
|
compute_config.head_node,
|
|
157
167
|
cloud=cloud,
|
|
168
|
+
cloud_resource_name=compute_config.cloud_resource,
|
|
158
169
|
schedulable_by_default=(
|
|
159
170
|
not compute_config.worker_nodes
|
|
160
171
|
and not compute_config.auto_select_worker_config
|
|
@@ -240,7 +251,9 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
240
251
|
for config in compute_config.configs:
|
|
241
252
|
assert isinstance(config, ComputeConfig)
|
|
242
253
|
deployment_configs.append(
|
|
243
|
-
self._convert_single_deployment_compute_config_to_api_model(
|
|
254
|
+
self._convert_single_deployment_compute_config_to_api_model(
|
|
255
|
+
config, compute_config.flags
|
|
256
|
+
)
|
|
244
257
|
)
|
|
245
258
|
default_config = deployment_configs[0]
|
|
246
259
|
|
|
@@ -261,11 +274,10 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
261
274
|
)
|
|
262
275
|
self.logger.info(f"Created compute config: '{full_name}'")
|
|
263
276
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
# self.logger.info(f"View the compute config in the UI: '{ui_url}'")
|
|
277
|
+
ui_url = self.client.get_compute_config_ui_url(
|
|
278
|
+
compute_config_id, cloud_id=cloud_id
|
|
279
|
+
)
|
|
280
|
+
self.logger.info(f"View the compute config in the UI: '{ui_url}'")
|
|
269
281
|
|
|
270
282
|
return full_name, compute_config_id
|
|
271
283
|
|
|
@@ -428,6 +440,7 @@ class PrivateComputeConfigSDK(BaseSDK):
|
|
|
428
440
|
min_resources=min_resources,
|
|
429
441
|
max_resources=max_resources or None,
|
|
430
442
|
flags=flags,
|
|
443
|
+
auto_select_worker_config=api_model.auto_select_worker_config or False,
|
|
431
444
|
)
|
|
432
445
|
|
|
433
446
|
def _convert_api_model_to_compute_config_version(
|
|
@@ -742,6 +742,21 @@ configs:
|
|
|
742
742
|
|
|
743
743
|
return config_models
|
|
744
744
|
|
|
745
|
+
flags: Optional[Dict[str, Any]] = field(
|
|
746
|
+
default=None,
|
|
747
|
+
repr=False,
|
|
748
|
+
metadata={
|
|
749
|
+
"docstring": "Flags specifying advanced or experimental options that should be applied to all cloud resources. Flags specified in the individual compute configurations will override these flags.",
|
|
750
|
+
},
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def _validate_flags(self, flags: Optional[Dict[str, Any]]):
|
|
754
|
+
if flags is None:
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
if not isinstance(flags, dict):
|
|
758
|
+
raise TypeError("'flags' must be a dict")
|
|
759
|
+
|
|
745
760
|
|
|
746
761
|
ComputeConfigType = Union[ComputeConfig, MultiResourceComputeConfig]
|
|
747
762
|
|
|
@@ -1746,7 +1746,7 @@ class CloudController(BaseController):
|
|
|
1746
1746
|
spec_file: str,
|
|
1747
1747
|
skip_verification: bool = False,
|
|
1748
1748
|
yes: bool = False,
|
|
1749
|
-
):
|
|
1749
|
+
) -> str:
|
|
1750
1750
|
cloud_id, _ = get_cloud_id_and_name(
|
|
1751
1751
|
self.api_client, cloud_id=cloud_id, cloud_name=cloud
|
|
1752
1752
|
)
|
|
@@ -1797,16 +1797,21 @@ class CloudController(BaseController):
|
|
|
1797
1797
|
|
|
1798
1798
|
# Add the resource.
|
|
1799
1799
|
try:
|
|
1800
|
-
self.api_client.add_cloud_resource_api_v2_clouds_cloud_id_add_resource_put(
|
|
1800
|
+
response = self.api_client.add_cloud_resource_api_v2_clouds_cloud_id_add_resource_put(
|
|
1801
1801
|
cloud_id=cloud_id, cloud_deployment=new_deployment,
|
|
1802
1802
|
)
|
|
1803
1803
|
except Exception as e: # noqa: BLE001
|
|
1804
1804
|
raise ClickException(f"Failed to add cloud resource: {e}")
|
|
1805
1805
|
|
|
1806
|
+
# Extract cloud_resource_id from the response
|
|
1807
|
+
cloud_resource_id = response.result.cloud_resource_id
|
|
1808
|
+
|
|
1806
1809
|
self.log.info(
|
|
1807
1810
|
f"Successfully created cloud resource{' ' + (new_deployment.name or '')} in cloud {cloud or cloud_id}!"
|
|
1808
1811
|
)
|
|
1809
1812
|
|
|
1813
|
+
return cloud_resource_id
|
|
1814
|
+
|
|
1810
1815
|
def update_cloud_resources( # noqa: PLR0912, C901
|
|
1811
1816
|
self,
|
|
1812
1817
|
cloud_name: Optional[str],
|
|
@@ -1828,6 +1833,14 @@ class CloudController(BaseController):
|
|
|
1828
1833
|
|
|
1829
1834
|
spec = yaml.safe_load(path.read_text())
|
|
1830
1835
|
|
|
1836
|
+
# Normalize spec to a list
|
|
1837
|
+
if isinstance(spec, dict):
|
|
1838
|
+
spec = [spec]
|
|
1839
|
+
elif not isinstance(spec, list):
|
|
1840
|
+
raise ClickException(
|
|
1841
|
+
"Invalid cloud resources file format. Must contain either a single CloudResource or a list of CloudResources."
|
|
1842
|
+
)
|
|
1843
|
+
|
|
1831
1844
|
# Get the existing spec.
|
|
1832
1845
|
existing_resources = self.get_cloud_resources(cloud_id=cloud_id)
|
|
1833
1846
|
|
|
@@ -29,6 +29,7 @@ from anyscale.client.openapi_client.models.decorated_production_job import (
|
|
|
29
29
|
DecoratedProductionJob,
|
|
30
30
|
)
|
|
31
31
|
from anyscale.client.openapi_client.models.ha_job_states import HaJobStates
|
|
32
|
+
from anyscale.commands.util import flatten_tag_dict_to_api_list
|
|
32
33
|
from anyscale.controllers.base_controller import BaseController
|
|
33
34
|
from anyscale.models.job_model import JobConfig
|
|
34
35
|
from anyscale.project_utils import infer_project_id
|
|
@@ -213,6 +214,7 @@ class JobController(BaseController):
|
|
|
213
214
|
workspace_id=job_config.workspace_id,
|
|
214
215
|
config=config_object,
|
|
215
216
|
job_queue_config=job_queue_config,
|
|
217
|
+
tags=getattr(job_config, "tags", None),
|
|
216
218
|
)
|
|
217
219
|
).result
|
|
218
220
|
self.log.info(
|
|
@@ -288,6 +290,13 @@ class JobController(BaseController):
|
|
|
288
290
|
).result
|
|
289
291
|
return job.state.current_state
|
|
290
292
|
|
|
293
|
+
def resolve_job_id(self, job_id: Optional[str], job_name: Optional[str]) -> str:
|
|
294
|
+
"""Resolve and return a job's ID from either an explicit ID or a name.
|
|
295
|
+
|
|
296
|
+
Raises click.ClickException if neither is provided or if the name is ambiguous.
|
|
297
|
+
"""
|
|
298
|
+
return self._resolve_job_object(job_id, job_name).id
|
|
299
|
+
|
|
291
300
|
def list( # noqa: PLR0913
|
|
292
301
|
self,
|
|
293
302
|
include_all_users: bool,
|
|
@@ -297,6 +306,7 @@ class JobController(BaseController):
|
|
|
297
306
|
include_archived: bool,
|
|
298
307
|
max_items: int,
|
|
299
308
|
states: List[HaJobStates],
|
|
309
|
+
tags: Optional[Dict[str, List[str]]] = None,
|
|
300
310
|
) -> None:
|
|
301
311
|
"""
|
|
302
312
|
This function will list jobs.
|
|
@@ -349,6 +359,7 @@ class JobController(BaseController):
|
|
|
349
359
|
archive_status="ALL" if include_archived else "NOT_ARCHIVED",
|
|
350
360
|
count=DEFAULT_PAGE_LIMIT,
|
|
351
361
|
state_filter=states,
|
|
362
|
+
tag_filter=flatten_tag_dict_to_api_list(tags),
|
|
352
363
|
)
|
|
353
364
|
jobs_list.extend(resp.results)
|
|
354
365
|
paging_token = resp.metadata.next_paging_token
|
|
@@ -363,6 +374,7 @@ class JobController(BaseController):
|
|
|
363
374
|
count=DEFAULT_PAGE_LIMIT,
|
|
364
375
|
paging_token=paging_token,
|
|
365
376
|
state_filter=states,
|
|
377
|
+
tag_filter=flatten_tag_dict_to_api_list(tags),
|
|
366
378
|
)
|
|
367
379
|
jobs_list.extend(resp.results)
|
|
368
380
|
paging_token = resp.metadata.next_paging_token
|