1# -*- coding: utf-8 -*- #
2# Copyright 2014 Google LLC. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#    http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Command for adding a path matcher to a URL map."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import unicode_literals
21
22import collections
23from apitools.base.py import encoding
24
25from googlecloudsdk.api_lib.compute import base_classes
26from googlecloudsdk.calliope import arg_parsers
27from googlecloudsdk.calliope import base
28from googlecloudsdk.calliope import exceptions
29from googlecloudsdk.command_lib.compute import scope as compute_scope
30from googlecloudsdk.command_lib.compute.backend_buckets import (
31    flags as backend_bucket_flags)
32from googlecloudsdk.command_lib.compute.backend_services import (
33    flags as backend_service_flags)
34from googlecloudsdk.command_lib.compute.url_maps import flags
35from googlecloudsdk.command_lib.compute.url_maps import url_maps_utils
36from googlecloudsdk.core import properties
37import six
38
39
40def _DetailedHelp():
41  # pylint:disable=line-too-long
42  return {
43      'brief':
44          'Add a path matcher to a URL map.',
45      'DESCRIPTION': """
46*{command}* is used to add a path matcher to a URL map. A path
47matcher maps HTTP request paths to backend services or backend
48buckets. Each path matcher must be referenced by at least one
49host rule. This command can create a new host rule through the
50`--new-hosts` flag or it can reconfigure an existing host rule
51to point to the newly added path matcher using `--existing-host`.
52In the latter case, if a path matcher is orphaned as a result
53of the operation, this command will fail unless
54`--delete-orphaned-path-matcher` is provided. Path matcher
55constraints can be found
56[here](https://cloud.google.com/load-balancing/docs/url-map-concepts#pm-constraints).
57""",
58      'EXAMPLES': """
59To create a rule for mapping the path ```/search/*``` to the
60hypothetical ```search-service```, ```/static/*``` to the
61```static-bucket``` backend bucket and ```/images/*``` to the
62```images-service``` under the hosts ```example.com``` and
63```*.example.com```, run:
64
65  $ {command} MY-URL-MAP --path-matcher-name=MY-MATCHER --default-service=MY-DEFAULT-SERVICE --backend-service-path-rules='/search/*=search_service,/images/*=images-service' --backend-bucket-path-rules='/static/*=static-bucket' --new-hosts=example.com '*.example.com'
66
67Note that a default service or default backend bucket must be
68provided to handle paths for which there is no mapping.
69""",
70  }
71  # pylint:enable=line-too-long
72
73
74def _Args(parser):
75  """Common arguments to add-path-matcher commands for each release track."""
76  parser.add_argument(
77      '--description',
78      help='An optional, textual description for the path matcher.')
79
80  parser.add_argument(
81      '--path-matcher-name',
82      required=True,
83      help='The name to assign to the path matcher.')
84
85  parser.add_argument(
86      '--path-rules',
87      type=arg_parsers.ArgDict(min_length=1),
88      default={},
89      metavar='PATH=SERVICE',
90      help='Rules for mapping request paths to services.')
91
92  host_rule = parser.add_mutually_exclusive_group()
93  host_rule.add_argument(
94      '--new-hosts',
95      type=arg_parsers.ArgList(min_length=1),
96      metavar='NEW_HOST',
97      help=('If specified, a new host rule with the given hosts is created '
98            'and the path matcher is tied to the new host rule.'))
99
100  host_rule.add_argument(
101      '--existing-host',
102      help="""\
103      An existing host rule to tie the new path matcher to. Although
104      host rules can contain more than one host, only a single host
105      is needed to uniquely identify the host rule.
106      """)
107
108  parser.add_argument(
109      '--delete-orphaned-path-matcher',
110      action='store_true',
111      default=False,
112      help=('If provided and a path matcher is orphaned as a result of this '
113            'command, the command removes the orphaned path matcher instead '
114            'of failing.'))
115
116  group = parser.add_mutually_exclusive_group(required=True)
117  group.add_argument(
118      '--default-service',
119      help=('A backend service that will be used for requests that the path '
120            'matcher cannot match. Exactly one of --default-service or '
121            '--default-backend-bucket is required.'))
122  group.add_argument(
123      '--default-backend-bucket',
124      help=('A backend bucket that will be used for requests that the path '
125            'matcher cannot match. Exactly one of --default-service or '
126            '--default-backend-bucket is required.'))
127
128  parser.add_argument('--backend-service-path-rules',
129                      type=arg_parsers.ArgDict(min_length=1),
130                      default={},
131                      metavar='PATH=SERVICE',
132                      help='Rules for mapping request paths to services.')
133  parser.add_argument(
134      '--backend-bucket-path-rules',
135      type=arg_parsers.ArgDict(min_length=1),
136      default={},
137      metavar='PATH=BUCKET',
138      help='Rules for mapping request paths to backend buckets.')
139
140
141def _GetGetRequest(client, url_map_ref):
142  """Returns the request for the existing URL map resource."""
143  return (client.apitools_client.urlMaps, 'Get',
144          client.messages.ComputeUrlMapsGetRequest(
145              urlMap=url_map_ref.Name(), project=url_map_ref.project))
146
147
148def _GetSetRequest(client, url_map_ref, replacement):
149  return (client.apitools_client.urlMaps, 'Update',
150          client.messages.ComputeUrlMapsUpdateRequest(
151              urlMap=url_map_ref.Name(),
152              urlMapResource=replacement,
153              project=url_map_ref.project))
154
155
156def _ModifyBase(client, args, existing):
157  """Modifications to the URL map that are shared between release tracks.
158
159  Args:
160    client: The compute client.
161    args: the argparse arguments that this command was invoked with.
162    existing: the existing URL map message.
163
164  Returns:
165    A modified URL map message.
166  """
167  replacement = encoding.CopyProtoMessage(existing)
168
169  if not args.new_hosts and not args.existing_host:
170    new_hosts = ['*']
171  else:
172    new_hosts = args.new_hosts
173
174  # If --new-hosts is given, we check to make sure none of those
175  # hosts already exist and once the check succeeds, we create the
176  # new host rule.
177  if new_hosts:
178    new_hosts = set(new_hosts)
179    for host_rule in existing.hostRules:
180      for host in host_rule.hosts:
181        if host in new_hosts:
182          raise exceptions.ToolException(
183              'Cannot create a new host rule with host [{0}] because the '
184              'host is already part of a host rule that references the path '
185              'matcher [{1}].'.format(host, host_rule.pathMatcher))
186
187    replacement.hostRules.append(
188        client.messages.HostRule(
189            hosts=sorted(new_hosts), pathMatcher=args.path_matcher_name))
190
191  # If --existing-host is given, we check to make sure that the
192  # corresponding host rule will not render a patch matcher
193  # orphan. If the check succeeds, we change the path matcher of the
194  # host rule. If the check fails, we remove the path matcher if
195  # --delete-orphaned-path-matcher is given otherwise we fail.
196  else:
197    target_host_rule = None
198    for host_rule in existing.hostRules:
199      for host in host_rule.hosts:
200        if host == args.existing_host:
201          target_host_rule = host_rule
202          break
203      if target_host_rule:
204        break
205
206    if not target_host_rule:
207      raise exceptions.ToolException(
208          'No host rule with host [{0}] exists. Check your spelling or '
209          'use [--new-hosts] to create a new host rule.'.format(
210              args.existing_host))
211
212    path_matcher_orphaned = True
213    for host_rule in replacement.hostRules:
214      if host_rule == target_host_rule:
215        host_rule.pathMatcher = args.path_matcher_name
216        continue
217
218      if host_rule.pathMatcher == target_host_rule.pathMatcher:
219        path_matcher_orphaned = False
220        break
221
222    if path_matcher_orphaned:
223      # A path matcher will be orphaned, so now we determine whether
224      # we should delete the path matcher or report an error.
225      if args.delete_orphaned_path_matcher:
226        replacement.pathMatchers = [
227            path_matcher for path_matcher in existing.pathMatchers
228            if path_matcher.name != target_host_rule.pathMatcher
229        ]
230      else:
231        raise exceptions.ToolException(
232            'This operation will orphan the path matcher [{0}]. To '
233            'delete the orphan path matcher, rerun this command with '
234            '[--delete-orphaned-path-matcher] or use [gcloud compute '
235            'url-maps edit] to modify the URL map by hand.'.format(
236                host_rule.pathMatcher))
237
238  return replacement
239
240
241def _Modify(client, resources, args, url_map, url_map_ref, backend_service_arg,
242            backend_bucket_arg):
243  """Returns a modified URL map message."""
244  replacement = _ModifyBase(client, args, url_map)
245
246  # Creates PathRule objects from --path-rules, --backend-service-path-rules,
247  # and --backend-bucket-path-rules.
248  service_map = collections.defaultdict(set)
249  bucket_map = collections.defaultdict(set)
250  for path, service in six.iteritems(args.path_rules):
251    service_map[service].add(path)
252  for path, service in six.iteritems(args.backend_service_path_rules):
253    service_map[service].add(path)
254  for path, bucket in six.iteritems(args.backend_bucket_path_rules):
255    bucket_map[bucket].add(path)
256  path_rules = []
257  for service, paths in sorted(six.iteritems(service_map)):
258    path_rules.append(
259        client.messages.PathRule(
260            paths=sorted(paths),
261            service=resources.Parse(
262                service,
263                params=_GetBackendServiceParamsForUrlMap(url_map, url_map_ref),
264                collection=_GetBackendServiceCollectionForUrlMap(
265                    url_map)).SelfLink()))
266  for bucket, paths in sorted(six.iteritems(bucket_map)):
267    path_rules.append(
268        client.messages.PathRule(
269            paths=sorted(paths),
270            service=resources.Parse(
271                bucket,
272                params={
273                    'project': properties.VALUES.core.project.GetOrFail
274                },
275                collection='compute.backendBuckets').SelfLink()))
276
277  if args.default_service:
278    default_backend_uri = url_maps_utils.ResolveUrlMapDefaultService(
279        args, backend_service_arg, url_map_ref, resources).SelfLink()
280  else:
281    default_backend_uri = backend_bucket_arg.ResolveAsResource(
282        args, resources).SelfLink()
283
284  new_path_matcher = client.messages.PathMatcher(
285      defaultService=default_backend_uri,
286      description=args.description,
287      name=args.path_matcher_name,
288      pathRules=path_rules)
289
290  replacement.pathMatchers.append(new_path_matcher)
291  return replacement
292
293
294def _GetRegionalGetRequest(client, url_map_ref):
295  """Returns the request to get an existing regional URL map resource."""
296  return (client.apitools_client.regionUrlMaps, 'Get',
297          client.messages.ComputeRegionUrlMapsGetRequest(
298              urlMap=url_map_ref.Name(),
299              project=url_map_ref.project,
300              region=url_map_ref.region))
301
302
303def _GetRegionalSetRequest(client, url_map_ref, replacement):
304  """Returns the request to update an existing regional URL map resource."""
305  return (client.apitools_client.regionUrlMaps, 'Update',
306          client.messages.ComputeRegionUrlMapsUpdateRequest(
307              urlMap=url_map_ref.Name(),
308              urlMapResource=replacement,
309              project=url_map_ref.project,
310              region=url_map_ref.region))
311
312
313def _GetBackendServiceParamsForUrlMap(url_map, url_map_ref):
314  params = {'project': properties.VALUES.core.project.GetOrFail}
315  if hasattr(url_map, 'region') and url_map.region:
316    params['region'] = url_map_ref.region
317
318  return params
319
320
321def _GetBackendServiceCollectionForUrlMap(url_map):
322  if hasattr(url_map, 'region') and url_map.region:
323    return 'compute.regionBackendServices'
324  else:
325    return 'compute.backendServices'
326
327
328def _Run(args, holder, url_map_arg, backend_servie_arg, backend_bucket_arg):
329  """Issues requests necessary to add path matcher to the Url Map."""
330  client = holder.client
331
332  url_map_ref = url_map_arg.ResolveAsResource(
333      args, holder.resources, default_scope=compute_scope.ScopeEnum.GLOBAL)
334  if url_maps_utils.IsRegionalUrlMapRef(url_map_ref):
335    get_request = _GetRegionalGetRequest(client, url_map_ref)
336  else:
337    get_request = _GetGetRequest(client, url_map_ref)
338
339  url_map = client.MakeRequests([get_request])[0]
340
341  modified_url_map = _Modify(client, holder.resources, args, url_map,
342                             url_map_ref, backend_servie_arg,
343                             backend_bucket_arg)
344
345  if url_maps_utils.IsRegionalUrlMapRef(url_map_ref):
346    set_request = _GetRegionalSetRequest(client, url_map_ref, modified_url_map)
347  else:
348    set_request = _GetSetRequest(client, url_map_ref, modified_url_map)
349
350  return client.MakeRequests([set_request])
351
352
353@base.ReleaseTracks(base.ReleaseTrack.ALPHA, base.ReleaseTrack.BETA,
354                    base.ReleaseTrack.GA)
355class AddPathMatcher(base.UpdateCommand):
356  """Add a path matcher to a URL map."""
357
358  # TODO(b/144022508): Remove _include_l7_internal_load_balancing
359  _include_l7_internal_load_balancing = True
360
361  detailed_help = _DetailedHelp()
362  BACKEND_SERVICE_ARG = None
363  BACKEND_BUCKET_ARG = None
364  URL_MAP_ARG = None
365
366  @classmethod
367  def Args(cls, parser):
368    cls.BACKEND_BUCKET_ARG = (
369        backend_bucket_flags.BackendBucketArgumentForUrlMap())
370    cls.BACKEND_SERVICE_ARG = (
371        backend_service_flags.BackendServiceArgumentForUrlMap(
372            include_l7_internal_load_balancing=cls
373            ._include_l7_internal_load_balancing))
374    cls.URL_MAP_ARG = flags.UrlMapArgument(
375        include_l7_internal_load_balancing=cls
376        ._include_l7_internal_load_balancing)
377    cls.URL_MAP_ARG.AddArgument(parser)
378
379    _Args(parser)
380
381  def Run(self, args):
382    holder = base_classes.ComputeApiHolder(self.ReleaseTrack())
383    return _Run(args, holder, self.URL_MAP_ARG, self.BACKEND_SERVICE_ARG,
384                self.BACKEND_BUCKET_ARG)
385