1# -*- coding: utf-8 -*- #
2# Copyright 2015 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"""Fingerprinting code for the Python runtime."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import unicode_literals
21
22import os
23import textwrap
24
25from gae_ext_runtime import ext_runtime
26
27from googlecloudsdk.api_lib.app.images import config
28from googlecloudsdk.core import log
29from googlecloudsdk.core.util import files
30
31NAME = 'Python Compat'
32ALLOWED_RUNTIME_NAMES = ('python27', 'python-compat')
33PYTHON_RUNTIME_NAME = 'python27'
34
35# TODO(b/36055866): this generated app.yaml doesn't work because the compat
36# runtimes need a "handlers" section.  Query the user for this information.
37PYTHON_APP_YAML = textwrap.dedent("""\
38    env: flex
39    runtime: {runtime}
40    api_version: 1
41    threadsafe: false
42    # You must add a handlers section here.  Example:
43    # handlers:
44    # - url: .*
45    #   script: main.app
46    """)
47APP_YAML_WARNING = ('app.yaml has been generated, but needs to be provided a '
48                    '"handlers" section.')
49DOCKERIGNORE = textwrap.dedent("""\
50    .dockerignore
51    Dockerfile
52    .git
53    .hg
54    .svn
55    """)
56COMPAT_DOCKERFILE_PREAMBLE = (
57    'FROM gcr.io/google_appengine/python-compat-multicore\n')
58PYTHON27_DOCKERFILE_PREAMBLE = 'FROM gcr.io/google_appengine/python-compat\n'
59
60DOCKERFILE_INSTALL_APP = 'ADD . /app/\n'
61
62# TODO(b/36057458): Do the check for requirements.txt in the source inspection
63# and don't generate the pip install if it doesn't exist.
64DOCKERFILE_INSTALL_REQUIREMENTS_TXT = (
65    'RUN if [ -s requirements.txt ]; then pip install -r requirements.txt; '
66    'fi\n')
67
68
69class PythonConfigurator(ext_runtime.Configurator):
70  """Generates configuration for a Python application."""
71
72  def __init__(self, path, params, runtime):
73    """Constructor.
74
75    Args:
76      path: (str) Root path of the source tree.
77      params: (ext_runtime.Params) Parameters passed through to the
78        fingerprinters.
79      runtime: (str) The runtime name.
80    """
81    self.root = path
82    self.params = params
83    self.runtime = runtime
84
85  def GenerateAppYaml(self, notify):
86    """Generate app.yaml.
87
88    Args:
89      notify: depending on whether we're in deploy, write messages to the
90        user or to log.
91    Returns:
92      (bool) True if file was written
93
94    Note: this is not a recommended use-case,
95    python-compat users likely have an existing app.yaml.  But users can
96    still get here with the --runtime flag.
97    """
98    if not self.params.appinfo:
99      app_yaml = os.path.join(self.root, 'app.yaml')
100      if not os.path.exists(app_yaml):
101        notify('Writing [app.yaml] to [%s].' % self.root)
102        runtime = 'custom' if self.params.custom else self.runtime
103        files.WriteFileContents(app_yaml,
104                                PYTHON_APP_YAML.format(runtime=runtime))
105        log.warning(APP_YAML_WARNING)
106        return True
107    return False
108
109  def GenerateDockerfileData(self):
110    """Generates dockerfiles.
111
112    Returns:
113      list(ext_runtime.GeneratedFile) the list of generated dockerfiles
114    """
115    if self.runtime == 'python-compat':
116      dockerfile_preamble = COMPAT_DOCKERFILE_PREAMBLE
117    else:
118      dockerfile_preamble = PYTHON27_DOCKERFILE_PREAMBLE
119
120    all_config_files = []
121
122    dockerfile_name = config.DOCKERFILE
123    dockerfile_components = [dockerfile_preamble, DOCKERFILE_INSTALL_APP]
124    if self.runtime == 'python-compat':
125      dockerfile_components.append(DOCKERFILE_INSTALL_REQUIREMENTS_TXT)
126    dockerfile_contents = ''.join(c for c in dockerfile_components)
127    dockerfile = ext_runtime.GeneratedFile(dockerfile_name,
128                                           dockerfile_contents)
129    all_config_files.append(dockerfile)
130
131    dockerignore = ext_runtime.GeneratedFile('.dockerignore', DOCKERIGNORE)
132    all_config_files.append(dockerignore)
133
134    return all_config_files
135
136  def GenerateConfigs(self):
137    """Generate all config files for the module."""
138    # Write messages to user or to log depending on whether we're in "deploy."
139    notify = log.info if self.params.deploy else log.status.Print
140
141    self.GenerateAppYaml(notify)
142
143    created = False
144    if self.params.custom or self.params.deploy:
145      dockerfiles = self.GenerateDockerfileData()
146      for dockerfile in dockerfiles:
147        if dockerfile.WriteTo(self.root, notify):
148          created = True
149      if not created:
150        notify('All config files already exist, not generating anything.')
151    return created
152
153  def GenerateConfigData(self):
154    """Generate all config files for the module.
155
156    Returns:
157      list(ext_runtime.GeneratedFile) A list of the config files
158        that were generated
159    """
160    # Write messages to user or to log depending on whether we're in "deploy."
161    notify = log.info if self.params.deploy else log.status.Print
162
163    self.GenerateAppYaml(notify)
164    if not (self.params.custom or self.params.deploy):
165      return []
166    all_config_files = self.GenerateDockerfileData()
167    return [f for f in all_config_files
168            if not os.path.exists(os.path.join(self.root, f.filename))]
169
170
171def Fingerprint(path, params):
172  """Check for a Python app.
173
174  Args:
175    path: (str) Application path.
176    params: (ext_runtime.Params) Parameters passed through to the
177      fingerprinters.
178
179  Returns:
180    (PythonConfigurator or None) Returns a module if the path contains a
181    python app.
182  """
183  log.info('Checking for Python Compat.')
184
185  # The only way we select these runtimes is if either the user has specified
186  # it or a matching runtime is specified in the app.yaml.
187  if (not params.runtime and
188      (not params.appinfo or
189       params.appinfo.GetEffectiveRuntime() not in ALLOWED_RUNTIME_NAMES)):
190    return None
191
192  if params.appinfo:
193    runtime = params.appinfo.GetEffectiveRuntime()
194  else:
195    runtime = params.runtime
196
197  log.info('Python Compat matches ([{0}] specified in "runtime" field)'.format(
198      runtime))
199  return PythonConfigurator(path, params, runtime)
200