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# --------------------------------------------------------------------------------------------
5import os
6import uuid
7import tempfile
8
9from knack.util import CLIError
10from knack.log import get_logger
11
12from azure.cli.core.commands import LongRunningOperation
13from azure.cli.core.commands.parameters import get_resources_in_subscription
14from azure.core.exceptions import ResourceNotFoundError
15
16from ._constants import (
17    REGISTRY_RESOURCE_TYPE,
18    TASK_RESOURCE_ID_TEMPLATE,
19    ACR_TASK_YAML_DEFAULT_NAME,
20    get_classic_sku,
21    get_managed_sku,
22    get_premium_sku,
23    get_valid_os,
24    get_valid_architecture,
25    get_valid_variant,
26    ACR_NULL_CONTEXT
27)
28from ._client_factory import cf_acr_registries
29
30from ._archive_utils import upload_source_code, check_remote_source_code
31
32logger = get_logger(__name__)
33
34
35def _arm_get_resource_by_name(cli_ctx, resource_name, resource_type):
36    """Returns the ARM resource in the current subscription with resource_name.
37    :param str resource_name: The name of resource
38    :param str resource_type: The type of resource
39    """
40    result = get_resources_in_subscription(cli_ctx, resource_type)
41    elements = [item for item in result if item.name.lower() ==
42                resource_name.lower()]
43
44    if not elements:
45        from azure.cli.core._profile import Profile
46        profile = Profile(cli_ctx=cli_ctx)
47        message = "The resource with name '{}' and type '{}' could not be found".format(
48            resource_name, resource_type)
49        try:
50            subscription = profile.get_subscription(
51                cli_ctx.data['subscription_id'])
52            raise ResourceNotFound(
53                "{} in subscription '{} ({})'.".format(message, subscription['name'], subscription['id']))
54        except (KeyError, TypeError) as e:
55            logger.debug(
56                "Could not get the current subscription. Exception: %s", str(e))
57            raise ResourceNotFound(
58                "{} in the current subscription.".format(message))
59
60    elif len(elements) == 1:
61        return elements[0]
62    else:
63        raise CLIError(
64            "More than one resources with type '{}' are found with name '{}'.".format(
65                resource_type, resource_name))
66
67
68def _get_resource_group_name_by_resource_id(resource_id):
69    """Returns the resource group name from parsing the resource id.
70    :param str resource_id: The resource id
71    """
72    resource_id = resource_id.lower()
73    resource_group_keyword = '/resourcegroups/'
74    return resource_id[resource_id.index(resource_group_keyword) + len(
75        resource_group_keyword): resource_id.index('/providers/')]
76
77
78def get_resource_group_name_by_registry_name(cli_ctx, registry_name,
79                                             resource_group_name=None):
80    """Returns the resource group name for the container registry.
81    :param str registry_name: The name of container registry
82    :param str resource_group_name: The name of resource group
83    """
84    if not resource_group_name:
85        arm_resource = _arm_get_resource_by_name(
86            cli_ctx, registry_name, REGISTRY_RESOURCE_TYPE)
87        resource_group_name = _get_resource_group_name_by_resource_id(
88            arm_resource.id)
89    return resource_group_name
90
91
92def get_resource_id_by_registry_name(cli_ctx, registry_name):
93    """Returns the resource id for the container registry.
94    :param str storage_account_name: The name of container registry
95    """
96    arm_resource = _arm_get_resource_by_name(
97        cli_ctx, registry_name, REGISTRY_RESOURCE_TYPE)
98    return arm_resource.id
99
100
101def get_registry_by_name(cli_ctx, registry_name, resource_group_name=None):
102    """Returns a tuple of Registry object and resource group name.
103    :param str registry_name: The name of container registry
104    :param str resource_group_name: The name of resource group
105    """
106    resource_group_name = get_resource_group_name_by_registry_name(
107        cli_ctx, registry_name, resource_group_name)
108    client = cf_acr_registries(cli_ctx)
109
110    return client.get(resource_group_name, registry_name), resource_group_name
111
112
113def get_registry_from_name_or_login_server(cli_ctx, login_server, registry_name=None):
114    """Returns a Registry object for the specified name.
115    :param str name: either the registry name or the login server of the registry.
116    """
117    client = cf_acr_registries(cli_ctx)
118    registry_list = client.list()
119
120    if registry_name:
121        elements = [item for item in registry_list if
122                    item.login_server.lower() == login_server.lower() or item.name.lower() == registry_name.lower()]
123    else:
124        elements = [item for item in registry_list if
125                    item.login_server.lower() == login_server.lower()]
126
127    if len(elements) == 1:
128        return elements[0]
129    if len(elements) > 1:
130        logger.warning(
131            "More than one registries were found by %s.", login_server)
132    return None
133
134
135def validate_managed_registry(cmd, registry_name, resource_group_name=None, message=None):
136    """Raise CLIError if the registry in not in Managed SKU.
137    :param str registry_name: The name of container registry
138    :param str resource_group_name: The name of resource group
139    """
140    registry, resource_group_name = get_registry_by_name(
141        cmd.cli_ctx, registry_name, resource_group_name)
142
143    if not registry.sku or registry.sku.name not in get_managed_sku(cmd):
144        raise CLIError(
145            message or "This operation is only supported for managed registries.")
146
147    return registry, resource_group_name
148
149
150def validate_premium_registry(cmd, registry_name, resource_group_name=None, message=None):
151    """Raise CLIError if the registry in not in Premium SKU.
152    :param str registry_name: The name of container registry
153    :param str resource_group_name: The name of resource group
154    """
155    registry, resource_group_name = get_registry_by_name(
156        cmd.cli_ctx, registry_name, resource_group_name)
157
158    if not registry.sku or registry.sku.name not in get_premium_sku(cmd):
159        raise CLIError(
160            message or "This operation is only supported for managed registries in Premium SKU.")
161
162    return registry, resource_group_name
163
164
165def validate_sku_update(cmd, current_sku, sku_parameter):
166    """Validates a registry SKU update parameter.
167    :param object sku_parameter: The registry SKU update parameter
168    """
169    if sku_parameter is None:
170        return
171
172    Sku = cmd.get_models('Sku')
173    if isinstance(sku_parameter, dict):
174        if 'name' not in sku_parameter:
175            _invalid_sku_update(cmd)
176        if sku_parameter['name'] not in get_classic_sku(cmd) and sku_parameter['name'] not in get_managed_sku(cmd):
177            _invalid_sku_update(cmd)
178        if current_sku in get_managed_sku(cmd) and sku_parameter['name'] in get_classic_sku(cmd):
179            _invalid_sku_downgrade()
180    elif isinstance(sku_parameter, Sku):
181        if current_sku in get_managed_sku(cmd) and sku_parameter.name in get_classic_sku(cmd):
182            _invalid_sku_downgrade()
183    else:
184        _invalid_sku_update(cmd)
185
186
187def _invalid_sku_update(cmd):
188    raise CLIError("Please specify SKU by '--sku SKU' or '--set sku.name=SKU'. Allowed SKUs: {0}".format(
189        get_managed_sku(cmd)))
190
191
192def _invalid_sku_downgrade():
193    raise CLIError(
194        "Managed registries could not be downgraded to Classic SKU.")
195
196
197def get_validate_platform(cmd, platform):
198    """Gets and validates the Platform from both flags
199    :param str platform: The name of Platform passed by user in --platform flag
200    """
201    OS, Architecture = cmd.get_models('OS', 'Architecture', operation_group='runs')
202
203    # Defaults
204    platform_os = OS.linux.value
205    platform_arch = Architecture.amd64.value
206    platform_variant = None
207
208    if platform:
209        platform_split = platform.split('/')
210        platform_os = platform_split[0]
211        platform_arch = platform_split[1] if len(platform_split) > 1 else Architecture.amd64.value
212        platform_variant = platform_split[2] if len(platform_split) > 2 else None
213
214    platform_os = platform_os.lower()
215    platform_arch = platform_arch.lower()
216
217    valid_os = get_valid_os(cmd)
218    valid_arch = get_valid_architecture(cmd)
219    valid_variant = get_valid_variant(cmd)
220
221    if platform_os not in valid_os:
222        raise CLIError(
223            "'{0}' is not a valid value for OS specified in --platform. "
224            "Valid options are {1}.".format(platform_os, ','.join(valid_os))
225        )
226    if platform_arch not in valid_arch:
227        raise CLIError(
228            "'{0}' is not a valid value for Architecture specified in --platform. "
229            "Valid options are {1}.".format(
230                platform_arch, ','.join(valid_arch))
231        )
232    if platform_variant and (platform_variant not in valid_variant):
233        raise CLIError(
234            "'{0}' is not a valid value for Variant specified in --platform. "
235            "Valid options are {1}.".format(
236                platform_variant, ','.join(valid_variant))
237        )
238
239    return platform_os, platform_arch, platform_variant
240
241
242def get_yaml_template(cmd_value, timeout, file):
243    """Generates yaml template
244    :param str cmd_value: The command to execute in each step. Task version defaults to v1.1.0
245    :param str timeout: The timeout for each step
246    :param str file: The task definition
247    """
248    yaml_template = "version: v1.1.0\n"
249    if cmd_value:
250        yaml_template += "steps: \n  - cmd: {0}\n    disableWorkingDirectoryOverride: true\n".format(cmd_value)
251        if timeout:
252            yaml_template += "    timeout: {0}\n".format(timeout)
253    else:
254        if not file:
255            file = ACR_TASK_YAML_DEFAULT_NAME
256
257        if file == "-":
258            import sys
259            for s in sys.stdin.readlines():
260                yaml_template += s
261        else:
262            if os.path.exists(file):
263                f = open(file, 'r')
264                for line in f:
265                    yaml_template += line
266            else:
267                raise CLIError("{0} does not exist.".format(file))
268
269    if not yaml_template:
270        raise CLIError("Failed to initialize yaml template.")
271
272    return yaml_template
273
274
275def get_custom_registry_credentials(cmd,
276                                    auth_mode=None,
277                                    login_server=None,
278                                    username=None,
279                                    password=None,
280                                    identity=None,
281                                    is_remove=False):
282    """Get the credential object from the input
283    :param str auth_mode: The login mode for the source registry
284    :param str login_server: The login server of custom registry
285    :param str username: The username for custom registry (plain text or a key vault secret URI)
286    :param str password: The password for custom registry (plain text or a key vault secret URI)
287    :param str identity: The task managed identity used for the credential
288    """
289    Credentials, CustomRegistryCredentials, SourceRegistryCredentials, SecretObject, \
290        SecretObjectType = cmd.get_models(
291            'Credentials', 'CustomRegistryCredentials', 'SourceRegistryCredentials', 'SecretObject',
292            'SecretObjectType',
293            operation_group='tasks')
294
295    source_registry_credentials = None
296    if auth_mode:
297        source_registry_credentials = SourceRegistryCredentials(
298            login_mode=auth_mode)
299
300    custom_registries = None
301    if login_server:
302        # if null username and password (or identity), then remove the credential
303        custom_reg_credential = None
304
305        is_identity_credential = False
306        if not username and not password:
307            is_identity_credential = identity is not None
308
309        if not is_remove:
310            if is_identity_credential:
311                custom_reg_credential = CustomRegistryCredentials(
312                    identity=identity
313                )
314            else:
315                custom_reg_credential = CustomRegistryCredentials(
316                    user_name=SecretObject(
317                        type=SecretObjectType.vaultsecret if is_vault_secret(
318                            cmd, username)else SecretObjectType.opaque,
319                        value=username
320                    ),
321                    password=SecretObject(
322                        type=SecretObjectType.vaultsecret if is_vault_secret(
323                            cmd, password) else SecretObjectType.opaque,
324                        value=password
325                    ),
326                    identity=identity
327                )
328
329        custom_registries = {login_server: custom_reg_credential}
330
331    return Credentials(
332        source_registry=source_registry_credentials,
333        custom_registries=custom_registries
334    )
335
336
337def build_timers_info(cmd, schedules):
338    timer_triggers = []
339    TriggerStatus, TimerTrigger = cmd.get_models('TriggerStatus', 'TimerTrigger', operation_group='tasks')
340
341    # Provide a default name for the timer if no name was provided.
342    for index, schedule in enumerate(schedules, start=1):
343        split_schedule = None
344        if ':' in schedule:
345            split_schedule = schedule.split(":")
346        timer_triggers.append(
347            TimerTrigger(
348                name=(split_schedule[0] if split_schedule else "t" + str(index)).strip(),
349                status=TriggerStatus.enabled.value,
350                schedule=split_schedule[1] if split_schedule else schedule
351            ))
352    return timer_triggers
353
354
355def remove_timer_trigger(task_name,
356                         timer_name,
357                         timer_triggers):
358    """Remove the timer trigger from the list of existing timer triggers for a task.
359    :param str task_name: The name of the task
360    :param str timer_name: The name of the timer trigger to be removed
361    :param str timer_triggers: The list of existing timer_triggers for a task
362    """
363
364    if not timer_triggers:
365        raise CLIError("No timer triggers exist for the task '{}'.".format(task_name))
366
367    # Check that the timer trigger exists in the list and if not exit
368    if any(timer.name == timer_name for timer in timer_triggers):
369        for timer in timer_triggers:
370            if timer.name == timer_name:
371                timer_triggers.remove(timer)
372    else:
373        raise CLIError("The timer '{}' does not exist for the task '{}'.".format(timer_name, task_name))
374
375    return timer_triggers
376
377
378def add_days_to_now(days):
379    if days <= 0:
380        raise CLIError('Days must be positive.')
381    from datetime import datetime, timedelta
382    try:
383        return datetime.utcnow() + timedelta(days=days)
384    except OverflowError:
385        return datetime.max
386
387
388def is_vault_secret(cmd, credential):
389    keyvault_dns = None
390    try:
391        keyvault_dns = cmd.cli_ctx.cloud.suffixes.keyvault_dns
392    except ResourceNotFound:
393        return False
394    return keyvault_dns.upper() in credential.upper()
395
396
397def get_task_id_from_task_name(cli_ctx, resource_group, registry_name, task_name):
398    from azure.cli.core.commands.client_factory import get_subscription_id
399    subscription_id = get_subscription_id(cli_ctx)
400    return TASK_RESOURCE_ID_TEMPLATE.format(
401        sub_id=subscription_id,
402        rg=resource_group,
403        reg=registry_name,
404        name=task_name
405    )
406
407
408def prepare_source_location(cmd, source_location, client_registries, registry_name, resource_group_name):
409    if not source_location or source_location.lower() == ACR_NULL_CONTEXT:
410        source_location = None
411    elif os.path.exists(source_location):
412        if not os.path.isdir(source_location):
413            raise CLIError(
414                "Source location should be a local directory path or remote URL.")
415
416        tar_file_path = os.path.join(tempfile.gettempdir(
417        ), 'cli_source_archive_{}.tar.gz'.format(uuid.uuid4().hex))
418
419        try:
420            source_location = upload_source_code(
421                cmd, client_registries, registry_name, resource_group_name,
422                source_location, tar_file_path, "", "")
423        except Exception as err:
424            raise CLIError(err)
425        finally:
426            try:
427                logger.debug(
428                    "Deleting the archived source code from '%s'...", tar_file_path)
429                os.remove(tar_file_path)
430            except OSError:
431                pass
432    else:
433        source_location = check_remote_source_code(source_location)
434        logger.warning("Sending context to registry: %s...", registry_name)
435
436    return source_location
437
438
439class ResourceNotFound(CLIError):
440    """For exceptions that a resource couldn't be found in user's subscription
441    """
442
443
444# Scope & Tokens help functions
445def parse_repositories_from_actions(actions):
446    if not actions:
447        return []
448    from .scope_map import RepoScopeMapActions
449    valid_actions = {action.value for action in RepoScopeMapActions}
450    repositories = []
451    REPOSITORY = 'repositories/'
452    for action in actions:
453        if action.startswith(REPOSITORY):
454            for rule in valid_actions:
455                if action.endswith(rule):
456                    repo = action[len(REPOSITORY):-len(rule) - 1]
457                    repositories.append(repo)
458    return list(set(repositories))
459
460
461def parse_scope_map_actions(repository_actions_list=None, gateway_actions_list=None):
462    from .scope_map import RepoScopeMapActions, GatewayScopeMapActions
463    valid_actions = {action.value for action in RepoScopeMapActions}
464    actions = _parse_scope_map_actions(repository_actions_list, valid_actions, 'repositories')
465    valid_actions = {action.value for action in GatewayScopeMapActions}
466    actions.extend(_parse_scope_map_actions(gateway_actions_list, valid_actions, 'gateway'))
467    return actions
468
469
470def _parse_scope_map_actions(actions_list, valid_actions, action_prefix):
471    if not actions_list:
472        return []
473    actions = []
474    for rule in actions_list:
475        resource = rule[0].lower()
476        if len(rule) < 2:
477            raise CLIError('At least one action must be specified with "{}".'.format(resource))
478        for action in rule[1:]:
479            action = action.lower()
480            if action not in valid_actions:
481                raise CLIError('Invalid action "{}" provided. \nValid actions are {}.'.format(action, valid_actions))
482            actions.append('{}/{}/{}'.format(action_prefix, resource, action))
483    return actions
484
485
486def create_default_scope_map(cmd,
487                             resource_group_name,
488                             registry_name,
489                             scope_map_name,
490                             repositories,
491                             gateways,
492                             scope_map_description="",
493                             force=False):
494    from ._client_factory import cf_acr_scope_maps
495    scope_map_client = cf_acr_scope_maps(cmd.cli_ctx)
496    actions = parse_scope_map_actions(repositories, gateways)
497    try:
498        existing_scope_map = scope_map_client.get(resource_group_name, registry_name, scope_map_name)
499        # for command idempotency, if the actions are the same, we accept it
500        if sorted(existing_scope_map.actions) == sorted(actions):
501            return existing_scope_map
502        if force and existing_scope_map:
503            logger.warning("Overriding scope map '%s' properties", scope_map_name)
504        else:
505            raise CLIError('The default scope map was already configured with different repository permissions.' +
506                           '\nPlease use "az acr scope-map update -r {} -n {} --add <REPO> --remove <REPO>" to update.'
507                           .format(registry_name, scope_map_name))
508    except ResourceNotFoundError:
509        pass
510    logger.info('Creating a scope map "%s" for provided permissions.', scope_map_name)
511    scope_map_request = {
512        'actions': actions,
513        'scope_map_description': scope_map_description
514    }
515    poller = scope_map_client.begin_create(resource_group_name, registry_name, scope_map_name, scope_map_request)
516    scope_map = LongRunningOperation(cmd.cli_ctx)(poller)
517    return scope_map
518
519
520def build_token_id(subscription_id, resource_group_name, registry_name, token_name):
521    return "/subscriptions/{}/resourceGroups/{}".format(subscription_id, resource_group_name) + \
522        "/providers/Microsoft.ContainerRegistry/registries/{}/tokens/{}".format(registry_name, token_name)
523
524
525def get_token_from_id(cmd, token_id):
526    from ._client_factory import cf_acr_tokens
527    from .token import acr_token_show
528    token_client = cf_acr_tokens(cmd.cli_ctx)
529    # SCOPE MAP ID example
530    # /subscriptions/<1>/resourceGroups/<3>/providers/Microsoft.ContainerRegistry/registries/<7>/tokens/<9>'
531    token_info = token_id.lstrip('/').split('/')
532    if len(token_info) != 10:
533        raise CLIError("Not valid scope map id: {}".format(token_id))
534    resource_group_name = token_info[3]
535    registry_name = token_info[7]
536    token_name = token_info[9]
537    return acr_token_show(cmd, token_client, registry_name, token_name, resource_group_name)
538
539
540def get_scope_map_from_id(cmd, scope_map_id):
541    from ._client_factory import cf_acr_scope_maps
542    from .scope_map import acr_scope_map_show
543    scope_map_client = cf_acr_scope_maps(cmd.cli_ctx)
544    # SCOPE MAP ID example
545    # /subscriptions/<1>/resourceGroups/<3>/providers/Microsoft.ContainerRegistry/registries/<7>/scopeMaps/<9>'
546    scope_info = scope_map_id.lstrip('/').split('/')
547    if len(scope_info) != 10:
548        raise CLIError("Not valid scope map id: {}".format(scope_map_id))
549    resource_group_name = scope_info[3]
550    registry_name = scope_info[7]
551    scope_map_name = scope_info[9]
552    return acr_scope_map_show(cmd, scope_map_client, registry_name, scope_map_name, resource_group_name)
553# endregion
554
555
556def resolve_identity_client_id(cli_ctx, managed_identity_resource_id):
557    from azure.mgmt.msi import ManagedServiceIdentityClient
558    from azure.cli.core.commands.client_factory import get_mgmt_service_client
559    from msrestazure.tools import parse_resource_id
560
561    res = parse_resource_id(managed_identity_resource_id)
562    client = get_mgmt_service_client(cli_ctx, ManagedServiceIdentityClient, subscription_id=res['subscription'])
563    return client.user_assigned_identities.get(res['resource_group'], res['name']).client_id
564
565
566def get_task_details_by_name(cli_ctx, resource_group_name, registry_name, task_name):
567    """Returns the task details.
568    :param str resource_group_name: The name of resource group
569    :param str registry_name: The name of container registry
570    :param str task_name: The name of task
571    """
572    from ._client_factory import cf_acr_tasks
573    client = cf_acr_tasks(cli_ctx)
574    return client.get_details(resource_group_name, registry_name, task_name)
575