Skip to content

Commit

Permalink
Add command to show the configurations pulled by Application Configur…
Browse files Browse the repository at this point in the history
…ation Service from upstream Git repositories. (#7296)
  • Loading branch information
jiec-msft authored Mar 21, 2024
1 parent fdc4a6a commit 428d2a9
Show file tree
Hide file tree
Showing 10 changed files with 248 additions and 2 deletions.
4 changes: 4 additions & 0 deletions src/spring/HISTORY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/spring/azext_spring/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/spring/azext_spring/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.')
Expand Down
13 changes: 13 additions & 0 deletions src/spring/azext_spring/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/spring/azext_spring/_validators_enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
120 changes: 120 additions & 0 deletions src/spring/azext_spring/application_configuration_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,27 @@

# 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"

RESOURCE_TYPE = "configurationServices"
DEFAULT_NAME = "default"

CONFIGURATION_FILES = "configurationFiles"

logger = get_logger(__name__)


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}'.")
8 changes: 7 additions & 1 deletion src/spring/azext_spring/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
# -----------------------------------------------------------------------------
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 1 addition & 1 deletion src/spring/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 428d2a9

Please sign in to comment.