diff --git a/docs/docs/concepts/backends.md b/docs/docs/concepts/backends.md index 78fb7613e..f549ef803 100644 --- a/docs/docs/concepts/backends.md +++ b/docs/docs/concepts/backends.md @@ -325,6 +325,9 @@ There are two ways to configure Azure: using a client secret or using the defaul } ``` + The `"Microsoft.Resources/subscriptions/resourceGroups/write"` permission is not required + if [`resource_group`](/docs/reference/server/config.yml/#azure) is specified. + ??? info "VPC" By default, `dstack` creates new Azure networks and subnets for every configured region. It's possible to use custom networks by specifying `vpc_ids`: diff --git a/src/dstack/_internal/core/models/backends/azure.py b/src/dstack/_internal/core/models/backends/azure.py index 21b5b41a0..e386120fd 100644 --- a/src/dstack/_internal/core/models/backends/azure.py +++ b/src/dstack/_internal/core/models/backends/azure.py @@ -11,6 +11,7 @@ class AzureConfigInfo(CoreModel): type: Literal["azure"] = "azure" tenant_id: str subscription_id: str + resource_group: Optional[str] = None locations: Optional[List[str]] = None vpc_ids: Optional[Dict[str, str]] = None public_ips: Optional[bool] = None @@ -48,6 +49,7 @@ class AzureConfigInfoWithCredsPartial(CoreModel): creds: Optional[AnyAzureCreds] tenant_id: Optional[str] subscription_id: Optional[str] + resource_group: Optional[str] locations: Optional[List[str]] vpc_ids: Optional[Dict[str, str]] public_ips: Optional[bool] @@ -63,4 +65,4 @@ class AzureConfigValues(CoreModel): class AzureStoredConfig(AzureConfigInfo): - resource_group: str + resource_group: str = "" diff --git a/src/dstack/_internal/server/services/backends/configurators/azure.py b/src/dstack/_internal/server/services/backends/configurators/azure.py index 363d331c8..188a5cabb 100644 --- a/src/dstack/_internal/server/services/backends/configurators/azure.py +++ b/src/dstack/_internal/server/services/backends/configurators/azure.py @@ -2,6 +2,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List, Optional, Tuple +import azure.core.exceptions from azure.core.credentials import TokenCredential from azure.mgmt import network as network_mgmt from azure.mgmt import resource as resource_mgmt @@ -154,16 +155,17 @@ def create_backend( if is_core_model_instance(config.creds, AzureClientCreds): self._set_client_creds_tenant_id(config.creds, config.tenant_id) credential, _ = auth.authenticate(config.creds) - resource_group = self._create_resource_group( - credential=credential, - subscription_id=config.subscription_id, - location=MAIN_LOCATION, - project_name=project.name, - ) + if config.resource_group is None: + config.resource_group = self._create_resource_group( + credential=credential, + subscription_id=config.subscription_id, + location=MAIN_LOCATION, + project_name=project.name, + ) self._create_network_resources( credential=credential, subscription_id=config.subscription_id, - resource_group=resource_group, + resource_group=config.resource_group, locations=config.locations, create_default_network=config.vpc_ids is None, ) @@ -172,7 +174,6 @@ def create_backend( type=self.TYPE.value, config=AzureStoredConfig( **AzureConfigInfo.__response__.parse_obj(config).dict(), - resource_group=resource_group, ).json(), auth=DecryptedString(plaintext=AzureCreds.parse_obj(config.creds).__root__.json()), ) @@ -322,6 +323,7 @@ def _check_config( self, config: AzureConfigInfoWithCredsPartial, credential: auth.AzureCredential ): self._check_tags_config(config) + self._check_resource_group(config=config, credential=credential) self._check_vpc_config(config=config, credential=credential) def _check_tags_config(self, config: AzureConfigInfoWithCredsPartial): @@ -336,6 +338,18 @@ def _check_tags_config(self, config: AzureConfigInfoWithCredsPartial): except BackendError as e: raise ServerClientError(e.args[0]) + def _check_resource_group( + self, config: AzureConfigInfoWithCredsPartial, credential: auth.AzureCredential + ): + if config.resource_group is None: + return + resource_manager = ResourceManager( + credential=credential, + subscription_id=config.subscription_id, + ) + if not resource_manager.resource_group_exists(config.resource_group): + raise ServerClientError(f"Resource group {config.resource_group} not found") + def _check_vpc_config( self, config: AzureConfigInfoWithCredsPartial, credential: auth.AzureCredential ): @@ -406,6 +420,18 @@ def create_resource_group( ) return resource_group.name + def resource_group_exists( + self, + name: str, + ) -> bool: + try: + self.resource_client.resource_groups.get( + resource_group_name=name, + ) + except azure.core.exceptions.ResourceNotFoundError: + return False + return True + class NetworkManager: def __init__(self, credential: TokenCredential, subscription_id: str): diff --git a/src/dstack/_internal/server/services/config.py b/src/dstack/_internal/server/services/config.py index bfd35df95..e2d6dad38 100644 --- a/src/dstack/_internal/server/services/config.py +++ b/src/dstack/_internal/server/services/config.py @@ -124,6 +124,15 @@ class AzureConfig(CoreModel): type: Annotated[Literal["azure"], Field(description="The type of the backend")] = "azure" tenant_id: Annotated[str, Field(description="The tenant ID")] subscription_id: Annotated[str, Field(description="The subscription ID")] + resource_group: Annotated[ + Optional[str], + Field( + description=( + "The resource group for resources created by `dstack`." + " If not specified, `dstack` will create a new resource group" + ) + ), + ] = None regions: Annotated[ Optional[List[str]], Field(description="The list of Azure regions (locations). Omit to use all regions"),