diff --git a/src/containerapp/azext_containerapp/_clients.py b/src/containerapp/azext_containerapp/_clients.py index d217e12be5e..dfc41773ea3 100644 --- a/src/containerapp/azext_containerapp/_clients.py +++ b/src/containerapp/azext_containerapp/_clients.py @@ -784,3 +784,120 @@ def list(cls, cmd, resource_group_name, environment_name, formatter=lambda x: x) app_list.append(formatted) return app_list + + +class StorageClient(): + @classmethod + def create_or_update(cls, cmd, resource_group_name, env_name, name, storage_envelope, no_wait=False): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = STABLE_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/storages/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + env_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(storage_envelope)) + + if no_wait: + return r.json() + elif r.status_code == 201: + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/storages/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + env_name, + name, + api_version) + return poll(cmd, request_url, "waiting") + + return r.json() + + @classmethod + def delete(cls, cmd, resource_group_name, env_name, name, no_wait=False): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = STABLE_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/storages/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + env_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "DELETE", request_url) + + if no_wait: + return # API doesn't return JSON (it returns no content) + elif r.status_code in [200, 201, 202, 204]: + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/storages/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + env_name, + name, + api_version) + if r.status_code == 200: # 200 successful delete, 204 means storage not found + from azure.cli.core.azclierror import ResourceNotFoundError + try: + poll(cmd, request_url, "scheduledfordelete") + except ResourceNotFoundError: + pass + logger.warning('Containerapp environment storage successfully deleted') + return + + @classmethod + def show(cls, cmd, resource_group_name, env_name, name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = STABLE_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/storages/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + env_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + return r.json() + + @classmethod + def list(cls, cmd, resource_group_name, env_name, formatter=lambda x: x): + env_list = [] + + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = STABLE_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/storages?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + env_name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + j = r.json() + for env in j["value"]: + formatted = formatter(env) + env_list.append(formatted) + + while j.get("nextLink") is not None: + request_url = j["nextLink"] + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + j = r.json() + for env in j["value"]: + formatted = formatter(env) + env_list.append(formatted) + + return env_list diff --git a/src/containerapp/azext_containerapp/_help.py b/src/containerapp/azext_containerapp/_help.py index ab4cde705eb..054250a09e4 100644 --- a/src/containerapp/azext_containerapp/_help.py +++ b/src/containerapp/azext_containerapp/_help.py @@ -356,6 +356,48 @@ az containerapp env dapr-component remove -g MyResourceGroup --dapr-component-name MyDaprComponentName --name MyEnvironment """ +helps['containerapp env storage'] = """ + type: group + short-summary: Commands to manage storage for the Container Apps environment. +""" + +helps['containerapp env storage list'] = """ + type: command + short-summary: List the storages for an environment. + examples: + - name: List the storages for an environment. + text: | + az containerapp env storage list -g MyResourceGroup -n MyEnvironment +""" + +helps['containerapp env storage show'] = """ + type: command + short-summary: Show the details of a storage. + examples: + - name: Show the details of a storage. + text: | + az containerapp env storage show -g MyResourceGroup --storage-name MyStorageName -n MyEnvironment +""" + +helps['containerapp env storage set'] = """ + type: command + short-summary: Create or update a storage. + examples: + - name: Create a storage. + text: | + az containerapp env storage set -g MyResourceGroup -n MyEnv --storage-name MyStorageName --access-mode ReadOnly --azure-file-account-key MyAccountKey --azure-file-account-name MyAccountName --azure-file-share-name MyShareName +""" + +helps['containerapp env storage remove'] = """ + type: command + short-summary: Remove a storage from an environment. + examples: + - name: Remove a storage from a Container Apps environment. + text: | + az containerapp env storage remove -g MyResourceGroup --storage-name MyStorageName -n MyEnvironment +""" + + # Identity Commands helps['containerapp identity'] = """ type: group diff --git a/src/containerapp/azext_containerapp/_models.py b/src/containerapp/azext_containerapp/_models.py index 6a474f89267..02e5bcb916a 100644 --- a/src/containerapp/azext_containerapp/_models.py +++ b/src/containerapp/azext_containerapp/_models.py @@ -231,3 +231,10 @@ "tenantId": None, # str "subscriptionId": None # str } + +AzureFileProperties = { + "accountName": None, + "accountKey": None, + "accessMode": None, + "shareName": None +} diff --git a/src/containerapp/azext_containerapp/_params.py b/src/containerapp/azext_containerapp/_params.py index fa33873602f..86aa24736db 100644 --- a/src/containerapp/azext_containerapp/_params.py +++ b/src/containerapp/azext_containerapp/_params.py @@ -147,6 +147,14 @@ def load_arguments(self, _): with self.argument_context('containerapp env show') as c: c.argument('name', name_type, help='Name of the Container Apps Environment.') + with self.argument_context('containerapp env storage') as c: + c.argument('name', id_part=None) + c.argument('storage_name', help="Name of the storage.") + c.argument('access_mode', id_part=None, arg_type=get_enum_type(["ReadWrite", "ReadOnly"]), help="Access mode for the AzureFile storage.") + c.argument('azure_file_account_key', help="Key of the AzureFile storage account.") + c.argument('azure_file_share_name', help="Name of the share on the AzureFile storage.") + c.argument('azure_file_account_name', help="Name of the AzureFile storage account.") + with self.argument_context('containerapp identity') as c: c.argument('user_assigned', nargs='+', help="Space-separated user identities.") c.argument('system_assigned', help="Boolean indicating whether to assign system-assigned identity.") diff --git a/src/containerapp/azext_containerapp/commands.py b/src/containerapp/azext_containerapp/commands.py index 158616ca0b2..d7013b98bad 100644 --- a/src/containerapp/azext_containerapp/commands.py +++ b/src/containerapp/azext_containerapp/commands.py @@ -73,6 +73,12 @@ def load_command_table(self, _): g.custom_command('set', 'create_or_update_dapr_component') g.custom_command('remove', 'remove_dapr_component') + with self.command_group('containerapp env storage') as g: + g.custom_show_command('show', 'show_storage') + g.custom_command('list', 'list_storage') + g.custom_command('set', 'create_or_update_storage', supports_no_wait=True, exception_handler=ex_handler_factory()) + g.custom_command('remove', 'remove_storage', supports_no_wait=True, confirmation=True, exception_handler=ex_handler_factory()) + with self.command_group('containerapp identity') as g: g.custom_command('assign', 'assign_managed_identity', supports_no_wait=True, exception_handler=ex_handler_factory()) g.custom_command('remove', 'remove_managed_identity', supports_no_wait=True, exception_handler=ex_handler_factory()) diff --git a/src/containerapp/azext_containerapp/custom.py b/src/containerapp/azext_containerapp/custom.py index 676212133f5..31669d3ecf1 100644 --- a/src/containerapp/azext_containerapp/custom.py +++ b/src/containerapp/azext_containerapp/custom.py @@ -25,7 +25,7 @@ from msrest.exceptions import DeserializationError from ._client_factory import handle_raw_exception -from ._clients import ManagedEnvironmentClient, ContainerAppClient, GitHubActionClient, DaprComponentClient +from ._clients import ManagedEnvironmentClient, ContainerAppClient, GitHubActionClient, DaprComponentClient, StorageClient from ._github_oauth import get_github_access_token from ._models import ( ManagedEnvironment as ManagedEnvironmentModel, @@ -45,7 +45,8 @@ RegistryInfo as RegistryInfoModel, AzureCredentials as AzureCredentialsModel, SourceControl as SourceControlModel, - ManagedServiceIdentity as ManagedServiceIdentityModel) + ManagedServiceIdentity as ManagedServiceIdentityModel, + AzureFileProperties as AzureFilePropertiesModel) from ._utils import (_validate_subscription_registered, _get_location_from_resource_group, _ensure_location_allowed, parse_secret_flags, store_as_secret_and_return_secret_ref, parse_env_var_flags, _generate_log_analytics_if_not_provided, _get_existing_secrets, _convert_object_from_snake_to_camel_case, @@ -2201,3 +2202,64 @@ def containerapp_up_logic(cmd, resource_group_name, name, managed_env, image, en return ContainerAppClient.create_or_update(cmd, resource_group_name, name, containerapp_def) except Exception as e: handle_raw_exception(e) + + +def show_storage(cmd, name, storage_name, resource_group_name): + _validate_subscription_registered(cmd, "Microsoft.App") + + try: + return StorageClient.show(cmd, resource_group_name, name, storage_name) + except CLIError as e: + handle_raw_exception(e) + + +def list_storage(cmd, name, resource_group_name): + _validate_subscription_registered(cmd, "Microsoft.App") + + try: + return StorageClient.list(cmd, resource_group_name, name) + except CLIError as e: + handle_raw_exception(e) + + +def create_or_update_storage(cmd, storage_name, resource_group_name, name, azure_file_account_name, azure_file_share_name, azure_file_account_key, access_mode, no_wait=False): # pylint: disable=redefined-builtin + _validate_subscription_registered(cmd, "Microsoft.App") + + if len(azure_file_share_name) < 3: + raise ValidationError("File share name must be longer than 2 characters.") + + if len(azure_file_account_name) < 3: + raise ValidationError("Account name must be longer than 2 characters.") + + r = None + + try: + r = StorageClient.show(cmd, resource_group_name, name, storage_name) + except: + pass + + if r: + logger.warning("Only AzureFile account keys can be updated. In order to change the AzureFile share name or account name, please delete this storage and create a new one.") + + storage_def = AzureFilePropertiesModel + storage_def["accountKey"] = azure_file_account_key + storage_def["accountName"] = azure_file_account_name + storage_def["shareName"] = azure_file_share_name + storage_def["accessMode"] = access_mode + storage_envelope = {} + storage_envelope["properties"] = {} + storage_envelope["properties"]["azureFile"] = storage_def + + try: + return StorageClient.create_or_update(cmd, resource_group_name, name, storage_name, storage_envelope, no_wait) + except CLIError as e: + handle_raw_exception(e) + + +def remove_storage(cmd, storage_name, name, resource_group_name, no_wait=False): + _validate_subscription_registered(cmd, "Microsoft.App") + + try: + return StorageClient.delete(cmd, resource_group_name, name, storage_name, no_wait) + except CLIError as e: + handle_raw_exception(e) diff --git a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_commands.py b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_commands.py index 3f57809fbfa..97e52aabde8 100644 --- a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_commands.py +++ b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_commands.py @@ -147,3 +147,44 @@ def test_containerapp_identity_user(self, resource_group): self.cmd('containerapp identity show -g {} -n {}'.format(resource_group, ca_name), checks=[ JMESPathCheck('type', 'None'), ]) + + +@live_only() +class ContainerappEnvStorageTests(ScenarioTest): + @AllowLargeResponse(8192) + @ResourceGroupPreparer(location="eastus2") + def test_containerapp_env_storage(self, resource_group): + env_name = self.create_random_name(prefix='containerapp-env', length=24) + storage_name = self.create_random_name(prefix='storage', length=24) + shares_name = self.create_random_name(prefix='share', length=24) + + self.cmd('containerapp env create -g {} -n {}'.format(resource_group, env_name)) + + containerapp_env = self.cmd('containerapp env show -g {} -n {}'.format(resource_group, env_name)).get_output_in_json() + + while containerapp_env["properties"]["provisioningState"].lower() == "waiting": + time.sleep(5) + containerapp_env = self.cmd('containerapp env show -g {} -n {}'.format(resource_group, env_name)).get_output_in_json() + + self.cmd('storage account create -g {} -n {} --kind StorageV2 --sku Standard_ZRS --enable-large-file-share'.format(resource_group, storage_name)) + self.cmd('storage share-rm create -g {} -n {} --storage-account {} --access-tier "TransactionOptimized" --quota 1024'.format(resource_group, shares_name, storage_name)) + + storage_keys = self.cmd('az storage account keys list -g {} -n {}'.format(resource_group, storage_name)).get_output_in_json()[0] + + self.cmd('containerapp env storage set -g {} -n {} --storage-name {} --azure-file-account-name {} --azure-file-account-key {} --access-mode ReadOnly --azure-file-share-name {}'.format(resource_group, env_name, storage_name, storage_name, storage_keys["value"], shares_name), checks=[ + JMESPathCheck('name', storage_name), + ]) + + self.cmd('containerapp env storage show -g {} -n {} --storage-name {}'.format(resource_group, env_name, storage_name), checks=[ + JMESPathCheck('name', storage_name), + ]) + + self.cmd('containerapp env storage list -g {} -n {}'.format(resource_group, env_name), checks=[ + JMESPathCheck('[0].name', storage_name), + ]) + + self.cmd('containerapp env storage remove -g {} -n {} --storage-name {} --yes'.format(resource_group, env_name, storage_name)) + + self.cmd('containerapp env storage list -g {} -n {}'.format(resource_group, env_name), checks=[ + JMESPathCheck('length(@)', 0), + ])