diff --git a/src/spring/HISTORY.md b/src/spring/HISTORY.md index 342e67dafd8..a500c1ded6b 100644 --- a/src/spring/HISTORY.md +++ b/src/spring/HISTORY.md @@ -1,5 +1,9 @@ Release History =============== +1.20.1 +--- +* Add command to show the configurations pulled by Application Configuration Service from upstream Git repositories. `az spring application-configuration-service config show`. + 1.20.0 --- * Change default Application Configuration Service generation value to Gen2. diff --git a/src/spring/azext_spring/_help.py b/src/spring/azext_spring/_help.py index b6622fedfea..870c16ad296 100644 --- a/src/spring/azext_spring/_help.py +++ b/src/spring/azext_spring/_help.py @@ -1063,6 +1063,16 @@ text: az spring application-configuration-service unbind --app MyApp -s MyService -g MyResourceGroup """ +helps['spring application-configuration-service config'] = """ + type: group + short-summary: Commands to manage the configurations pulled by Application Configuration Service from upstream Git repositories. +""" + +helps['spring application-configuration-service config show'] = """ + type: command + short-summary: Command to show the configurations pulled by Application Configuration Service from upstream Git repositories. +""" + helps['spring gateway'] = """ type: group short-summary: (Enterprise Tier Only) Commands to manage gateway in Azure Spring Apps. diff --git a/src/spring/azext_spring/_params.py b/src/spring/azext_spring/_params.py index f2719409055..d160e9c6b3a 100644 --- a/src/spring/azext_spring/_params.py +++ b/src/spring/azext_spring/_params.py @@ -64,6 +64,9 @@ memory_type = CLIArgumentType(type=str, help='Memory resource quantity. Should be 512Mi, 1536Mi, 2560Mi, 3584Mi or #Gi, e.g., 1Gi, 3Gi.', validator=validate_memory) build_cpu_type = CLIArgumentType(type=str, help='CPU resource quantity. Should be 500m or number of CPU cores.', validator=validate_build_cpu) build_memory_type = CLIArgumentType(type=str, help='Memory resource quantity. Should be 512Mi or #Gi, e.g., 1Gi, 3Gi.', validator=validate_build_memory) +acs_configs_export_path_type = CLIArgumentType(nargs='?', + const='.', + help='The path of directory to export the configuration files. Default to the current folder if no value provided.') # pylint: disable=too-many-statements @@ -892,6 +895,13 @@ def prepare_logs_argument(c): with self.argument_context('spring application-configuration-service git repo {}'.format(scope)) as c: c.argument('name', help="Required unique name to label each item of git configs.") + with self.argument_context('spring application-configuration-service config show') as c: + c.argument('config_file_pattern', + options_list=['--config-file-pattern', '--pattern'], + help='Case sensitive. Set the config file pattern in the formats like {application} or {application}/{profile} ' + 'instead of {application}-{profile}.yml') + c.argument('export_path', arg_type=acs_configs_export_path_type) + for scope in ['gateway create', 'api-portal create']: with self.argument_context('spring {}'.format(scope)) as c: c.argument('instance_count', type=int, help='Number of instance.') diff --git a/src/spring/azext_spring/_utils.py b/src/spring/azext_spring/_utils.py index 2a40bfeaeba..35a2bc5817f 100644 --- a/src/spring/azext_spring/_utils.py +++ b/src/spring/azext_spring/_utils.py @@ -17,6 +17,7 @@ from re import (search, match, compile) from json import dumps +from azure.cli.core._profile import Profile from azure.cli.core.commands.client_factory import get_subscription_id, get_mgmt_service_client from azure.cli.core.profiles import ResourceType from knack.util import CLIError, todict @@ -291,6 +292,11 @@ def get_portal_uri(cli_ctx): return 'https://portal.azure.com' +def get_hostname(cli_ctx, client, resource_group, service_name): + resource = client.services.get(resource_group, service_name) + return get_proxy_api_endpoint(cli_ctx, resource) + + def get_proxy_api_endpoint(cli_ctx, spring_resource): """Get the endpoint of the proxy api.""" if not spring_resource.properties.fqdn: @@ -381,6 +387,13 @@ def _register_resource_provider(cmd, resource_provider): raise ValidationError(resource_provider, msg.format(e.args)) from e +def get_bearer_auth(cli_ctx): + profile = Profile(cli_ctx=cli_ctx) + creds, _, tenant = profile.get_raw_token() + token = creds[1] + return BearerAuth(token) + + class BearerAuth(requests.auth.AuthBase): def __init__(self, token): self.token = token diff --git a/src/spring/azext_spring/_validators_enterprise.py b/src/spring/azext_spring/_validators_enterprise.py index 75688e4630e..62d8bae387d 100644 --- a/src/spring/azext_spring/_validators_enterprise.py +++ b/src/spring/azext_spring/_validators_enterprise.py @@ -329,6 +329,18 @@ def _validate_patterns(patterns): raise InvalidArgumentValueError("Patterns should be the collection of patterns separated by comma, each pattern in the format of 'application' or 'application/profile'") +def validate_pattern_for_show_acs_configs(namespace): + if namespace.config_file_pattern: + if not _is_valid_pattern(namespace.config_file_pattern): + raise InvalidArgumentValueError("Pattern should be in the format of 'application' or 'application/profile'") + if _is_valid_app_and_profile_name(namespace.config_file_pattern): + parts = namespace.config_file_pattern.split('/') + if parts[1] == '*': + namespace.config_file_pattern = f"{parts[0]}/default" + elif _is_valid_app_name(namespace.config_file_pattern): + namespace.config_file_pattern = f"{namespace.config_file_pattern}/default" + + def _is_valid_pattern(pattern): return _is_valid_app_name(pattern) or _is_valid_app_and_profile_name(pattern) diff --git a/src/spring/azext_spring/application_configuration_service.py b/src/spring/azext_spring/application_configuration_service.py index eee7869423f..ef944b3d765 100644 --- a/src/spring/azext_spring/application_configuration_service.py +++ b/src/spring/azext_spring/application_configuration_service.py @@ -5,14 +5,18 @@ # pylint: disable=unused-argument, logging-format-interpolation, protected-access, wrong-import-order, too-many-lines import json +import os +import requests from azure.cli.core.azclierror import ClientRequestError, ValidationError from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import sdk_no_wait from knack.log import get_logger +from knack.util import CLIError from msrestazure.tools import resource_id from .vendored_sdks.appplatform.v2024_01_01_preview.models._app_platform_management_client_enums import (GitImplementation, ConfigurationServiceGeneration) from .vendored_sdks.appplatform.v2024_01_01_preview import models +from ._utils import (get_hostname, get_bearer_auth) APPLICATION_CONFIGURATION_SERVICE_NAME = "applicationConfigurationService" RESOURCE_ID = "resourceId" @@ -20,6 +24,8 @@ RESOURCE_TYPE = "configurationServices" DEFAULT_NAME = "default" +CONFIGURATION_FILES = "configurationFiles" + logger = get_logger(__name__) @@ -144,6 +150,25 @@ def application_configuration_service_unbind(cmd, client, service, resource_grou return _acs_bind_or_unbind_app(cmd, client, service, resource_group, app, False) +def application_configuration_service_config_show(cmd, client, service, resource_group, config_file_pattern, + export_path=None): + url = _get_show_configs_urls(cmd, client, service, resource_group, config_file_pattern) + auth = get_bearer_auth(cmd.cli_ctx) + connect_timeout_in_seconds = 30 + read_timeout_in_seconds = 60 + timeout = (connect_timeout_in_seconds, read_timeout_in_seconds) + with requests.get(url, stream=False, auth=auth, timeout=timeout) as response: + if response.status_code != 200: + _handle_and_raise_get_acs_config_error(url, response) + response_json = response.json() + if export_path is not None: + _export_configs_to_files(response_json, export_path) + # Return None after export to the files + return None + else: + return _split_config_lines(response_json) + + def _acs_bind_or_unbind_app(cmd, client, service, resource_group, app_name, enabled): app = client.apps.get(resource_group, service, app_name) app.properties.addon_configs = _get_app_addon_configs_with_acs(app.properties.addon_configs) @@ -267,3 +292,98 @@ def _validate_acs_settings(client, resource_group, service, acs_settings): validation_result = git_result.git_repos_validation_result filter_result = [{'name': x.name, 'messages': x.messages} for x in validation_result if len(x.messages) > 0] raise ClientRequestError("Application Configuration Service settings contain errors.\n{}".format(json.dumps(filter_result, indent=2))) + + +def _get_show_configs_urls(cmd, client, service, resource_group, config_file_pattern): + hostname = get_hostname(cmd.cli_ctx, client, resource_group, service) + appName, profileName = _get_app_and_profile(config_file_pattern) + url_template = "https://{}/api/applicationConfigurationService/configs/applications/{}/profiles/{}" + url = url_template.format(hostname, appName, profileName) + return url + + +def _get_app_and_profile(config_file_pattern): + # The config file pattern should already be standardized with non-empty app name and profile name + parts = config_file_pattern.split('/') + return parts[0], parts[1] + + +def _handle_and_raise_get_acs_config_error(url, response): + failure_reason = response.reason + if response.content: + if isinstance(response.content, bytes): + failure_reason = f"{failure_reason}:{response.content.decode('utf-8')}" + else: + failure_reason = f"{failure_reason}:{response.content}" + msg = f"Failed to access the url '{url}' with status code '{response.status_code}' and reason '{failure_reason}'" + raise CLIError(msg) + + +def _split_config_lines(response_json): + """ + The configs is subject to the implementation of Application Configuration Service (ACS). + Currently, it only uses "application.properties" file. An exmaple of raw_configs is: + { + "configurationFiles": { + "application.properties": "auth: ssh\nrepo: ado\nspring.cloud.config.enabled: false" + } + } + The expected format is as follows: + { + "configurationFiles": { + "application.properties": [ + "auth: ssh", + "repo: ado", + "spring.cloud.config.enabled: false" + ] + } + } + Note we don't continue parse each line, since there can be corner case like: + { + "application.properties": "p1: v1-\n-8976\np2: v2-\\n-5674" + } + It will be converted to below content in ACS: + { + "application": [ + "p1: v1-", + "-8976", + "p2: v2-\n-5674" + ] + } + """ + configuration_files = response_json[CONFIGURATION_FILES] + + filename_to_multi_line_configs_dict = {} + + for key in configuration_files.keys(): + value = configuration_files[key] + if key.endswith(".properties") and isinstance(value, str): + filename_to_multi_line_configs_dict[key] = value.splitlines() + else: + filename_to_multi_line_configs_dict[key] = value + + if len(filename_to_multi_line_configs_dict) == 0: + raise CLIError("No configuration files found.") + + return { + CONFIGURATION_FILES: filename_to_multi_line_configs_dict + } + + +def _export_configs_to_files(response_json, folder_path): + absolute_folder_path = os.path.abspath(folder_path) + + if not os.path.exists(absolute_folder_path): + logger.warning(f"Directory '{absolute_folder_path}' does not exist, creating it.") + os.makedirs(absolute_folder_path) + + if not os.path.isdir(absolute_folder_path): + raise CLIError(f"Path '{absolute_folder_path}' is not a directory.") + + for filename in response_json[CONFIGURATION_FILES].keys(): + absolute_file_path = os.path.join(absolute_folder_path, filename) + if os.path.exists(absolute_file_path): + logger.warning(f"File already exists: '{absolute_file_path}', overriding it.") + with open(absolute_file_path, 'w', encoding="utf-8") as file: + file.write(response_json[CONFIGURATION_FILES][filename]) + logger.warning(f"Exported configurations to file '{absolute_file_path}'.") diff --git a/src/spring/azext_spring/commands.py b/src/spring/azext_spring/commands.py index 2ac3543ad1a..2ad2dd1fd2a 100644 --- a/src/spring/azext_spring/commands.py +++ b/src/spring/azext_spring/commands.py @@ -31,7 +31,8 @@ transform_support_server_versions_output) from ._validators import validate_app_insights_command_not_supported_tier from ._marketplace import (transform_marketplace_plan_output) -from ._validators_enterprise import (validate_gateway_update, validate_api_portal_update, validate_dev_tool_portal, validate_customized_accelerator) +from ._validators_enterprise import (validate_gateway_update, validate_api_portal_update, validate_dev_tool_portal, + validate_customized_accelerator, validate_pattern_for_show_acs_configs) from .managed_components.validators_managed_component import (validate_component_logs, validate_component_list, validate_instance_list) from ._app_managed_identity_validator import (validate_app_identity_remove_or_warning, validate_app_identity_assign_or_warning) @@ -319,6 +320,11 @@ def load_command_table(self, _): g.custom_command('update', 'application_configuration_service_update', table_transformer=transform_application_configuration_service_output) g.custom_command('delete', 'application_configuration_service_delete', confirmation=True) + with self.command_group('spring application-configuration-service config', + custom_command_type=application_configuration_service_cmd_group, + exception_handler=handle_asc_exception) as g: + g.custom_show_command('show', 'application_configuration_service_config_show', validator=validate_pattern_for_show_acs_configs) + with self.command_group('spring application-configuration-service git repo', custom_command_type=application_configuration_service_cmd_group, exception_handler=handle_asc_exception) as g: diff --git a/src/spring/azext_spring/tests/latest/application_configuration_service/__init__.py b/src/spring/azext_spring/tests/latest/application_configuration_service/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/spring/azext_spring/tests/latest/application_configuration_service/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/spring/azext_spring/tests/latest/application_configuration_service/test_acs_validators.py b/src/spring/azext_spring/tests/latest/application_configuration_service/test_acs_validators.py new file mode 100644 index 00000000000..563fe2e6462 --- /dev/null +++ b/src/spring/azext_spring/tests/latest/application_configuration_service/test_acs_validators.py @@ -0,0 +1,66 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- + +import unittest +from argparse import Namespace +from azure.cli.core.azclierror import InvalidArgumentValueError +from ...._validators_enterprise import (validate_pattern_for_show_acs_configs) + +valid_pattern_dict = { + "ASfefsdeDfWeb/azdit": "ASfefsdeDfWeb/azdit", + "ASfefsdeDfWeb/*": "ASfefsdeDfWeb/default", + "ASfefsdeDfWeb/default": "ASfefsdeDfWeb/default", + "admin-application": "admin-application/default", + "admin-application/*": "admin-application/default", + "admin-application/default": "admin-application/default", + "application/default": "application/default", + "application/*": "application/default", + "application": "application/default", + "a": "a/default", + "application/b": "application/b", + "a/b": "a/b", + "a/*": "a/default" +} + +invalid_pattern_list = [ + "admin-application/", + "admin-application/default/default", + "admin-application//my-profile", + "admin-application///my-profile", + "admin-application/**", + "/*", + "/*//", + "/default", + "//default", + " /default", + " /default", + "application/default ", + "application/ default", + "application /default", + " application/default", + "application/ ", + "application/ " +] + + +class TestAcsValidators(unittest.TestCase): + def test_valid_pattern_for_show_acs_configs(self): + for pattern in valid_pattern_dict.keys(): + ns = Namespace(resource_group="group", + service="service", + config_file_pattern=pattern) + validate_pattern_for_show_acs_configs(ns) + self.assertEquals(valid_pattern_dict[pattern], ns.config_file_pattern) + + def test_invalid_pattern_for_show_acs_configs(self): + expectedErr = "Pattern should be in the format of 'application' or 'application/profile'" + for pattern in invalid_pattern_list: + with self.assertRaises(InvalidArgumentValueError) as context: + ns = Namespace(resource_group="group", + service="service", + config_file_pattern=pattern) + validate_pattern_for_show_acs_configs(ns) + self.assertTrue(expectedErr in str(context.exception)) diff --git a/src/spring/setup.py b/src/spring/setup.py index c41ed197e3f..b7728f17d40 100644 --- a/src/spring/setup.py +++ b/src/spring/setup.py @@ -16,7 +16,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '1.20.0' +VERSION = '1.20.1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers