1# -*- coding: utf-8 -*- #
2# Copyright 2018 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"""Shared resource flags for Cloud Run commands."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import print_function
21from __future__ import unicode_literals
22
23import abc
24import os
25import re
26
27from googlecloudsdk.api_lib.run import global_methods
28from googlecloudsdk.calliope.concepts import concepts
29from googlecloudsdk.calliope.concepts import deps
30from googlecloudsdk.command_lib.run import exceptions
31from googlecloudsdk.command_lib.run import platforms
32from googlecloudsdk.command_lib.util.concepts import presentation_specs
33from googlecloudsdk.core import log
34from googlecloudsdk.core import properties
35from googlecloudsdk.core import resources
36from googlecloudsdk.core.console import console_io
37
38
39class PromptFallthrough(deps.Fallthrough):
40  """Fall through to reading from an interactive prompt."""
41
42  def __init__(self, hint):
43    super(PromptFallthrough, self).__init__(function=None, hint=hint)
44
45  @abc.abstractmethod
46  def _Prompt(self, parsed_args):
47    pass
48
49  def _Call(self, parsed_args):
50    if not console_io.CanPrompt():
51      return None
52    return self._Prompt(parsed_args)
53
54
55def GenerateServiceName(image):
56  """Produce a valid default service name.
57
58  Converts a file path or image path into a reasonable default service name by
59  stripping file path delimeters, image tags, and image hashes.
60  For example, the image name 'gcr.io/myproject/myimage:latest' would produce
61  the service name 'myimage'.
62
63  Args:
64    image: str, The container path.
65
66  Returns:
67    A valid Cloud Run service name.
68  """
69  base_name = os.path.basename(image.rstrip(os.sep))
70  base_name = base_name.split(':')[0]  # Discard image tag if present.
71  base_name = base_name.split('@')[0]  # Disacard image hash if present.
72  # Remove non-supported special characters.
73  return re.sub(r'[^a-zA-Z0-9-]', '', base_name).strip('-').lower()
74
75
76class ResourcePromptFallthrough(PromptFallthrough):
77  """Fall through to reading the resource name from an interactive prompt."""
78
79  def __init__(self, resource_type_lower):
80    super(ResourcePromptFallthrough, self).__init__(
81        'specify the {} name from an interactive prompt'.format(
82            resource_type_lower))
83    self.resource_type_lower = resource_type_lower
84
85  def _Prompt(self, parsed_args):
86    image = None
87    if hasattr(parsed_args, 'image'):
88      image = parsed_args.image
89    message = self.resource_type_lower.capitalize() + ' name'
90    if image:
91      default_name = GenerateServiceName(image)
92      service_name = console_io.PromptWithDefault(
93          message=message, default=default_name)
94    else:
95      service_name = console_io.PromptResponse(message='{}: '.format(message))
96    return service_name
97
98
99class ServicePromptFallthrough(ResourcePromptFallthrough):
100
101  def __init__(self):
102    super(ServicePromptFallthrough, self).__init__('service')
103
104
105class JobPromptFallthrough(ResourcePromptFallthrough):
106
107  def __init__(self):
108    super(JobPromptFallthrough, self).__init__('job')
109
110
111class DefaultFallthrough(deps.Fallthrough):
112  """Use the namespace "default".
113
114  For Knative only.
115
116  For Cloud Run, raises an ArgumentError if project not set.
117  """
118
119  def __init__(self):
120    super(DefaultFallthrough, self).__init__(
121        function=None,
122        hint='For Cloud Run on Kubernetes Engine, defaults to "default". '
123        'Otherwise, defaults to project ID.')
124
125  def _Call(self, parsed_args):
126    if (platforms.GetPlatform() == platforms.PLATFORM_GKE or
127        platforms.GetPlatform() == platforms.PLATFORM_KUBERNETES):
128      return 'default'
129    elif not (getattr(parsed_args, 'project', None) or
130              properties.VALUES.core.project.Get()):
131      # HACK: Compensate for how "namespace" is actually "project" in Cloud Run
132      # by providing an error message explicitly early here.
133      raise exceptions.ArgumentError(
134          'The [project] resource is not properly specified. '
135          'Please specify the argument [--project] on the command line or '
136          'set the property [core/project].')
137    return None
138
139
140def NamespaceAttributeConfig():
141  return concepts.ResourceParameterAttributeConfig(
142      name='namespace',
143      help_text='Specific to Cloud Run for Anthos: '
144      'Kubernetes namespace for the {resource}.',
145      fallthroughs=[
146          deps.PropertyFallthrough(properties.VALUES.run.namespace),
147          DefaultFallthrough(),
148          deps.ArgFallthrough('project'),
149          deps.PropertyFallthrough(properties.VALUES.core.project),
150      ])
151
152
153def ServiceAttributeConfig(prompt=False):
154  """Attribute config with fallthrough prompt only if requested."""
155  if prompt:
156    fallthroughs = [ServicePromptFallthrough()]
157  else:
158    fallthroughs = []
159  return concepts.ResourceParameterAttributeConfig(
160      name='service',
161      help_text='Service for the {resource}.',
162      fallthroughs=fallthroughs)
163
164
165def ConfigurationAttributeConfig():
166  return concepts.ResourceParameterAttributeConfig(
167      name='configuration',
168      help_text='Configuration for the {resource}.')
169
170
171def RouteAttributeConfig():
172  return concepts.ResourceParameterAttributeConfig(
173      name='route',
174      help_text='Route for the {resource}.')
175
176
177def RevisionAttributeConfig():
178  return concepts.ResourceParameterAttributeConfig(
179      name='revision',
180      help_text='Revision for the {resource}.')
181
182
183def DomainAttributeConfig():
184  return concepts.ResourceParameterAttributeConfig(
185      name='domain',
186      help_text='Name of the domain to be mapped to.')
187
188
189def JobAttributeConfig(prompt=False):
190  if prompt:
191    fallthroughs = [JobPromptFallthrough()]
192  else:
193    fallthroughs = []
194  return concepts.ResourceParameterAttributeConfig(
195      name='jobs',
196      help_text='Job for the {resource}.',
197      fallthroughs=fallthroughs)
198
199
200class ClusterPromptFallthrough(PromptFallthrough):
201  """Fall through to reading the cluster name from an interactive prompt."""
202
203  def __init__(self):
204    super(ClusterPromptFallthrough, self).__init__(
205        'specify the cluster from a list of available clusters')
206
207  def _Prompt(self, parsed_args):
208    """Fallthrough to reading the cluster name from an interactive prompt.
209
210    Only prompt for cluster name if the user-specified platform is GKE.
211
212    Args:
213      parsed_args: Namespace, the args namespace.
214
215    Returns:
216      A cluster name string
217    """
218    if platforms.GetPlatform() != platforms.PLATFORM_GKE:
219      return
220
221    project = properties.VALUES.core.project.Get(required=True)
222    cluster_location = (
223        getattr(parsed_args, 'cluster_location', None) or
224        properties.VALUES.run.cluster_location.Get())
225    cluster_location_msg = ' in [{}]'.format(
226        cluster_location) if cluster_location else ''
227
228    cluster_refs = global_methods.MultiTenantClustersForProject(
229        project, cluster_location)
230    if not cluster_refs:
231      raise exceptions.ConfigurationError(
232          'No compatible clusters found{}. '
233          'Ensure your cluster has Cloud Run enabled.'.format(
234              cluster_location_msg))
235
236    cluster_refs_descs = [
237        self._GetClusterDescription(c, cluster_location, project)
238        for c in cluster_refs
239    ]
240
241    idx = console_io.PromptChoice(
242        cluster_refs_descs,
243        message='GKE cluster{}:'.format(cluster_location_msg),
244        cancel_option=True)
245
246    cluster_ref = cluster_refs[idx]
247
248    if cluster_location:
249      location_help_text = ''
250    else:
251      location_help_text = (
252          ' && gcloud config set run/cluster_location {}'.format(
253              cluster_ref.zone))
254
255    cluster_name = cluster_ref.Name()
256
257    if cluster_ref.projectId != project:
258      cluster_name = cluster_ref.RelativeName()
259      location_help_text = ''
260
261    log.status.Print('To make this the default cluster, run '
262                     '`gcloud config set run/cluster {cluster}'
263                     '{location}`.\n'.format(
264                         cluster=cluster_name, location=location_help_text))
265    return cluster_ref.SelfLink()
266
267  def _GetClusterDescription(self, cluster, cluster_location, project):
268    """Description of cluster for prompt."""
269
270    response = cluster.Name()
271    if not cluster_location:
272      response = '{} in {}'.format(response, cluster.zone)
273    if project != cluster.projectId:
274      response = '{} in {}'.format(response, cluster.projectId)
275
276    return response
277
278
279def ClusterAttributeConfig():
280  return concepts.ResourceParameterAttributeConfig(
281      name='cluster',
282      help_text='Name of the Kubernetes Engine cluster to use. '
283      'Alternatively, set the property [run/cluster].',
284      fallthroughs=[
285          deps.PropertyFallthrough(properties.VALUES.run.cluster),
286          ClusterPromptFallthrough()
287      ])
288
289
290class ClusterLocationPromptFallthrough(PromptFallthrough):
291  """Fall through to reading the cluster name from an interactive prompt."""
292
293  def __init__(self):
294    super(ClusterLocationPromptFallthrough, self).__init__(
295        'specify the cluster location from a list of available zones')
296
297  def _Prompt(self, parsed_args):
298    """Fallthrough to reading the cluster location from an interactive prompt.
299
300    Only prompt for cluster location if the user-specified platform is GKE
301    and if cluster name is already defined.
302
303    Args:
304      parsed_args: Namespace, the args namespace.
305
306    Returns:
307      A cluster location string
308    """
309    cluster_name = (
310        getattr(parsed_args, 'cluster', None) or
311        properties.VALUES.run.cluster.Get())
312    if platforms.GetPlatform() == platforms.PLATFORM_GKE and cluster_name:
313      clusters = [
314          c for c in global_methods.ListClusters() if c.name == cluster_name
315      ]
316      if not clusters:
317        raise exceptions.ConfigurationError(
318            'No cluster locations found for cluster [{}]. '
319            'Ensure your clusters have Cloud Run enabled.'
320            .format(cluster_name))
321      cluster_locations = [c.zone for c in clusters]
322      idx = console_io.PromptChoice(
323          cluster_locations,
324          message='GKE cluster location for [{}]:'.format(
325              cluster_name),
326          cancel_option=True)
327      location = cluster_locations[idx]
328      log.status.Print(
329          'To make this the default cluster location, run '
330          '`gcloud config set run/cluster_location {}`.\n'.format(location))
331      return location
332
333
334def ClusterLocationAttributeConfig():
335  return concepts.ResourceParameterAttributeConfig(
336      name='location',
337      help_text='Zone in which the {resource} is located. '
338      'Alternatively, set the property [run/cluster_location].',
339      fallthroughs=[
340          deps.PropertyFallthrough(properties.VALUES.run.cluster_location),
341          ClusterLocationPromptFallthrough()
342      ])
343
344
345def GetClusterResourceSpec():
346  return concepts.ResourceSpec(
347      'container.projects.zones.clusters',
348      projectId=concepts.DEFAULT_PROJECT_ATTRIBUTE_CONFIG,
349      zone=ClusterLocationAttributeConfig(),
350      clusterId=ClusterAttributeConfig(),
351      resource_name='cluster')
352
353
354def GetServiceResourceSpec(prompt=False):
355  return concepts.ResourceSpec(
356      'run.namespaces.services',
357      namespacesId=NamespaceAttributeConfig(),
358      servicesId=ServiceAttributeConfig(prompt),
359      resource_name='service')
360
361
362def GetConfigurationResourceSpec():
363  return concepts.ResourceSpec(
364      'run.namespaces.configurations',
365      namespacesId=NamespaceAttributeConfig(),
366      configurationsId=ConfigurationAttributeConfig(),
367      resource_name='configuration')
368
369
370def GetRouteResourceSpec():
371  return concepts.ResourceSpec(
372      'run.namespaces.routes',
373      namespacesId=NamespaceAttributeConfig(),
374      routesId=RouteAttributeConfig(),
375      resource_name='route')
376
377
378def GetRevisionResourceSpec():
379  return concepts.ResourceSpec(
380      'run.namespaces.revisions',
381      namespacesId=NamespaceAttributeConfig(),
382      revisionsId=RevisionAttributeConfig(),
383      resource_name='revision')
384
385
386def GetDomainMappingResourceSpec():
387  return concepts.ResourceSpec(
388      'run.namespaces.domainmappings',
389      namespacesId=NamespaceAttributeConfig(),
390      domainmappingsId=DomainAttributeConfig(),
391      resource_name='DomainMapping')
392
393
394def GetJobResourceSpec(prompt=False):
395  return concepts.ResourceSpec(
396      'run.namespaces.jobs',
397      namespacesId=concepts.DEFAULT_PROJECT_ATTRIBUTE_CONFIG,
398      jobsId=JobAttributeConfig(prompt=prompt),
399      resource_name='Job',
400      api_version='v1alpha1')
401
402
403def GetNamespaceResourceSpec():
404  """Returns a resource spec for the namespace."""
405  # TODO(b/148817410): Remove this when the api has been split.
406  # This try/except block is needed because the v1alpha1 and v1 run apis
407  # have different collection names for the namespaces.
408  try:
409    return concepts.ResourceSpec(
410        'run.namespaces',
411        namespacesId=NamespaceAttributeConfig(),
412        resource_name='namespace')
413  except resources.InvalidCollectionException:
414    return concepts.ResourceSpec(
415        'run.api.v1.namespaces',
416        namespacesId=NamespaceAttributeConfig(),
417        resource_name='namespace')
418
419
420CLUSTER_PRESENTATION = presentation_specs.ResourcePresentationSpec(
421    '--cluster',
422    GetClusterResourceSpec(),
423    'Kubernetes Engine cluster to connect to.',
424    required=False,
425    prefixes=True)
426