1# -*- coding: utf-8 -*- #
2# Copyright 2017 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"""Utilities for deriving services and configs from paths.
16
17Paths are typically given as positional params, like
18`gcloud app deploy <path1> <path2>...`.
19"""
20
21from __future__ import absolute_import
22from __future__ import division
23from __future__ import unicode_literals
24
25import collections
26import os
27
28from googlecloudsdk.api_lib.app import env
29from googlecloudsdk.api_lib.app import yaml_parsing
30from googlecloudsdk.command_lib.app import exceptions
31from googlecloudsdk.core import log
32from googlecloudsdk.core.util import files
33
34_STANDARD_APP_YAML_URL = (
35    'https://cloud.google.com/appengine/docs/standard/python/config/appref')
36_FLEXIBLE_APP_YAML_URL = (
37    'https://cloud.google.com/'
38    'appengine/docs/flexible/python/configuring-your-app-with-app-yaml')
39
40APP_YAML_INSTRUCTIONS = (
41    'using the directions at {flex} (App Engine flexible environment) or {std} '
42    '(App Engine standard environment) under the tab for your language.'
43).format(flex=_FLEXIBLE_APP_YAML_URL, std=_STANDARD_APP_YAML_URL)
44
45FINGERPRINTING_WARNING = (
46    'As an alternative, create an app.yaml file yourself ' +
47    APP_YAML_INSTRUCTIONS)
48NO_YAML_ERROR = (
49    'An app.yaml (or appengine-web.xml) file is required to deploy this '
50    'directory as an App Engine application. Create an app.yaml file '
51    + APP_YAML_INSTRUCTIONS)
52
53
54class Service(object):
55  """Represents data around a deployable service.
56
57  Attributes:
58    descriptor: str, File path to the original deployment descriptor, which is
59      either a `<service>.yaml` or an `appengine-web.xml`.
60    source: str, Path to the original deployable artifact or directory, which
61      is typically the original source directory, but could also be an artifact
62      such as a fat JAR file.
63    service_info: yaml_parsing.ServiceYamlInfo, Info parsed from the
64      `<service>.yaml` file. Note that service_info.file may point to a
65      file in a staged directory.
66    upload_dir: str, Path to the source directory. If staging is required, this
67      points to the staged directory.
68    service_id: str, the service id.
69    path: str, File path to the staged deployment `<service>.yaml` descriptor
70      or to the original one, if no staging is used.
71  """
72
73  def __init__(self, descriptor, source, service_info, upload_dir):
74    self.descriptor = descriptor
75    self.source = source
76    self.service_info = service_info
77    self.upload_dir = upload_dir
78
79  @property
80  def service_id(self):
81    return self.service_info.module
82
83  @property
84  def path(self):
85    return self.service_info.file
86
87  @classmethod
88  def FromPath(cls, path, stager, path_matchers, appyaml):
89    """Return a Service from a path using staging if necessary.
90
91    Args:
92      path: str, Unsanitized absolute path, may point to a directory or a file
93          of any type. There is no guarantee that it exists.
94      stager: staging.Stager, stager that will be invoked if there is a runtime
95          and environment match.
96      path_matchers: List[Function], ordered list of functions on the form
97          fn(path, stager), where fn returns a Service or None if no match.
98      appyaml: str or None, the app.yaml location to used for deployment.
99
100    Returns:
101      Service, if one can be derived, else None.
102    """
103    for matcher in path_matchers:
104      service = matcher(path, stager, appyaml)
105      if service:
106        return service
107    return None
108
109
110def ServiceYamlMatcher(path, stager, appyaml):
111  """Generate a Service from an <service>.yaml source path.
112
113  This function is a path matcher that returns if and only if:
114  - `path` points to either a `<service>.yaml` or `<app-dir>` where
115    `<app-dir>/app.yaml` exists.
116  - the yaml-file is a valid <service>.yaml file.
117
118  If the runtime and environment match an entry in the stager, the service will
119  be staged into a directory.
120
121  Args:
122    path: str, Unsanitized absolute path, may point to a directory or a file of
123        any type. There is no guarantee that it exists.
124    stager: staging.Stager, stager that will be invoked if there is a runtime
125        and environment match.
126    appyaml: str or None, the app.yaml location to used for deployment.
127
128  Raises:
129    staging.StagingCommandFailedError, staging command failed.
130
131  Returns:
132    Service, fully populated with entries that respect a potentially
133        staged deployable service, or None if the path does not match the
134        pattern described.
135  """
136  descriptor = path if os.path.isfile(path) else os.path.join(path,
137                                                              'app.yaml')
138  _, ext = os.path.splitext(descriptor)
139  if os.path.exists(descriptor) and ext in ['.yaml', '.yml']:
140    app_dir = os.path.dirname(descriptor)
141    service_info = yaml_parsing.ServiceYamlInfo.FromFile(descriptor)
142    staging_dir = stager.Stage(descriptor, app_dir, service_info.runtime,
143                               service_info.env, appyaml)
144    # If staging, stage, get stage_dir
145    return Service(descriptor, app_dir, service_info, staging_dir or app_dir)
146  return None
147
148
149def JarMatcher(jar_path, stager, appyaml):
150  """Generate a Service from a Java fatjar path.
151
152  This function is a path matcher that returns if and only if:
153  - `jar_path` points to  a jar file .
154
155  The service will be staged according to the stager as a jar runtime,
156  which is defined in staging.py.
157
158  Args:
159    jar_path: str, Unsanitized absolute path pointing to a file of jar type.
160    stager: staging.Stager, stager that will be invoked if there is a runtime
161      and environment match.
162    appyaml: str or None, the app.yaml location to used for deployment.
163
164  Raises:
165    staging.StagingCommandFailedError, staging command failed.
166
167  Returns:
168    Service, fully populated with entries that respect a staged deployable
169        service, or None if the path does not match the pattern described.
170  """
171  _, ext = os.path.splitext(jar_path)
172  if os.path.exists(jar_path) and ext in ['.jar']:
173    app_dir = os.path.abspath(os.path.join(jar_path, os.pardir))
174    descriptor = jar_path
175    staging_dir = stager.Stage(descriptor, app_dir, 'java-jar', env.STANDARD,
176                               appyaml)
177    yaml_path = os.path.join(staging_dir, 'app.yaml')
178    service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path)
179    return Service(descriptor, app_dir, service_info, staging_dir)
180  return None
181
182
183def PomXmlMatcher(path, stager, appyaml):
184  """Generate a Service from an Maven project source path.
185
186  This function is a path matcher that returns true if and only if:
187  - `path` points to either a Maven `pom.xml` or `<maven=project-dir>` where
188    `<maven-project-dir>/pom.xml` exists.
189
190  If the runtime and environment match an entry in the stager, the service will
191  be staged into a directory.
192
193  Args:
194    path: str, Unsanitized absolute path, may point to a directory or a file of
195      any type. There is no guarantee that it exists.
196    stager: staging.Stager, stager that will be invoked if there is a runtime
197      and environment match.
198    appyaml: str or None, the app.yaml location to used for deployment.
199
200  Raises:
201    staging.StagingCommandFailedError, staging command failed.
202
203  Returns:
204    Service, fully populated with entries that respect a potentially
205        staged deployable service, or None if the path does not match the
206        pattern described.
207  """
208  descriptor = path if os.path.isfile(path) else os.path.join(path, 'pom.xml')
209  filename = os.path.basename(descriptor)
210  if os.path.exists(descriptor) and filename == 'pom.xml':
211    app_dir = os.path.dirname(descriptor)
212    staging_dir = stager.Stage(descriptor, app_dir, 'java-maven-project',
213                               env.STANDARD, appyaml)
214    yaml_path = os.path.join(staging_dir, 'app.yaml')
215    service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path)
216    return Service(descriptor, app_dir, service_info, staging_dir)
217  return None
218
219
220def BuildGradleMatcher(path, stager, appyaml):
221  """Generate a Service from an Gradle project source path.
222
223  This function is a path matcher that returns true if and only if:
224  - `path` points to either a Gradle `build.gradle` or `<gradle-project-dir>`
225  where `<gradle-project-dir>/build.gradle` exists.
226
227  If the runtime and environment match an entry in the stager, the service will
228  be staged into a directory.
229
230  Args:
231    path: str, Unsanitized absolute path, may point to a directory or a file of
232      any type. There is no guarantee that it exists.
233    stager: staging.Stager, stager that will be invoked if there is a runtime
234      and environment match.
235    appyaml: str or None, the app.yaml location to used for deployment.
236
237  Raises:
238    staging.StagingCommandFailedError, staging command failed.
239
240  Returns:
241    Service, fully populated with entries that respect a potentially
242        staged deployable service, or None if the path does not match the
243        pattern described.
244  """
245  descriptor = path if os.path.isfile(path) else os.path.join(
246      path, 'build.gradle')
247  filename = os.path.basename(descriptor)
248  if os.path.exists(descriptor) and filename == 'build.gradle':
249    app_dir = os.path.dirname(descriptor)
250    staging_dir = stager.Stage(descriptor, app_dir, 'java-gradle-project',
251                               env.STANDARD, appyaml)
252    yaml_path = os.path.join(staging_dir, 'app.yaml')
253    service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path)
254    return Service(descriptor, app_dir, service_info, staging_dir)
255  return None
256
257
258def AppengineWebMatcher(path, stager, appyaml):
259  """Generate a Service from an appengine-web.xml source path.
260
261  This function is a path matcher that returns if and only if:
262  - `path` points to either `.../WEB-INF/appengine-web.xml` or `<app-dir>` where
263    `<app-dir>/WEB-INF/appengine-web.xml` exists.
264  - the xml-file is a valid appengine-web.xml file according to the Java stager.
265
266  The service will be staged according to the stager as a java-xml runtime,
267  which is defined in staging.py.
268
269  Args:
270    path: str, Unsanitized absolute path, may point to a directory or a file of
271        any type. There is no guarantee that it exists.
272    stager: staging.Stager, stager that will be invoked if there is a runtime
273        and environment match.
274    appyaml: str or None, the app.yaml location to used for deployment.
275
276  Raises:
277    staging.StagingCommandFailedError, staging command failed.
278
279  Returns:
280    Service, fully populated with entries that respect a staged deployable
281        service, or None if the path does not match the pattern described.
282  """
283  suffix = os.path.join(os.sep, 'WEB-INF', 'appengine-web.xml')
284  app_dir = path[:-len(suffix)] if path.endswith(suffix) else path
285  descriptor = os.path.join(app_dir, 'WEB-INF', 'appengine-web.xml')
286  if not os.path.isfile(descriptor):
287    return None
288
289  xml_file = files.ReadFileContents(descriptor)
290  if '<application>' in xml_file or '<version>' in xml_file:
291    log.warning('<application> and <version> elements in ' +
292                '`appengine-web.xml` are not respected')
293
294  staging_dir = stager.Stage(descriptor, app_dir, 'java-xml', env.STANDARD,
295                             appyaml)
296  if not staging_dir:
297    # After GA launch of appengine-web.xml support, this should never occur.
298    return None
299  yaml_path = os.path.join(staging_dir, 'app.yaml')
300  service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path)
301  return Service(descriptor, app_dir, service_info, staging_dir)
302
303
304def ExplicitAppYamlMatcher(path, stager, appyaml):
305  """Use optional app.yaml with a directory or a file the user wants to deploy.
306
307  Args:
308    path: str, Unsanitized absolute path, may point to a directory or a file of
309      any type. There is no guarantee that it exists.
310    stager: staging.Stager, stager that will not be invoked.
311    appyaml: str or None, the app.yaml location to used for deployment.
312
313  Returns:
314    Service, fully populated with entries that respect a staged deployable
315        service, or None if there is no optional --appyaml flag usage.
316  """
317
318  if appyaml:
319    service_info = yaml_parsing.ServiceYamlInfo.FromFile(appyaml)
320    staging_dir = stager.Stage(appyaml, path, 'generic-copy', service_info.env,
321                               appyaml)
322    return Service(appyaml, path, service_info, staging_dir)
323  return None
324
325
326def UnidentifiedDirMatcher(path, stager, appyaml):
327  """Points out to the user that they need an app.yaml to deploy.
328
329  Args:
330    path: str, Unsanitized absolute path, may point to a directory or a file of
331        any type. There is no guarantee that it exists.
332    stager: staging.Stager, stager that will not be invoked.
333    appyaml: str or None, the app.yaml location to used for deployment.
334  Returns:
335    None
336  """
337  del stager, appyaml
338  if os.path.isdir(path):
339    log.error(NO_YAML_ERROR)
340  return None
341
342
343def GetPathMatchers():
344  """Get list of path matchers ordered by descending precedence.
345
346  Returns:
347    List[Function], ordered list of functions on the form fn(path, stager),
348    where fn returns a Service or None if no match.
349  """
350  return [
351      ServiceYamlMatcher, AppengineWebMatcher, JarMatcher, PomXmlMatcher,
352      BuildGradleMatcher, ExplicitAppYamlMatcher, UnidentifiedDirMatcher
353  ]
354
355
356class Services(object):
357  """Collection of deployable services."""
358
359  def __init__(self, services=None):
360    """Instantiate a set of deployable services.
361
362    Args:
363      services: List[Service], optional list of services for quick
364          initialization.
365
366    Raises:
367      DuplicateServiceError: Two or more services have the same service id.
368    """
369    self._services = collections.OrderedDict()
370    if services:
371      for d in services:
372        self.Add(d)
373
374  def Add(self, service):
375    """Add a deployable service to the set.
376
377    Args:
378      service: Service, to add.
379
380    Raises:
381      DuplicateServiceError: Two or more services have the same service id.
382    """
383    existing = self._services.get(service.service_id)
384    if existing:
385      raise exceptions.DuplicateServiceError(existing.path, service.path,
386                                             service.service_id)
387    self._services[service.service_id] = service
388
389  def GetAll(self):
390    """Retrieve the service info objects in the order they were added.
391
392    Returns:
393      List[Service], list of services.
394    """
395    return list(self._services.values())
396
397
398class Configs(object):
399  """Collection of config files."""
400
401  def __init__(self):
402    self._configs = collections.OrderedDict()
403
404  def Add(self, config):
405    """Add a ConfigYamlInfo to the set of configs.
406
407    Args:
408      config: ConfigYamlInfo, the config to add.
409
410    Raises:
411      exceptions.DuplicateConfigError, the config type is already in the set.
412    """
413    config_type = config.config
414    existing = self._configs.get(config_type)
415    if existing:
416      raise exceptions.DuplicateConfigError(existing.file, config.file,
417                                            config_type)
418    self._configs[config_type] = config
419
420  def GetAll(self):
421    """Retreive the config file objects in the order they were added.
422
423    Returns:
424      List[ConfigYamlInfo], list of config file objects.
425    """
426    return list(self._configs.values())
427
428
429def GetDeployables(args, stager, path_matchers, appyaml=None):
430  """Given a list of args, infer the deployable services and configs.
431
432  Given a deploy command, e.g. `gcloud app deploy ./dir other/service.yaml
433  cron.yaml WEB-INF/appengine-web.xml`, the deployables can be on multiple
434  forms. This method pre-processes and infers yaml descriptors from the
435  various formats accepted. The rules are as following:
436
437  This function is a context manager, and should be used in conjunction with
438  the `with` keyword.
439
440  1. If `args` is an empty list, add the current directory to it.
441  2. For each arg:
442    - If arg refers to a config file, add it to the configs set.
443    - Else match the arg against the path matchers. The first match will win.
444      The match will be added to the services set. Matchers may run staging.
445
446  Args:
447    args: List[str], positional args as given on the command-line.
448    stager: staging.Stager, stager that will be invoked on sources that have
449        entries in the stager's registry.
450    path_matchers: List[Function], list of functions on the form
451        fn(path, stager) ordered by descending precedence, where fn returns
452        a Service or None if no match.
453    appyaml: str or None, the app.yaml location to used for deployment.
454
455  Raises:
456    FileNotFoundError: One or more argument does not point to an existing file
457        or directory.
458    UnknownSourceError: Could not infer a config or service from an arg.
459    DuplicateConfigError: Two or more config files have the same type.
460    DuplicateServiceError: Two or more services have the same service id.
461
462  Returns:
463    Tuple[List[Service], List[ConfigYamlInfo]], lists of deployable services
464    and configs.
465  """
466  if not args:
467    args = ['.']
468  paths = [os.path.abspath(arg) for arg in args]
469  configs = Configs()
470  services = Services()
471  if appyaml:
472    if len(paths) > 1:
473      raise exceptions.MultiDeployError()
474    if not os.path.exists(os.path.abspath(appyaml)):
475      raise exceptions.FileNotFoundError('File {0} referenced by --appyaml '
476                                         'does not exist.'.format(appyaml))
477    if not os.path.exists(paths[0]):
478      raise exceptions.FileNotFoundError(paths[0])
479
480  for path in paths:
481    if not os.path.exists(path):
482      raise exceptions.FileNotFoundError(path)
483    config = yaml_parsing.ConfigYamlInfo.FromFile(path)
484    if config:
485      configs.Add(config)
486      continue
487    service = Service.FromPath(path, stager, path_matchers, appyaml)
488    if service:
489      services.Add(service)
490      continue
491    raise exceptions.UnknownSourceError(path)
492  return services.GetAll(), configs.GetAll()
493