1# --------------------------------------------------------------------------------------------
2# Copyright (c) Microsoft Corporation. All rights reserved.
3# Licensed under the MIT License. See License.txt in the project root for license information.
4# --------------------------------------------------------------------------------------------
5
6# pylint: disable=too-many-locals
7
8from knack.util import CLIError
9from knack.log import get_logger
10from azure.cli.core.util import user_confirmation
11
12from ._constants import get_managed_sku, get_premium_sku
13from ._utils import (
14    get_registry_by_name,
15    validate_managed_registry,
16    validate_sku_update,
17    get_resource_group_name_by_registry_name,
18    resolve_identity_client_id
19)
20from ._docker_utils import get_login_credentials, EMPTY_GUID
21from .network_rule import NETWORK_RULE_NOT_SUPPORTED
22
23logger = get_logger(__name__)
24DEF_DIAG_SETTINGS_NAME_TEMPLATE = '{}-diagnostic-settings'
25SYSTEM_ASSIGNED_IDENTITY_ALIAS = '[system]'
26
27
28def acr_check_name(client, registry_name):
29    registry = {
30        'name': registry_name,
31        'type': 'Microsoft.ContainerRegistry/registries'
32    }
33    return client.check_name_availability(registry)
34
35
36def acr_list(client, resource_group_name=None):
37    if resource_group_name:
38        return client.list_by_resource_group(resource_group_name)
39    return client.list()
40
41
42def acr_create(cmd,
43               client,
44               registry_name,
45               resource_group_name,
46               sku,
47               location=None,
48               admin_enabled=False,
49               default_action=None,
50               workspace=None,
51               identity=None,
52               key_encryption_key=None,
53               public_network_enabled=None,
54               zone_redundancy=None,
55               allow_trusted_services=None,
56               allow_exports=None,
57               tags=None):
58
59    if default_action and sku not in get_premium_sku(cmd):
60        raise CLIError(NETWORK_RULE_NOT_SUPPORTED)
61
62    if sku not in get_managed_sku(cmd):
63        raise CLIError("Classic SKU is no longer supported. Please select a managed SKU.")
64
65    Registry, Sku, NetworkRuleSet = cmd.get_models('Registry', 'Sku', 'NetworkRuleSet')
66    registry = Registry(location=location, sku=Sku(name=sku), admin_user_enabled=admin_enabled,
67                        zone_redundancy=zone_redundancy, tags=tags)
68    if default_action:
69        registry.network_rule_set = NetworkRuleSet(default_action=default_action)
70
71    if public_network_enabled is not None:
72        _configure_public_network_access(cmd, registry, public_network_enabled)
73
74    if identity or key_encryption_key:
75        _configure_cmk(cmd, registry, resource_group_name, identity, key_encryption_key)
76
77    _handle_network_bypass(cmd, registry, allow_trusted_services)
78    _handle_export_policy(cmd, registry, allow_exports)
79
80    lro_poller = client.begin_create(resource_group_name, registry_name, registry)
81
82    if workspace:
83        from msrestazure.tools import is_valid_resource_id, resource_id
84        from azure.cli.core.commands import LongRunningOperation
85        from azure.cli.core.commands.client_factory import get_subscription_id
86        acr = LongRunningOperation(cmd.cli_ctx)(lro_poller)
87        if not is_valid_resource_id(workspace):
88            workspace = resource_id(subscription=get_subscription_id(cmd.cli_ctx),
89                                    resource_group=resource_group_name,
90                                    namespace='microsoft.OperationalInsights',
91                                    type='workspaces',
92                                    name=workspace)
93        _create_diagnostic_settings(cmd.cli_ctx, acr, workspace)
94        return acr
95
96    return lro_poller
97
98
99def acr_delete(cmd, client, registry_name, resource_group_name=None, yes=False):
100    user_confirmation("Are you sure you want to delete the registry '{}'?".format(registry_name), yes)
101    resource_group_name = get_resource_group_name_by_registry_name(cmd.cli_ctx, registry_name, resource_group_name)
102    return client.begin_delete(resource_group_name, registry_name)
103
104
105def acr_show(cmd, client, registry_name, resource_group_name=None):
106    resource_group_name = get_resource_group_name_by_registry_name(cmd.cli_ctx, registry_name, resource_group_name)
107    return client.get(resource_group_name, registry_name)
108
109
110def acr_update_custom(cmd,
111                      instance,
112                      sku=None,
113                      admin_enabled=None,
114                      default_action=None,
115                      data_endpoint_enabled=None,
116                      public_network_enabled=None,
117                      allow_trusted_services=None,
118                      anonymous_pull_enabled=None,
119                      allow_exports=None,
120                      tags=None):
121    if sku is not None:
122        Sku = cmd.get_models('Sku')
123        instance.sku = Sku(name=sku)
124
125    if admin_enabled is not None:
126        instance.admin_user_enabled = admin_enabled
127
128    if tags is not None:
129        instance.tags = tags
130
131    if default_action is not None:
132        NetworkRuleSet = cmd.get_models('NetworkRuleSet')
133        instance.network_rule_set = NetworkRuleSet(default_action=default_action)
134
135    if data_endpoint_enabled is not None:
136        instance.data_endpoint_enabled = data_endpoint_enabled
137
138    if public_network_enabled is not None:
139        _configure_public_network_access(cmd, instance, public_network_enabled)
140
141    if anonymous_pull_enabled is not None:
142        instance.anonymous_pull_enabled = anonymous_pull_enabled
143
144    _handle_network_bypass(cmd, instance, allow_trusted_services)
145    _handle_export_policy(cmd, instance, allow_exports)
146
147    return instance
148
149
150def _configure_public_network_access(cmd, registry, enabled):
151    PublicNetworkAccess = cmd.get_models('PublicNetworkAccess')
152    registry.public_network_access = (PublicNetworkAccess.enabled if enabled else PublicNetworkAccess.disabled)
153
154
155def _handle_network_bypass(cmd, registry, allow_trusted_services):
156    if allow_trusted_services is not None:
157        NetworkRuleBypassOptions = cmd.get_models('NetworkRuleBypassOptions')
158        registry.network_rule_bypass_options = (NetworkRuleBypassOptions.azure_services
159                                                if allow_trusted_services else NetworkRuleBypassOptions.none)
160
161
162def _handle_export_policy(cmd, registry, allow_exports):
163    if allow_exports is not None:
164        Policies, ExportPolicy, ExportPolicyStatus = cmd.get_models('Policies', 'ExportPolicy', 'ExportPolicyStatus')
165
166        if registry.policies is None:
167            registry.policies = Policies()
168
169        status = ExportPolicyStatus.DISABLED if not allow_exports else ExportPolicyStatus.ENABLED
170        try:
171            registry.policies.export_policy.status = status
172        except AttributeError:
173            registry.policies.export_policy = ExportPolicy(status=status)
174
175
176def acr_update_get(cmd):
177    """Returns an empty RegistryUpdateParameters object.
178    """
179    RegistryUpdateParameters = cmd.get_models('RegistryUpdateParameters')
180    return RegistryUpdateParameters()
181
182
183def acr_update_set(cmd,
184                   client,
185                   registry_name,
186                   resource_group_name=None,
187                   parameters=None):
188    registry, resource_group_name = get_registry_by_name(cmd.cli_ctx, registry_name, resource_group_name)
189
190    if parameters.network_rule_set and registry.sku.name not in get_premium_sku(cmd):
191        raise CLIError(NETWORK_RULE_NOT_SUPPORTED)
192
193    validate_sku_update(cmd, registry.sku.name, parameters.sku)
194
195    return client.begin_update(resource_group_name, registry_name, parameters)
196
197
198def acr_show_endpoints(cmd,
199                       registry_name,
200                       resource_group_name=None):
201    registry, resource_group_name = get_registry_by_name(cmd.cli_ctx, registry_name, resource_group_name)
202    info = {
203        'loginServer': registry.login_server,
204        'dataEndpoints': []
205    }
206    if registry.data_endpoint_enabled:
207        for host in registry.data_endpoint_host_names:
208            info['dataEndpoints'].append({
209                'region': host.split('.')[1],
210                'endpoint': host,
211            })
212    else:
213        logger.warning('To configure client firewall w/o using wildcard storage blob urls, '
214                       'use "az acr update --name %s --data-endpoint-enabled" to enable dedicated '
215                       'data endpoints.', registry_name)
216        from ._client_factory import cf_acr_replications
217        replicate_client = cf_acr_replications(cmd.cli_ctx)
218        replicates = list(replicate_client.list(resource_group_name, registry_name))
219        for r in replicates:
220            info['dataEndpoints'].append({
221                'region': r.location,
222                'endpoint': '*.blob.' + cmd.cli_ctx.cloud.suffixes.storage_endpoint,
223            })
224        if not replicates:
225            info['dataEndpoints'].append({
226                'region': registry.location,
227                'endpoint': '*.blob.' + cmd.cli_ctx.cloud.suffixes.storage_endpoint,
228            })
229
230    return info
231
232
233def acr_login(cmd,
234              registry_name,
235              resource_group_name=None,  # pylint: disable=unused-argument
236              tenant_suffix=None,
237              username=None,
238              password=None,
239              expose_token=False):
240    if expose_token:
241        if username or password:
242            raise CLIError("`--expose-token` cannot be combined with `--username` or `--password`.")
243
244        login_server, _, password = get_login_credentials(
245            cmd=cmd,
246            registry_name=registry_name,
247            tenant_suffix=tenant_suffix,
248            username=username,
249            password=password)
250
251        logger.warning("You can perform manual login using the provided access token below, "
252                       "for example: 'docker login loginServer -u %s -p accessToken'", EMPTY_GUID)
253
254        token_info = {
255            "loginServer": login_server,
256            "accessToken": password
257        }
258
259        return token_info
260
261    tips = "You may want to use 'az acr login -n {} --expose-token' to get an access token, " \
262           "which does not require Docker to be installed.".format(registry_name)
263
264    from azure.cli.core.util import in_cloud_console
265    if in_cloud_console():
266        raise CLIError("This command requires running the docker daemon, "
267                       "which is not supported in Azure Cloud Shell. " + tips)
268
269    try:
270        docker_command, _ = get_docker_command()
271    except CLIError as e:
272        logger.warning(tips)
273        raise e
274
275    login_server, username, password = get_login_credentials(
276        cmd=cmd,
277        registry_name=registry_name,
278        tenant_suffix=tenant_suffix,
279        username=username,
280        password=password)
281
282    # warn casing difference caused by ACR normalizing to lower on login_server
283    parts = login_server.split('.')
284    if registry_name != parts[0] and registry_name.lower() == parts[0]:
285        logger.warning('Uppercase characters are detected in the registry name. When using its server url in '
286                       'docker commands, to avoid authentication errors, use all lowercase.')
287
288    from subprocess import PIPE, Popen
289    logger.debug("Invoking '%s --username %s --password <redacted> %s'",
290                 docker_command, username, login_server)
291    p = Popen([docker_command, "login",
292               "--username", username,
293               "--password", password,
294               login_server], stderr=PIPE)
295    _, stderr = p.communicate()
296    return_code = p.returncode
297
298    if stderr:
299        if b'error storing credentials' in stderr and b'stub received bad data' in stderr \
300           and _check_wincred(login_server):
301            # Retry once after disabling wincred
302            p = Popen([docker_command, "login",
303                       "--username", username,
304                       "--password", password,
305                       login_server])
306            p.wait()
307        else:
308            stderr_messages = stderr.decode()
309            # Dismiss the '--password-stdin' warning
310            if b'--password-stdin' in stderr:
311                errors = [err for err in stderr_messages.split('\n') if err and '--password-stdin' not in err]
312                # Will not raise CLIError if there is no error other than '--password-stdin'
313                if not errors:
314                    return None
315                stderr_messages = '\n'.join(errors)
316            logger.warning(stderr_messages)
317
318            # Raise error only if docker returns non-zero
319            if return_code != 0:
320                raise CLIError('Login failed.')
321
322    return None
323
324
325def acr_show_usage(cmd, client, registry_name, resource_group_name=None):
326    _, resource_group_name = validate_managed_registry(cmd,
327                                                       registry_name,
328                                                       resource_group_name,
329                                                       "Usage is only supported for managed registries.")
330    return client.list_usages(resource_group_name, registry_name)
331
332
333def get_docker_command(is_diagnostics_context=False):
334    from ._errors import DOCKER_COMMAND_ERROR, DOCKER_DAEMON_ERROR
335    docker_command = 'docker'
336
337    from subprocess import PIPE, Popen, CalledProcessError
338    try:
339        p = Popen([docker_command, "ps"], stdout=PIPE, stderr=PIPE)
340        _, stderr = p.communicate()
341    except OSError as e:
342        logger.debug("Could not run '%s' command. Exception: %s", docker_command, str(e))
343        # The executable may not be discoverable in WSL so retry *.exe once
344        try:
345            docker_command = 'docker.exe'
346            p = Popen([docker_command, "ps"], stdout=PIPE, stderr=PIPE)
347            _, stderr = p.communicate()
348        except OSError as inner:
349            logger.debug("Could not run '%s' command. Exception: %s", docker_command, str(inner))
350            if is_diagnostics_context:
351                return None, DOCKER_COMMAND_ERROR
352            raise CLIError(DOCKER_COMMAND_ERROR.get_error_message())
353        except CalledProcessError as inner:
354            logger.debug("Could not run '%s' command. Exception: %s", docker_command, str(inner))
355            if is_diagnostics_context:
356                return docker_command, DOCKER_DAEMON_ERROR
357            raise CLIError(DOCKER_DAEMON_ERROR.get_error_message())
358    except CalledProcessError as e:
359        logger.debug("Could not run '%s' command. Exception: %s", docker_command, str(e))
360        if is_diagnostics_context:
361            return docker_command, DOCKER_DAEMON_ERROR
362        raise CLIError(DOCKER_DAEMON_ERROR.get_error_message())
363
364    if stderr:
365        if is_diagnostics_context:
366            return None, DOCKER_COMMAND_ERROR.set_error_message(stderr.decode())
367        raise CLIError(DOCKER_COMMAND_ERROR.set_error_message(stderr.decode()).get_error_message())
368
369    return docker_command, None
370
371
372def _check_wincred(login_server):
373    import platform
374    if platform.system() == 'Windows':
375        import json
376        from os.path import expanduser, isfile, join
377        docker_directory = join(expanduser('~'), '.docker')
378        config_path = join(docker_directory, 'config.json')
379        logger.debug("Docker config file path %s", config_path)
380        if isfile(config_path):
381            with open(config_path) as input_file:
382                content = json.load(input_file)
383                input_file.close()
384            wincred = content.pop('credsStore', None)
385            if wincred and wincred.lower() == 'wincred':
386                # Ask for confirmation
387                from knack.prompting import prompt_y_n, NoTTYException
388                message = "This operation will disable wincred and use file system to store docker credentials." \
389                          " All registries that are currently logged in will be logged out." \
390                          "\nAre you sure you want to continue?"
391                try:
392                    if prompt_y_n(message):
393                        with open(config_path, 'w') as output_file:
394                            json.dump(content, output_file, indent=4)
395                            output_file.close()
396                        return True
397                    return False
398                except NoTTYException:
399                    return False
400            # Don't update config file or retry as this doesn't seem to be a wincred issue
401            return False
402
403        import os
404        content = {
405            "auths": {
406                login_server: {}
407            }
408        }
409        try:
410            os.makedirs(docker_directory)
411        except OSError as e:
412            logger.debug("Could not create docker directory '%s'. Exception: %s", docker_directory, str(e))
413        with open(config_path, 'w') as output_file:
414            json.dump(content, output_file, indent=4)
415            output_file.close()
416        return True
417
418    return False
419
420
421def _create_diagnostic_settings(cli_ctx, acr, workspace):
422    from azure.mgmt.monitor import MonitorManagementClient
423    from azure.mgmt.monitor.models import (DiagnosticSettingsResource, RetentionPolicy,
424                                           LogSettings, MetricSettings)
425    from azure.cli.core.commands.client_factory import get_mgmt_service_client
426
427    client = get_mgmt_service_client(cli_ctx, MonitorManagementClient)
428    def_retention_policy = RetentionPolicy(enabled=True, days=0)
429    logs = [
430        LogSettings(enabled=True, category="ContainerRegistryRepositoryEvents", retention_policy=def_retention_policy),
431        LogSettings(enabled=True, category="ContainerRegistryLoginEvents", retention_policy=def_retention_policy)
432    ]
433    metrics = [MetricSettings(enabled=True, category="AllMetrics", retention_policy=def_retention_policy)]
434    parameters = DiagnosticSettingsResource(workspace_id=workspace, metrics=metrics, logs=logs)
435
436    client.diagnostic_settings.create_or_update(resource_uri=acr.id, parameters=parameters,
437                                                name=DEF_DIAG_SETTINGS_NAME_TEMPLATE.format(acr.name))
438
439
440def _configure_cmk(cmd, registry, resource_group_name, identity, key_encryption_key):
441    from azure.cli.core.commands.client_factory import get_subscription_id
442
443    if bool(identity) != bool(key_encryption_key):
444        raise CLIError("Usage error: --identity and --key-encryption-key must be both supplied")
445
446    identity = _ensure_identity_resource_id(subscription_id=get_subscription_id(cmd.cli_ctx),
447                                            resource_group=resource_group_name,
448                                            resource=identity)
449
450    identity_client_id = resolve_identity_client_id(cmd.cli_ctx, identity)
451
452    KeyVaultProperties, EncryptionProperty = cmd.get_models('KeyVaultProperties', 'EncryptionProperty')
453    registry.encryption = EncryptionProperty(status='enabled', key_vault_properties=KeyVaultProperties(
454        key_identifier=key_encryption_key, identity=identity_client_id))
455
456    ResourceIdentityType, IdentityProperties = cmd.get_models('ResourceIdentityType', 'IdentityProperties')
457    registry.identity = IdentityProperties(type=ResourceIdentityType.user_assigned,
458                                           user_assigned_identities={identity: {}})
459
460
461def assign_identity(cmd, client, registry_name, identities, resource_group_name=None):
462    from azure.cli.core.commands.client_factory import get_subscription_id
463    assign_system_identity, assign_user_identities = _analyze_identities(identities)
464    registry, resource_group_name = get_registry_by_name(cmd.cli_ctx, registry_name, resource_group_name)
465
466    IdentityProperties, ResourceIdentityType = cmd.get_models('IdentityProperties', 'ResourceIdentityType')
467
468    # ensure registry.identity is set and is of type IdentityProperties
469    registry.identity = registry.identity or IdentityProperties(type=ResourceIdentityType.none)
470
471    if assign_system_identity and registry.identity.type != ResourceIdentityType.system_assigned:
472        registry.identity.type = (ResourceIdentityType.system_assigned
473                                  if registry.identity.type == ResourceIdentityType.none
474                                  else ResourceIdentityType.system_assigned_user_assigned)
475    if assign_user_identities and registry.identity.type != ResourceIdentityType.user_assigned:
476        registry.identity.type = (ResourceIdentityType.user_assigned
477                                  if registry.identity.type == ResourceIdentityType.none
478                                  else ResourceIdentityType.system_assigned_user_assigned)
479
480    if assign_user_identities:
481        subscription_id = get_subscription_id(cmd.cli_ctx)
482        registry.identity.user_assigned_identities = registry.identity.user_assigned_identities or {}
483
484        for r in assign_user_identities:
485            r = _ensure_identity_resource_id(subscription_id, resource_group_name, r)
486            registry.identity.user_assigned_identities[r] = {}
487
488    return client.begin_update(resource_group_name, registry_name, registry)
489
490
491def show_identity(cmd, client, registry_name, resource_group_name=None):
492    return acr_show(cmd, client, registry_name, resource_group_name).identity
493
494
495def remove_identity(cmd, client, registry_name, identities, resource_group_name=None):
496    from azure.cli.core.commands.client_factory import get_subscription_id
497    remove_system_identity, remove_user_identities = _analyze_identities(identities)
498    registry, resource_group_name = get_registry_by_name(cmd.cli_ctx, registry_name, resource_group_name)
499
500    ResourceIdentityType = cmd.get_models('ResourceIdentityType')
501
502    # if registry.identity is not set or is none, return the registry.
503    if not registry.identity or registry.identity.type == ResourceIdentityType.none:
504        raise CLIError("The registry {} has no system or user assigned identities.".format(registry_name))
505
506    if remove_system_identity:
507        if registry.identity.type.lower() == ResourceIdentityType.user_assigned.lower():
508            raise CLIError("The registry does not have a system identity assigned.")
509        registry.identity.type = (ResourceIdentityType.none
510                                  if registry.identity.type.lower() == ResourceIdentityType.system_assigned.lower()
511                                  else ResourceIdentityType.user_assigned)
512        # if we have no system assigned identitiy then set identity object to none
513        registry.identity.principal_id = None
514        registry.identity.tenant_id = None
515
516    if remove_user_identities:
517        subscription_id = get_subscription_id(cmd.cli_ctx)
518        registry.identity.user_assigned_identities = registry.identity.user_assigned_identities or {}
519
520        for id_to_remove in remove_user_identities:
521            original_identity = id_to_remove
522            was_removed = False
523
524            id_to_remove = _ensure_identity_resource_id(subscription_id, resource_group_name, id_to_remove)
525
526            # remove identity if it exists even if case is different
527            for existing_identity in registry.identity.user_assigned_identities.copy():
528                if existing_identity.lower() == id_to_remove.lower():
529                    registry.identity.user_assigned_identities.pop(existing_identity)
530                    was_removed = True
531                    break
532
533            if not was_removed:
534                raise CLIError("The registry does not have specified user identity '{}' assigned, "
535                               "so it cannot be removed.".format(original_identity))
536
537        # all user assigned identities are gone
538        if not registry.identity.user_assigned_identities:
539            registry.identity.user_assigned_identities = None  # required for put
540            registry.identity.type = (ResourceIdentityType.none
541                                      if registry.identity.type.lower() == ResourceIdentityType.user_assigned.lower()
542                                      else ResourceIdentityType.system_assigned)
543
544    # this method should be named create_or_update as it calls the PUT method
545    return client.begin_create(resource_group_name, registry_name, registry)
546
547
548def show_encryption(cmd, client, registry_name, resource_group_name=None):
549    return acr_show(cmd, client, registry_name, resource_group_name).encryption
550
551
552def rotate_key(cmd, client, registry_name, identity=None, key_encryption_key=None, resource_group_name=None):
553    registry, resource_group_name = get_registry_by_name(cmd.cli_ctx, registry_name, resource_group_name)
554    if not registry.encryption or not registry.encryption.key_vault_properties:
555        raise CLIError('usage error: key rotation is only applicable to registries with CMK enabled')
556    if key_encryption_key:
557        registry.encryption.key_vault_properties.key_identifier = key_encryption_key
558    if identity:
559        try:
560            import uuid
561            uuid.UUID(identity)
562            client_id = identity
563        except ValueError:
564            from azure.cli.core.commands.client_factory import get_subscription_id
565            if identity == SYSTEM_ASSIGNED_IDENTITY_ALIAS:
566                client_id = 'system'  # reserved word on ACR service
567            else:
568                identity = _ensure_identity_resource_id(subscription_id=get_subscription_id(cmd.cli_ctx),
569                                                        resource_group=resource_group_name,
570                                                        resource=identity)
571                client_id = resolve_identity_client_id(cmd.cli_ctx, identity)
572
573        registry.encryption.key_vault_properties.identity = client_id
574
575    return client.begin_update(resource_group_name, registry_name, registry)
576
577
578def _analyze_identities(identities):
579    identities = identities or []
580    return SYSTEM_ASSIGNED_IDENTITY_ALIAS in identities, [x for x in identities if x != SYSTEM_ASSIGNED_IDENTITY_ALIAS]
581
582
583def _ensure_identity_resource_id(subscription_id, resource_group, resource):
584    from msrestazure.tools import resource_id, is_valid_resource_id
585    if is_valid_resource_id(resource):
586        return resource
587    return resource_id(subscription=subscription_id,
588                       resource_group=resource_group,
589                       namespace='Microsoft.ManagedIdentity',
590                       type='userAssignedIdentities',
591                       name=resource)
592
593
594def list_private_link_resources(cmd, client, registry_name, resource_group_name=None):
595    resource_group_name = get_resource_group_name_by_registry_name(cmd.cli_ctx, registry_name, resource_group_name)
596    return client.list_private_link_resources(resource_group_name, registry_name)
597