diff --git a/src/spring-cloud/HISTORY.md b/src/spring-cloud/HISTORY.md index 719bc65fc83..109b0ec9172 100644 --- a/src/spring-cloud/HISTORY.md +++ b/src/spring-cloud/HISTORY.md @@ -1,6 +1,10 @@ Release History =============== +0.2.4 +----- +* Add command "az spring-cloud app identity" to support Managed Identity feature + 0.2.3 ----- * Add command "az spring-cloud app custom-domain" and "az spring-cloud certificate" to support Custom Domain feature. @@ -27,4 +31,4 @@ Release History 0.1.0 ----- -* Initial release. \ No newline at end of file +* Initial release. diff --git a/src/spring-cloud/azext_spring_cloud/_help.py b/src/spring-cloud/azext_spring_cloud/_help.py index 3fde2366504..3b721fbcae3 100644 --- a/src/spring-cloud/azext_spring_cloud/_help.py +++ b/src/spring-cloud/azext_spring_cloud/_help.py @@ -147,6 +147,37 @@ short-summary: Show logs of an app instance, logs will be streamed when setting '-f/--follow'. """ +helps['spring-cloud app identity'] = """ + type: group + short-summary: Manage an app's managed service identity. +""" + +helps['spring-cloud app identity assign'] = """ + type: command + short-summary: Enable managed service identity on an app. + examples: + - name: Enable the system assigned identity. + text: az spring-cloud app identity assign -n MyApp -s MyCluster -g MyResourceGroup + - name: Enable the system assigned identity on an app with the 'Reader' role. + text: az spring-cloud app identity assign -n MyApp -s MyCluster -g MyResourceGroup --role Reader --scope /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/xxxxx/providers/Microsoft.KeyVault/vaults/xxxxx +""" + +helps['spring-cloud app identity remove'] = """ + type: command + short-summary: Remove managed service identity from an app. + examples: + - name: Remove the system assigned identity from an app. + text: az spring-cloud app identity remove -n MyApp -s MyCluster -g MyResourceGroup +""" + +helps['spring-cloud app identity show'] = """ + type: command + short-summary: Display app's managed identity info. + examples: + - name: Display an app's managed identity info. + text: az spring-cloud app identity show -n MyApp -s MyCluster -g MyResourceGroup +""" + helps['spring-cloud app set-deployment'] = """ type: command short-summary: Set production deployment of an app. diff --git a/src/spring-cloud/azext_spring_cloud/_params.py b/src/spring-cloud/azext_spring_cloud/_params.py index e3c57b5063c..c72e0edab85 100644 --- a/src/spring-cloud/azext_spring_cloud/_params.py +++ b/src/spring-cloud/azext_spring_cloud/_params.py @@ -45,6 +45,8 @@ def load_arguments(self, _): with self.argument_context('spring-cloud app create') as c: c.argument( 'is_public', arg_type=get_three_state_flag(), help='If true, assign public domain', default=False) + c.argument('assign_identity', arg_type=get_three_state_flag(), + help='If true, assign managed service identity.') with self.argument_context('spring-cloud app update') as c: c.argument('is_public', arg_type=get_three_state_flag(), help='If true, assign endpoint') @@ -55,6 +57,10 @@ def load_arguments(self, _): c.argument('deployment', options_list=[ '--deployment', '-d'], help='Name of an existing deployment of the app. Default to the production deployment if not specified.', validator=validate_deployment_name) + with self.argument_context('spring-cloud app identity assign') as c: + c.argument('scope', help="The scope the managed identity has access to") + c.argument('role', help="Role name or id the managed identity will be assigned") + with self.argument_context('spring-cloud app logs') as c: c.argument('instance', options_list=['--instance', '-i'], help='Name of an existing instance of the deployment.') c.argument('lines', type=int, help='Number of lines to show. Maximum is 10000', validator=validate_log_lines) diff --git a/src/spring-cloud/azext_spring_cloud/commands.py b/src/spring-cloud/azext_spring_cloud/commands.py index b1080757170..4a8491c36a7 100644 --- a/src/spring-cloud/azext_spring_cloud/commands.py +++ b/src/spring-cloud/azext_spring_cloud/commands.py @@ -61,6 +61,11 @@ def load_command_table(self, _): g.custom_command('restart', 'app_restart', supports_no_wait=True) g.custom_command('logs', 'app_tail_log') + with self.command_group('spring-cloud app identity', client_factory=cf_spring_cloud) as g: + g.custom_command('assign', 'app_identity_assign') + g.custom_command('remove', 'app_identity_remove') + g.custom_show_command('show', 'app_identity_show') + with self.command_group('spring-cloud app log', client_factory=cf_spring_cloud, deprecate_info=g.deprecate(redirect='az spring-cloud app logs', hide=True)) as g: g.custom_command('tail', 'app_tail_log') diff --git a/src/spring-cloud/azext_spring_cloud/custom.py b/src/spring-cloud/azext_spring_cloud/custom.py index a9862f333af..1978e3b29ee 100644 --- a/src/spring-cloud/azext_spring_cloud/custom.py +++ b/src/spring-cloud/azext_spring_cloud/custom.py @@ -16,6 +16,7 @@ from knack.log import get_logger from .azure_storage_file import FileService from azure.cli.core.util import sdk_no_wait +from azure.cli.core.profiles import ResourceType, get_sdk from ast import literal_eval from azure.cli.core.commands import cached_put from ._utils import _get_rg_location @@ -100,7 +101,8 @@ def app_create(cmd, client, resource_group, service, name, runtime_version=None, jvm_options=None, env=None, - enable_persistent_storage=None): + enable_persistent_storage=None, + assign_identity=None): apps = _get_all_apps(client, resource_group, service) if name in apps: raise CLIError("App '{}' already exists.".format(name)) @@ -119,8 +121,14 @@ def app_create(cmd, client, resource_group, service, name, resource = client.services.get(resource_group, service) location = resource.location + app_resource = models.AppResource() + app_resource.properties = properties + app_resource.location = location + if assign_identity is True: + app_resource.identity = models.ManagedIdentityProperties(type="systemassigned") + poller = client.apps.create_or_update( - resource_group, service, name, properties, location) + resource_group, service, name, app_resource) while poller.done() is False: sleep(APP_CREATE_OR_UPDATE_SLEEP_INTERVAL) @@ -147,7 +155,10 @@ def app_create(cmd, client, resource_group, service, name, properties = models.AppResourceProperties( active_deployment_name=DEFAULT_DEPLOYMENT_NAME, public=is_public) - app_poller = client.apps.update(resource_group, service, name, properties, location) + app_resource.properties = properties + app_resource.location = location + + app_poller = client.apps.update(resource_group, service, name, app_resource) logger.warning( "[4/4] Updating app '{}' (this operation can take a while to complete)".format(name)) while not poller.done() or not app_poller.done(): @@ -179,9 +190,13 @@ def app_update(cmd, client, resource_group, service, name, resource = client.services.get(resource_group, service) location = resource.location + app_resource = models.AppResource() + app_resource.properties = properties + app_resource.location = location + logger.warning("[1/2] updating app '{}'".format(name)) poller = client.apps.update( - resource_group, service, name, properties, location) + resource_group, service, name, app_resource) while poller.done() is False: sleep(APP_CREATE_OR_UPDATE_SLEEP_INTERVAL) @@ -426,6 +441,69 @@ def app_tail_log(cmd, client, resource_group, service, name, instance=None, foll raise exceptions[0] +def app_identity_assign(cmd, client, resource_group, service, name, role=None, scope=None): + app_resource = models.AppResource() + identity = models.ManagedIdentityProperties(type="systemassigned") + properties = models.AppResourceProperties() + resource = client.services.get(resource_group, service) + location = resource.location + + app_resource.identity = identity + app_resource.properties = properties + app_resource.location = location + client.apps.update(resource_group, service, name, app_resource) + app = client.apps.get(resource_group, service, name) + if role: + principal_id = app.identity.principal_id + + from azure.cli.core.commands import arm as _arm + identity_role_id = _arm.resolve_role_id(cmd.cli_ctx, role, scope) + from azure.cli.core.commands.client_factory import get_mgmt_service_client + assignments_client = get_mgmt_service_client(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION).role_assignments + RoleAssignmentCreateParameters = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, + 'RoleAssignmentCreateParameters', mod='models', + operation_group='role_assignments') + parameters = RoleAssignmentCreateParameters(role_definition_id=identity_role_id, principal_id=principal_id) + logger.info("Creating an assignment with a role '%s' on the scope of '%s'", identity_role_id, scope) + retry_times = 36 + assignment_name = _arm._gen_guid() + for l in range(0, retry_times): + try: + assignments_client.create(scope=scope, role_assignment_name=assignment_name, + parameters=parameters) + break + except CloudError as ex: + if 'role assignment already exists' in ex.message: + logger.info('Role assignment already exists') + break + elif l < retry_times and ' does not exist in the directory ' in ex.message: + sleep(APP_CREATE_OR_UPDATE_SLEEP_INTERVAL) + logger.warning('Retrying role assignment creation: %s/%s', l + 1, + retry_times) + continue + else: + raise + return app + + +def app_identity_remove(cmd, client, resource_group, service, name): + app_resource = models.AppResource() + identity = models.ManagedIdentityProperties(type="none") + properties = models.AppResourceProperties() + resource = client.services.get(resource_group, service) + location = resource.location + + app_resource.identity = identity + app_resource.properties = properties + app_resource.location = location + return client.apps.update(resource_group, service, name, app_resource) + + +def app_identity_show(cmd, client, resource_group, service, name): + app = client.apps.get(resource_group, service, name) + return app.identity + + def app_set_deployment(cmd, client, resource_group, service, name, deployment): deployments = _get_all_deployments(client, resource_group, service, name) active_deployment = client.apps.get( @@ -442,7 +520,11 @@ def app_set_deployment(cmd, client, resource_group, service, name, deployment): resource = client.services.get(resource_group, service) location = resource.location - return client.apps.update(resource_group, service, name, properties, location) + app_resource = models.AppResource() + app_resource.properties = properties + app_resource.location = location + + return client.apps.update(resource_group, service, name, app_resource) def deployment_create(cmd, client, resource_group, service, app, name, diff --git a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/__init__.py b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/__init__.py index 128d3f0f9e5..f3745719d31 100644 --- a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/__init__.py +++ b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/__init__.py @@ -32,6 +32,7 @@ from ._models_py3 import GitPatternRepository from ._models_py3 import LogFileUrlResponse from ._models_py3 import LogSpecification + from ._models_py3 import ManagedIdentityProperties from ._models_py3 import MetricDimension from ._models_py3 import MetricSpecification from ._models_py3 import NameAvailability @@ -74,6 +75,7 @@ from ._models import GitPatternRepository from ._models import LogFileUrlResponse from ._models import LogSpecification + from ._models import ManagedIdentityProperties from ._models import MetricDimension from ._models import MetricSpecification from ._models import NameAvailability @@ -104,6 +106,7 @@ ProvisioningState, ConfigServerState, TraceProxyState, + ManagedIdentityType, TestKeyType, AppResourceProvisioningState, UserSourceType, @@ -135,6 +138,7 @@ 'GitPatternRepository', 'LogFileUrlResponse', 'LogSpecification', + 'ManagedIdentityProperties', 'MetricDimension', 'MetricSpecification', 'NameAvailability', @@ -164,6 +168,7 @@ 'ProvisioningState', 'ConfigServerState', 'TraceProxyState', + 'ManagedIdentityType', 'TestKeyType', 'AppResourceProvisioningState', 'UserSourceType', diff --git a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_app_platform_management_client_enums.py b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_app_platform_management_client_enums.py index 2978170fb08..c4b4fd4c440 100644 --- a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_app_platform_management_client_enums.py +++ b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_app_platform_management_client_enums.py @@ -42,6 +42,14 @@ class TraceProxyState(str, Enum): updating = "Updating" +class ManagedIdentityType(str, Enum): + + none = "None" + system_assigned = "SystemAssigned" + user_assigned = "UserAssigned" + system_assigned_user_assigned = "SystemAssigned,UserAssigned" + + class TestKeyType(str, Enum): primary = "Primary" diff --git a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models.py b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models.py index 7fee53fad61..b967013d0c6 100644 --- a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models.py +++ b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models.py @@ -91,6 +91,8 @@ class AppResource(ProxyResource): :vartype type: str :param properties: Properties of the App resource :type properties: ~azure.mgmt.appplatform.models.AppResourceProperties + :param identity: The Managed Identity type of the app resource + :type identity: ~azure.mgmt.appplatform.models.ManagedIdentityProperties :param location: The GEO location of the application, always the same with its parent resource :type location: str @@ -107,12 +109,14 @@ class AppResource(ProxyResource): 'name': {'key': 'name', 'type': 'str'}, 'type': {'key': 'type', 'type': 'str'}, 'properties': {'key': 'properties', 'type': 'AppResourceProperties'}, + 'identity': {'key': 'identity', 'type': 'ManagedIdentityProperties'}, 'location': {'key': 'location', 'type': 'str'}, } def __init__(self, **kwargs): super(AppResource, self).__init__(**kwargs) self.properties = kwargs.get('properties', None) + self.identity = kwargs.get('identity', None) self.location = kwargs.get('location', None) @@ -979,6 +983,31 @@ def __init__(self, **kwargs): self.blob_duration = kwargs.get('blob_duration', None) +class ManagedIdentityProperties(Model): + """Managed identity properties retrieved from ARM request headers. + + :param type: Possible values include: 'None', 'SystemAssigned', + 'UserAssigned', 'SystemAssigned,UserAssigned' + :type type: str or ~azure.mgmt.appplatform.models.ManagedIdentityType + :param principal_id: + :type principal_id: str + :param tenant_id: + :type tenant_id: str + """ + + _attribute_map = { + 'type': {'key': 'type', 'type': 'str'}, + 'principal_id': {'key': 'principalId', 'type': 'str'}, + 'tenant_id': {'key': 'tenantId', 'type': 'str'}, + } + + def __init__(self, **kwargs): + super(ManagedIdentityProperties, self).__init__(**kwargs) + self.type = kwargs.get('type', None) + self.principal_id = kwargs.get('principal_id', None) + self.tenant_id = kwargs.get('tenant_id', None) + + class MetricDimension(Model): """Specifications of the Dimension of metrics. diff --git a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models_py3.py b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models_py3.py index f15bff32d6f..8a05ad5edda 100644 --- a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models_py3.py +++ b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/models/_models_py3.py @@ -91,6 +91,8 @@ class AppResource(ProxyResource): :vartype type: str :param properties: Properties of the App resource :type properties: ~azure.mgmt.appplatform.models.AppResourceProperties + :param identity: The Managed Identity type of the app resource + :type identity: ~azure.mgmt.appplatform.models.ManagedIdentityProperties :param location: The GEO location of the application, always the same with its parent resource :type location: str @@ -107,12 +109,14 @@ class AppResource(ProxyResource): 'name': {'key': 'name', 'type': 'str'}, 'type': {'key': 'type', 'type': 'str'}, 'properties': {'key': 'properties', 'type': 'AppResourceProperties'}, + 'identity': {'key': 'identity', 'type': 'ManagedIdentityProperties'}, 'location': {'key': 'location', 'type': 'str'}, } - def __init__(self, *, properties=None, location: str=None, **kwargs) -> None: + def __init__(self, *, properties=None, identity=None, location: str=None, **kwargs) -> None: super(AppResource, self).__init__(**kwargs) self.properties = properties + self.identity = identity self.location = location @@ -979,6 +983,31 @@ def __init__(self, *, name: str=None, display_name: str=None, blob_duration: str self.blob_duration = blob_duration +class ManagedIdentityProperties(Model): + """Managed identity properties retrieved from ARM request headers. + + :param type: Possible values include: 'None', 'SystemAssigned', + 'UserAssigned', 'SystemAssigned,UserAssigned' + :type type: str or ~azure.mgmt.appplatform.models.ManagedIdentityType + :param principal_id: + :type principal_id: str + :param tenant_id: + :type tenant_id: str + """ + + _attribute_map = { + 'type': {'key': 'type', 'type': 'str'}, + 'principal_id': {'key': 'principalId', 'type': 'str'}, + 'tenant_id': {'key': 'tenantId', 'type': 'str'}, + } + + def __init__(self, *, type=None, principal_id: str=None, tenant_id: str=None, **kwargs) -> None: + super(ManagedIdentityProperties, self).__init__(**kwargs) + self.type = type + self.principal_id = principal_id + self.tenant_id = tenant_id + + class MetricDimension(Model): """Specifications of the Dimension of metrics. diff --git a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/operations/_apps_operations.py b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/operations/_apps_operations.py index 482213a628a..4b8a5e5d58c 100644 --- a/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/operations/_apps_operations.py +++ b/src/spring-cloud/azext_spring_cloud/vendored_sdks/appplatform/operations/_apps_operations.py @@ -113,9 +113,7 @@ def get( def _create_or_update_initial( - self, resource_group_name, service_name, app_name, properties=None, location=None, custom_headers=None, raw=False, **operation_config): - app_resource = models.AppResource(properties=properties, location=location) - + self, resource_group_name, service_name, app_name, app_resource, custom_headers=None, raw=False, **operation_config): # Construct URL url = self.create_or_update.metadata['url'] path_format_arguments = { @@ -167,7 +165,7 @@ def _create_or_update_initial( return deserialized def create_or_update( - self, resource_group_name, service_name, app_name, properties=None, location=None, custom_headers=None, raw=False, polling=True, **operation_config): + self, resource_group_name, service_name, app_name, app_resource, custom_headers=None, raw=False, polling=True, **operation_config): """Create a new App or update an exiting App. :param resource_group_name: The name of the resource group that @@ -178,11 +176,8 @@ def create_or_update( :type service_name: str :param app_name: The name of the App resource. :type app_name: str - :param properties: Properties of the App resource - :type properties: ~azure.mgmt.appplatform.models.AppResourceProperties - :param location: The GEO location of the application, always the same - with its parent resource - :type location: str + :param app_resource: Parameters for the create or update operation + :type app_resource: ~azure.mgmt.appplatform.models.AppResource :param dict custom_headers: headers that will be added to the request :param bool raw: The poller return type is ClientRawResponse, the direct response alongside the deserialized response @@ -200,8 +195,7 @@ def create_or_update( resource_group_name=resource_group_name, service_name=service_name, app_name=app_name, - properties=properties, - location=location, + app_resource=app_resource, custom_headers=custom_headers, raw=True, **operation_config @@ -285,9 +279,7 @@ def delete( def _update_initial( - self, resource_group_name, service_name, app_name, properties=None, location=None, custom_headers=None, raw=False, **operation_config): - app_resource = models.AppResource(properties=properties, location=location) - + self, resource_group_name, service_name, app_name, app_resource, custom_headers=None, raw=False, **operation_config): # Construct URL url = self.update.metadata['url'] path_format_arguments = { @@ -339,7 +331,7 @@ def _update_initial( return deserialized def update( - self, resource_group_name, service_name, app_name, properties=None, location=None, custom_headers=None, raw=False, polling=True, **operation_config): + self, resource_group_name, service_name, app_name, app_resource, custom_headers=None, raw=False, polling=True, **operation_config): """Operation to update an exiting App. :param resource_group_name: The name of the resource group that @@ -350,11 +342,8 @@ def update( :type service_name: str :param app_name: The name of the App resource. :type app_name: str - :param properties: Properties of the App resource - :type properties: ~azure.mgmt.appplatform.models.AppResourceProperties - :param location: The GEO location of the application, always the same - with its parent resource - :type location: str + :param app_resource: Parameters for the update operation + :type app_resource: ~azure.mgmt.appplatform.models.AppResource :param dict custom_headers: headers that will be added to the request :param bool raw: The poller return type is ClientRawResponse, the direct response alongside the deserialized response @@ -372,8 +361,7 @@ def update( resource_group_name=resource_group_name, service_name=service_name, app_name=app_name, - properties=properties, - location=location, + app_resource=app_resource, custom_headers=custom_headers, raw=True, **operation_config diff --git a/src/spring-cloud/setup.py b/src/spring-cloud/setup.py index 712fedee78a..3caca43bb82 100644 --- a/src/spring-cloud/setup.py +++ b/src/spring-cloud/setup.py @@ -16,7 +16,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '0.2.3' +VERSION = '0.2.4' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers