anyscale 0.26.70__py3-none-any.whl → 0.26.72__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 +63 -6
- anyscale/_private/anyscale_client/common.py +33 -3
- anyscale/_private/anyscale_client/fake_anyscale_client.py +27 -2
- anyscale/client/README.md +29 -0
- anyscale/client/openapi_client/__init__.py +19 -0
- anyscale/client/openapi_client/api/default_api.py +1307 -4
- anyscale/client/openapi_client/models/__init__.py +19 -0
- anyscale/client/openapi_client/models/apply_multi_version_update_weights_update_model.py +152 -0
- 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 +70 -1
- anyscale/client/openapi_client/models/catalog_metadata.py +150 -0
- 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_workspace_template_version.py +31 -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/data_catalog_object_type.py +100 -0
- 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_serve_deployment.py +27 -1
- anyscale/client/openapi_client/models/decoratedproductionservicev2_versionapimodel_response.py +121 -0
- anyscale/client/openapi_client/models/describe_machine_pool_machines_filters.py +2 -2
- anyscale/client/openapi_client/models/describe_machine_pool_requests_filters.py +33 -5
- anyscale/client/openapi_client/models/describe_machine_pool_workloads_filters.py +2 -2
- anyscale/client/openapi_client/models/physical_resources.py +178 -0
- anyscale/client/openapi_client/models/schema_metadata.py +150 -0
- anyscale/client/openapi_client/models/sso_config.py +18 -18
- anyscale/client/openapi_client/models/supportedbaseimagesenum.py +70 -1
- anyscale/client/openapi_client/models/table_data_preview.py +209 -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_template_version.py +29 -1
- anyscale/client/openapi_client/models/workspace_template_version_data_object.py +29 -1
- anyscale/commands/job_commands.py +120 -0
- anyscale/commands/job_queue_commands.py +99 -2
- anyscale/commands/service_commands.py +139 -2
- 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/job_controller.py +12 -0
- anyscale/controllers/workspace_controller.py +67 -5
- anyscale/job/_private/job_sdk.py +3 -1
- 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/baseimagesenum.py +70 -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/supportedbaseimagesenum.py +70 -1
- anyscale/sdk/anyscale_client/models/worker_node_type.py +29 -1
- anyscale/service/__init__.py +40 -0
- anyscale/service/_private/service_sdk.py +121 -24
- anyscale/service/commands.py +75 -1
- anyscale/service/models.py +46 -2
- 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.70.dist-info → anyscale-0.26.72.dist-info}/METADATA +1 -1
- {anyscale-0.26.70.dist-info → anyscale-0.26.72.dist-info}/RECORD +78 -58
- {anyscale-0.26.70.dist-info → anyscale-0.26.72.dist-info}/WHEEL +0 -0
- {anyscale-0.26.70.dist-info → anyscale-0.26.72.dist-info}/entry_points.txt +0 -0
- {anyscale-0.26.70.dist-info → anyscale-0.26.72.dist-info}/licenses/LICENSE +0 -0
- {anyscale-0.26.70.dist-info → anyscale-0.26.72.dist-info}/licenses/NOTICE +0 -0
- {anyscale-0.26.70.dist-info → anyscale-0.26.72.dist-info}/top_level.txt +0 -0
|
@@ -11,7 +11,11 @@ from typing_extensions import Literal
|
|
|
11
11
|
import yaml
|
|
12
12
|
|
|
13
13
|
from anyscale._private.models.image_uri import ImageURI
|
|
14
|
+
from anyscale.authenticate import get_auth_api_client
|
|
14
15
|
from anyscale.cli_logger import BlockLogger
|
|
16
|
+
from anyscale.client.openapi_client.models.resource_tag_resource_type import (
|
|
17
|
+
ResourceTagResourceType,
|
|
18
|
+
)
|
|
15
19
|
from anyscale.commands import command_examples
|
|
16
20
|
from anyscale.commands.list_util import (
|
|
17
21
|
display_list,
|
|
@@ -21,9 +25,12 @@ from anyscale.commands.list_util import (
|
|
|
21
25
|
)
|
|
22
26
|
from anyscale.commands.util import (
|
|
23
27
|
AnyscaleCommand,
|
|
28
|
+
build_kv_table,
|
|
24
29
|
convert_kv_strings_to_dict,
|
|
25
30
|
DeprecatedAnyscaleCommand,
|
|
26
31
|
override_env_vars,
|
|
32
|
+
parse_repeatable_tags_to_dict,
|
|
33
|
+
parse_tags_kv_to_str_map,
|
|
27
34
|
)
|
|
28
35
|
from anyscale.controllers.service_controller import ServiceController
|
|
29
36
|
import anyscale.service
|
|
@@ -210,6 +217,12 @@ def _read_name_from_config_file(path: str):
|
|
|
210
217
|
type=str,
|
|
211
218
|
help="Defines the traffic and capacity percents per version. Capacity defaults to traffic.",
|
|
212
219
|
)
|
|
220
|
+
@click.option(
|
|
221
|
+
"--tag",
|
|
222
|
+
"tags",
|
|
223
|
+
multiple=True,
|
|
224
|
+
help="Tag in key=value (or key:value) format. Repeat to add multiple.",
|
|
225
|
+
)
|
|
213
226
|
def deploy( # noqa: PLR0912, PLR0913 C901
|
|
214
227
|
config_file: List[str],
|
|
215
228
|
import_path: Optional[str],
|
|
@@ -231,6 +244,7 @@ def deploy( # noqa: PLR0912, PLR0913 C901
|
|
|
231
244
|
cloud: Optional[str],
|
|
232
245
|
project: Optional[str],
|
|
233
246
|
versions: Optional[str],
|
|
247
|
+
tags: Optional[Tuple[str]],
|
|
234
248
|
):
|
|
235
249
|
"""Deploy or update a service.
|
|
236
250
|
|
|
@@ -347,8 +361,10 @@ def deploy( # noqa: PLR0912, PLR0913 C901
|
|
|
347
361
|
configs = config
|
|
348
362
|
else:
|
|
349
363
|
# When multiple versions are being deployed.
|
|
350
|
-
configs = [ServiceConfig.from_yaml(config) for config in config_file]
|
|
351
|
-
|
|
364
|
+
# configs = [ServiceConfig.from_yaml(config) for config in config_file]
|
|
365
|
+
configs = []
|
|
366
|
+
for config in config_file:
|
|
367
|
+
config = ServiceConfig.from_yaml(config)
|
|
352
368
|
if name is not None:
|
|
353
369
|
config = config.options(name=name)
|
|
354
370
|
|
|
@@ -388,6 +404,13 @@ def deploy( # noqa: PLR0912, PLR0913 C901
|
|
|
388
404
|
if py_module:
|
|
389
405
|
log.warning("--py-module is ignored.")
|
|
390
406
|
|
|
407
|
+
configs.append(config)
|
|
408
|
+
|
|
409
|
+
if tags:
|
|
410
|
+
tag_map = parse_tags_kv_to_str_map(tags)
|
|
411
|
+
if tag_map:
|
|
412
|
+
config = config.options(tags=tag_map)
|
|
413
|
+
|
|
391
414
|
anyscale.service.deploy(
|
|
392
415
|
configs,
|
|
393
416
|
in_place=in_place,
|
|
@@ -472,6 +495,10 @@ def status(
|
|
|
472
495
|
# becomes a common pattern.
|
|
473
496
|
status_dict.get("primary_version", {}).pop("config", None)
|
|
474
497
|
status_dict.get("canary_version", {}).pop("config", None)
|
|
498
|
+
# Remove config from all versions in multi-version services
|
|
499
|
+
if status_dict.get("versions"):
|
|
500
|
+
for version in status_dict.get("versions", []):
|
|
501
|
+
version.pop("config", None)
|
|
475
502
|
|
|
476
503
|
console = Console()
|
|
477
504
|
if json:
|
|
@@ -806,6 +833,17 @@ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
|
|
|
806
833
|
type=str,
|
|
807
834
|
help="Named project to use; defaults to your org/workspace project.",
|
|
808
835
|
)
|
|
836
|
+
@click.option(
|
|
837
|
+
"--tag",
|
|
838
|
+
"tags",
|
|
839
|
+
multiple=True,
|
|
840
|
+
help=(
|
|
841
|
+
"This option can be repeated to filter by multiple tags. "
|
|
842
|
+
"Tags with the same key are ORed, whereas tags with different keys are ANDed. "
|
|
843
|
+
"Example: --tag team:mlops --tag team:infra --tag env:prod. "
|
|
844
|
+
"Filters with team: (mlops OR infra) AND env:prod."
|
|
845
|
+
),
|
|
846
|
+
)
|
|
809
847
|
@click.option(
|
|
810
848
|
"--created-by-me",
|
|
811
849
|
is_flag=True,
|
|
@@ -874,6 +912,7 @@ def _format_service_output_data(svc: ServiceStatus) -> Dict[str, str]:
|
|
|
874
912
|
def list( # noqa: PLR0913, A001
|
|
875
913
|
service_id: Optional[str],
|
|
876
914
|
name: Optional[str],
|
|
915
|
+
tags: List[str],
|
|
877
916
|
created_by_me: bool,
|
|
878
917
|
cloud: Optional[str],
|
|
879
918
|
project: Optional[str],
|
|
@@ -910,6 +949,7 @@ def list( # noqa: PLR0913, A001
|
|
|
910
949
|
stderr.print("[bold]Listing services with:[/]")
|
|
911
950
|
stderr.print(f"• name = {name or '<any>'}")
|
|
912
951
|
stderr.print(f"• states = {', '.join(state_filter) or '<all>'}")
|
|
952
|
+
stderr.print(f"• tags = {', '.join(tags) or '<none>'}")
|
|
913
953
|
stderr.print(f"• created_by_me = {created_by_me}")
|
|
914
954
|
stderr.print(f"• include_archived= {include_archived}")
|
|
915
955
|
stderr.print(f"• sort = {sort or '<none>'}")
|
|
@@ -942,6 +982,7 @@ def list( # noqa: PLR0913, A001
|
|
|
942
982
|
service_id=service_id,
|
|
943
983
|
name=name,
|
|
944
984
|
state_filter=state_filter,
|
|
985
|
+
tags_filter=parse_repeatable_tags_to_dict(tags) if tags else None,
|
|
945
986
|
creator_id=creator_id,
|
|
946
987
|
cloud=cloud,
|
|
947
988
|
project=project,
|
|
@@ -972,6 +1013,102 @@ def list( # noqa: PLR0913, A001
|
|
|
972
1013
|
sys.exit(1)
|
|
973
1014
|
|
|
974
1015
|
|
|
1016
|
+
@service_cli.group("tags", help="Manage tags for services.")
|
|
1017
|
+
def service_tags_cli() -> None:
|
|
1018
|
+
pass
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
@service_tags_cli.command(name="add", help="Add or update tags on a service.")
|
|
1022
|
+
@click.option("--service-id", "--id", help="ID of the service.")
|
|
1023
|
+
@click.option("--name", "-n", help="Name of the service.")
|
|
1024
|
+
@click.option("--cloud", type=str, help="Cloud name (for name resolution).")
|
|
1025
|
+
@click.option("--project", type=str, help="Project name (for name resolution).")
|
|
1026
|
+
@click.option(
|
|
1027
|
+
"--tag",
|
|
1028
|
+
"tags",
|
|
1029
|
+
multiple=True,
|
|
1030
|
+
help="Tag in key=value (or key:value) format. Repeat to add multiple.",
|
|
1031
|
+
)
|
|
1032
|
+
def tags_add(
|
|
1033
|
+
service_id: Optional[str],
|
|
1034
|
+
name: Optional[str],
|
|
1035
|
+
cloud: Optional[str],
|
|
1036
|
+
project: Optional[str],
|
|
1037
|
+
tags: Tuple[str],
|
|
1038
|
+
) -> None:
|
|
1039
|
+
if not service_id and not name:
|
|
1040
|
+
raise click.ClickException("Provide either --service-id/--id or --name.")
|
|
1041
|
+
tag_map = parse_tags_kv_to_str_map(tags)
|
|
1042
|
+
if not tag_map:
|
|
1043
|
+
raise click.ClickException("Provide at least one --tag key=value.")
|
|
1044
|
+
anyscale.service.add_tags(
|
|
1045
|
+
id=service_id, name=name, cloud=cloud, project=project, tags=tag_map
|
|
1046
|
+
)
|
|
1047
|
+
stderr = Console(stderr=True)
|
|
1048
|
+
ident = service_id or name or "<unknown>"
|
|
1049
|
+
stderr.print(f"Tags updated for service '{ident}'.")
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@service_tags_cli.command(name="remove", help="Remove tags by key from a service.")
|
|
1053
|
+
@click.option("--service-id", "--id", help="ID of the service.")
|
|
1054
|
+
@click.option("--name", "-n", help="Name of the service.")
|
|
1055
|
+
@click.option("--cloud", type=str, help="Cloud name (for name resolution).")
|
|
1056
|
+
@click.option("--project", type=str, help="Project name (for name resolution).")
|
|
1057
|
+
@click.option("--key", "keys", multiple=True, help="Tag key to remove. Repeatable.")
|
|
1058
|
+
def tags_remove(
|
|
1059
|
+
service_id: Optional[str],
|
|
1060
|
+
name: Optional[str],
|
|
1061
|
+
cloud: Optional[str],
|
|
1062
|
+
project: Optional[str],
|
|
1063
|
+
keys: Tuple[str],
|
|
1064
|
+
) -> None:
|
|
1065
|
+
if not service_id and not name:
|
|
1066
|
+
raise click.ClickException("Provide either --service-id/--id or --name.")
|
|
1067
|
+
key_list = [k for k in keys if k and k.strip()]
|
|
1068
|
+
if not key_list:
|
|
1069
|
+
raise click.ClickException("Provide at least one --key to remove.")
|
|
1070
|
+
anyscale.service.remove_tags(
|
|
1071
|
+
id=service_id, name=name, cloud=cloud, project=project, keys=key_list
|
|
1072
|
+
)
|
|
1073
|
+
stderr = Console(stderr=True)
|
|
1074
|
+
ident = service_id or name or "<unknown>"
|
|
1075
|
+
stderr.print(f"Removed tag keys {key_list} from service '{ident}'.")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@service_tags_cli.command(name="list", help="List tags for a service.")
|
|
1079
|
+
@click.option("--service-id", "--id", help="ID of the service.")
|
|
1080
|
+
@click.option("--name", "-n", help="Name of the service.")
|
|
1081
|
+
@click.option("--cloud", type=str, help="Cloud name (for name resolution).")
|
|
1082
|
+
@click.option("--project", type=str, help="Project name (for name resolution).")
|
|
1083
|
+
@click.option("--json", "json_output", is_flag=True, default=False)
|
|
1084
|
+
def tags_list(
|
|
1085
|
+
service_id: Optional[str],
|
|
1086
|
+
name: Optional[str],
|
|
1087
|
+
cloud: Optional[str],
|
|
1088
|
+
project: Optional[str],
|
|
1089
|
+
json_output: bool,
|
|
1090
|
+
) -> None:
|
|
1091
|
+
if not service_id and not name:
|
|
1092
|
+
raise click.ClickException("Provide either --service-id/--id or --name.")
|
|
1093
|
+
if not service_id:
|
|
1094
|
+
svc: ServiceStatus = anyscale.service.status(name=name, cloud=cloud, project=project) # type: ignore
|
|
1095
|
+
service_id = svc.id
|
|
1096
|
+
auth = get_auth_api_client()
|
|
1097
|
+
resp = auth.api_client.get_tags_for_resource_api_v2_tags_resource_get(
|
|
1098
|
+
ResourceTagResourceType.SERVICE, service_id
|
|
1099
|
+
)
|
|
1100
|
+
tags = getattr(resp.result, "tags", [])
|
|
1101
|
+
if json_output:
|
|
1102
|
+
Console().print_json(json=json_dumps([t.to_dict() for t in tags], indent=2))
|
|
1103
|
+
else:
|
|
1104
|
+
stderr = Console(stderr=True)
|
|
1105
|
+
if not tags:
|
|
1106
|
+
stderr.print("No tags found.")
|
|
1107
|
+
return
|
|
1108
|
+
pairs = [(t.key, t.value) for t in tags]
|
|
1109
|
+
stderr.print(build_kv_table(pairs, title="Tags"))
|
|
1110
|
+
|
|
1111
|
+
|
|
975
1112
|
# TODO(mowen): Add cloud support for this when we refactor to new SDK method
|
|
976
1113
|
@service_cli.command(name="rollback", help="Roll back a service.")
|
|
977
1114
|
@click.option(
|
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
|
|