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.
Files changed (113) hide show
  1. anyscale/_private/anyscale_client/anyscale_client.py +126 -3
  2. anyscale/_private/anyscale_client/common.py +51 -2
  3. anyscale/_private/anyscale_client/fake_anyscale_client.py +103 -11
  4. anyscale/client/README.md +43 -4
  5. anyscale/client/openapi_client/__init__.py +30 -4
  6. anyscale/client/openapi_client/api/default_api.py +1769 -27
  7. anyscale/client/openapi_client/models/__init__.py +30 -4
  8. anyscale/client/openapi_client/models/api_key_info.py +29 -3
  9. anyscale/client/openapi_client/models/apply_autoscaling_config_update_model.py +350 -0
  10. anyscale/client/openapi_client/models/apply_multi_version_update_weights_update_model.py +152 -0
  11. anyscale/client/openapi_client/models/apply_production_service_multi_version_v2_model.py +207 -0
  12. anyscale/client/openapi_client/models/apply_production_service_v2_model.py +31 -3
  13. anyscale/client/openapi_client/models/apply_version_weight_update_model.py +181 -0
  14. anyscale/client/openapi_client/models/backend_server_api_product_models_catalog_client_models_table_metadata.py +546 -0
  15. anyscale/client/openapi_client/models/backend_server_api_product_models_data_catalogs_table_metadata.py +178 -0
  16. anyscale/client/openapi_client/models/baseimagesenum.py +139 -1
  17. anyscale/client/openapi_client/models/catalog_metadata.py +150 -0
  18. anyscale/client/openapi_client/models/cloud_data_bucket_file_type.py +2 -1
  19. anyscale/client/openapi_client/models/{oauthconnectionresponse_response.py → clouddeployment_response.py} +11 -11
  20. anyscale/client/openapi_client/models/column_info.py +265 -0
  21. anyscale/client/openapi_client/models/compute_node_type.py +29 -1
  22. anyscale/client/openapi_client/models/connection_metadata.py +206 -0
  23. anyscale/client/openapi_client/models/create_experimental_workspace.py +29 -1
  24. anyscale/client/openapi_client/models/create_workspace_from_template.py +29 -1
  25. anyscale/client/openapi_client/models/create_workspace_template_version.py +59 -3
  26. anyscale/client/openapi_client/models/data_catalog.py +45 -31
  27. anyscale/client/openapi_client/models/data_catalog_connection.py +74 -58
  28. anyscale/client/openapi_client/models/{ha_job_event_level.py → data_catalog_object_type.py} +7 -8
  29. anyscale/client/openapi_client/models/data_catalog_schema.py +324 -0
  30. anyscale/client/openapi_client/models/data_catalog_table.py +437 -0
  31. anyscale/client/openapi_client/models/data_catalog_volume.py +437 -0
  32. anyscale/client/openapi_client/models/datacatalogschema_list_response.py +147 -0
  33. anyscale/client/openapi_client/models/datacatalogtable_list_response.py +147 -0
  34. anyscale/client/openapi_client/models/datacatalogvolume_list_response.py +147 -0
  35. anyscale/client/openapi_client/models/decorated_list_service_api_model.py +58 -1
  36. anyscale/client/openapi_client/models/decorated_production_service_v2_api_model.py +60 -3
  37. anyscale/client/openapi_client/models/decorated_serve_deployment.py +27 -1
  38. anyscale/client/openapi_client/models/decorated_service_event_api_model.py +3 -3
  39. anyscale/client/openapi_client/models/decoratedproductionservicev2_versionapimodel_response.py +121 -0
  40. anyscale/client/openapi_client/models/describe_machine_pool_machines_filters.py +33 -5
  41. anyscale/client/openapi_client/models/describe_machine_pool_requests_filters.py +33 -5
  42. anyscale/client/openapi_client/models/describe_machine_pool_workloads_filters.py +33 -5
  43. anyscale/client/openapi_client/models/{service_event_level.py → entity_type.py} +9 -9
  44. anyscale/client/openapi_client/models/event_level.py +2 -1
  45. anyscale/client/openapi_client/models/job_event_fields.py +206 -0
  46. anyscale/client/openapi_client/models/machine_type_partition_filter.py +152 -0
  47. anyscale/client/openapi_client/models/partition_info.py +30 -1
  48. anyscale/client/openapi_client/models/physical_resources.py +178 -0
  49. anyscale/client/openapi_client/models/production_job_event.py +3 -3
  50. anyscale/client/openapi_client/models/rollout_strategy.py +2 -1
  51. anyscale/client/openapi_client/models/schema_metadata.py +150 -0
  52. anyscale/client/openapi_client/models/service_event_fields.py +318 -0
  53. anyscale/client/openapi_client/models/sso_config.py +18 -18
  54. anyscale/client/openapi_client/models/supportedbaseimagesenum.py +139 -1
  55. anyscale/client/openapi_client/models/table_data_preview.py +209 -0
  56. anyscale/client/openapi_client/models/task_summary_config.py +29 -3
  57. anyscale/client/openapi_client/models/task_table_config.py +29 -3
  58. anyscale/client/openapi_client/models/unified_event.py +377 -0
  59. anyscale/client/openapi_client/models/unified_origin_filter.py +113 -0
  60. anyscale/client/openapi_client/models/unifiedevent_list_response.py +147 -0
  61. anyscale/client/openapi_client/models/volume_metadata.py +150 -0
  62. anyscale/client/openapi_client/models/worker_node_type.py +29 -1
  63. anyscale/client/openapi_client/models/workspace_event_fields.py +122 -0
  64. anyscale/client/openapi_client/models/workspace_template_version.py +58 -1
  65. anyscale/client/openapi_client/models/workspace_template_version_data_object.py +58 -1
  66. anyscale/cloud/models.py +2 -2
  67. anyscale/commands/cloud_commands.py +133 -2
  68. anyscale/commands/job_commands.py +121 -1
  69. anyscale/commands/job_queue_commands.py +99 -2
  70. anyscale/commands/service_commands.py +267 -67
  71. anyscale/commands/setup_k8s.py +546 -31
  72. anyscale/commands/util.py +104 -1
  73. anyscale/commands/workspace_commands.py +123 -5
  74. anyscale/commands/workspace_commands_v2.py +17 -1
  75. anyscale/compute_config/_private/compute_config_sdk.py +25 -12
  76. anyscale/compute_config/models.py +15 -0
  77. anyscale/controllers/cloud_controller.py +15 -2
  78. anyscale/controllers/job_controller.py +12 -0
  79. anyscale/controllers/kubernetes_verifier.py +80 -66
  80. anyscale/controllers/workspace_controller.py +67 -5
  81. anyscale/job/_private/job_sdk.py +50 -2
  82. anyscale/job/commands.py +3 -0
  83. anyscale/job/models.py +16 -0
  84. anyscale/job_queue/__init__.py +37 -1
  85. anyscale/job_queue/_private/job_queue_sdk.py +28 -1
  86. anyscale/job_queue/commands.py +61 -1
  87. anyscale/sdk/anyscale_client/__init__.py +1 -0
  88. anyscale/sdk/anyscale_client/api/default_api.py +12 -2
  89. anyscale/sdk/anyscale_client/models/__init__.py +1 -0
  90. anyscale/sdk/anyscale_client/models/apply_production_service_v2_model.py +31 -3
  91. anyscale/sdk/anyscale_client/models/apply_service_model.py +31 -3
  92. anyscale/sdk/anyscale_client/models/baseimagesenum.py +139 -1
  93. anyscale/sdk/anyscale_client/models/compute_node_type.py +29 -1
  94. anyscale/sdk/anyscale_client/models/physical_resources.py +178 -0
  95. anyscale/sdk/anyscale_client/models/rollout_strategy.py +2 -1
  96. anyscale/sdk/anyscale_client/models/supportedbaseimagesenum.py +139 -1
  97. anyscale/sdk/anyscale_client/models/worker_node_type.py +29 -1
  98. anyscale/service/__init__.py +51 -3
  99. anyscale/service/_private/service_sdk.py +481 -58
  100. anyscale/service/commands.py +90 -4
  101. anyscale/service/models.py +56 -0
  102. anyscale/shared_anyscale_utils/latest_ray_version.py +1 -1
  103. anyscale/version.py +1 -1
  104. anyscale/workspace/_private/workspace_sdk.py +1 -0
  105. anyscale/workspace/models.py +19 -0
  106. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/METADATA +1 -1
  107. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/RECORD +112 -85
  108. anyscale/client/openapi_client/models/o_auth_connection_response.py +0 -229
  109. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/WHEEL +0 -0
  110. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/entry_points.txt +0 -0
  111. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/licenses/LICENSE +0 -0
  112. {anyscale-0.26.69.dist-info → anyscale-0.26.71.dist-info}/licenses/NOTICE +0 -0
  113. {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.commands.util import DeprecatedAnyscaleCommand, LegacyAnyscaleCommand
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
- _execute_shell_command(cluster_id, f"cd ~/{dir_name} && {command}")
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
- def list_command() -> None:
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 AnyscaleCommand, convert_kv_strings_to_dict
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
- # If no head node config is provided, use the cloud default.
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: Dict[str, Any] = deepcopy(
143
- compute_config.flags
144
- ) if compute_config.flags else {}
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(config)
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
- # TODO(janet): add this back after the UI has been updated to support multi-deployment compute configs.
265
- # ui_url = self.client.get_compute_config_ui_url(
266
- # compute_config_id, cloud_id=cloud_id
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