feat: Add new gcloud commands, API clients, and third-party libraries across various services.

This commit is contained in:
2026-01-01 20:26:35 +01:00
parent 5e23cbece0
commit a19e592eb7
25221 changed files with 8324611 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Package containing fingerprinting for all runtimes.
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from gae_ext_runtime import ext_runtime
from googlecloudsdk.api_lib.app import ext_runtime_adapter
from googlecloudsdk.api_lib.app.runtimes import python
from googlecloudsdk.api_lib.app.runtimes import python_compat
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
RUNTIMES = [
# Note that ordering of runtimes here is very important and changes to the
# relative positions need to be tested carefully.
# Custom comes first, if we've got a Dockerfile this is a custom runtime.
ext_runtime_adapter.CoreRuntimeLoader('custom', 'Custom',
['custom']),
# Go's position is relatively flexible due to its orthogonal nature.
ext_runtime_adapter.CoreRuntimeLoader('go', 'Go', ['go', 'custom']),
ext_runtime_adapter.CoreRuntimeLoader('ruby', 'Ruby', ['ruby', 'custom']),
ext_runtime_adapter.CoreRuntimeLoader('nodejs', 'Node.js',
['nodejs', 'custom']),
ext_runtime_adapter.CoreRuntimeLoader('java', 'Java',
['java', 'java7', 'custom']),
python_compat,
# Python and PHP are last because they match if any .py or .php file is
# present.
ext_runtime_adapter.CoreRuntimeLoader('python', 'Python',
['python', 'custom']),
ext_runtime_adapter.CoreRuntimeLoader('php', 'PHP', ['php', 'custom']),
]
class UnidentifiedDirectoryError(exceptions.Error):
"""Raised when GenerateConfigs() can't identify the directory."""
def __init__(self, path):
"""Constructor.
Args:
path: (basestring) Directory we failed to identify.
"""
super(UnidentifiedDirectoryError, self).__init__(
'Unrecognized directory type: [{0}]'.format(path))
self.path = path
class ExtRuntimeError(exceptions.Error):
"""ext_runtime.Error errors are converted to this."""
class ConflictingConfigError(exceptions.Error):
"""Property in app.yaml conflicts with params passed to fingerprinter."""
class AlterConfigFileError(exceptions.Error):
"""Error when attempting to update an existing config file (app.yaml)."""
def __init__(self, inner_exception):
super(AlterConfigFileError, self).__init__(
'Could not alter app.yaml due to an internal error:\n{0}\n'
'Please update app.yaml manually.'.format(inner_exception))
def IdentifyDirectory(path, params=None):
"""Try to identify the given directory.
As a side-effect, if there is a config file in 'params' with a runtime of
'custom', this sets params.custom to True.
Args:
path: (basestring) Root directory to identify.
params: (ext_runtime.Params or None) Parameters passed through to the
fingerprinters. Uses defaults if not provided.
Returns:
(ext_runtime.Configurator or None) Returns a module if we've identified
it, None if not.
"""
if not params:
params = ext_runtime.Params()
# Parameter runtime has precedence
if params.runtime:
specified_runtime = params.runtime
elif params.appinfo:
specified_runtime = params.appinfo.GetEffectiveRuntime()
else:
specified_runtime = None
if specified_runtime == 'custom':
params.custom = True
for runtime in RUNTIMES:
# If we have an app.yaml, don't fingerprint for any runtimes that don't
# allow the runtime name it specifies.
if (specified_runtime and runtime.ALLOWED_RUNTIME_NAMES and
specified_runtime not in runtime.ALLOWED_RUNTIME_NAMES):
log.info('Not checking for [%s] because runtime is [%s]' %
(runtime.NAME, specified_runtime))
continue
try:
configurator = runtime.Fingerprint(path, params)
except ext_runtime.Error as ex:
raise ExtRuntimeError(ex.message)
if configurator:
return configurator
return None
def _GetModule(path, params=None, config_filename=None):
"""Helper function for generating configs.
Args:
path: (basestring) Root directory to identify.
params: (ext_runtime.Params or None) Parameters passed through to the
fingerprinters. Uses defaults if not provided.
config_filename: (str or None) Filename of the config file (app.yaml).
Raises:
UnidentifiedDirectoryError: No runtime module matched the directory.
ConflictingConfigError: Current app.yaml conflicts with other params.
Returns:
ext_runtime.Configurator, the configurator for the path
"""
if not params:
params = ext_runtime.Params()
config = params.appinfo
# An app.yaml exists, results in a lot more cases
if config and not params.deploy:
# Enforce --custom
if not params.custom:
raise ConflictingConfigError(
'Configuration file already exists. This command generates an '
'app.yaml configured to run an application on Google App Engine. '
'To create the configuration files needed to run this '
'application with docker, try `gcloud preview app gen-config '
'--custom`.')
# Check that current config is for MVM
if not config.IsVm():
raise ConflictingConfigError(
'gen-config is only supported for App Engine Flexible. Please '
'use "vm: true" in your app.yaml if you would like to use App Engine '
'Flexible to run your application.')
# Check for conflicting --runtime and runtime in app.yaml
if (config.GetEffectiveRuntime() != 'custom' and params.runtime is not None
and params.runtime != config.GetEffectiveRuntime()):
raise ConflictingConfigError(
'[{0}] contains "runtime: {1}" which conficts with '
'--runtime={2}.'.format(config_filename, config.GetEffectiveRuntime(),
params.runtime))
module = IdentifyDirectory(path, params)
if not module:
raise UnidentifiedDirectoryError(path)
return module
def GenerateConfigs(path, params=None, config_filename=None):
"""Identify runtime and generate config files for a directory.
If a runtime can be identified for the given directory, calls the runtime's
GenerateConfigs method, which writes configs to the directory.
Args:
path: (basestring) Root directory to identify.
params: (ext_runtime.Params or None) Parameters passed through to the
fingerprinters. Uses defaults if not provided.
config_filename: (str or None) Filename of the config file (app.yaml).
Raises:
ExtRuntimeError: if there was an error generating configs
Returns:
(bool): True if files were written
"""
module = _GetModule(path, params=params, config_filename=config_filename)
try:
return module.GenerateConfigs()
except ext_runtime.Error as ex:
raise ExtRuntimeError(ex.message)
def GenerateConfigData(path, params=None, config_filename=None):
"""Identify runtime and generate contents of config files for a directory.
If a runtime can be identified for the given directory, calls the runtime's
GenerateConfigData method, which generates the contents of config files.
Args:
path: (basestring) Root directory to identify.
params: (ext_runtime.Params or None) Parameters passed through to the
fingerprinters. Uses defaults if not provided.
config_filename: (str or None) Filename of the config file (app.yaml).
Raises:
ExtRuntimeError: if there was an error generating configs
Returns:
[ext_runtime.GeneratedFile] generated config files.
"""
module = _GetModule(path, params=params, config_filename=config_filename)
try:
return module.GenerateConfigData()
except ext_runtime.Error as ex:
raise ExtRuntimeError(ex.message)

View File

@@ -0,0 +1,219 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fingerprinting code for the Go runtime."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import fnmatch
import os
import re
import textwrap
from gae_ext_runtime import ext_runtime
from googlecloudsdk.api_lib.app.images import config as images_config
from googlecloudsdk.core import log
from googlecloudsdk.core.util import files
import six
NAME ='go'
ALLOWED_RUNTIME_NAMES = ('go', 'custom')
GO_RUNTIME_NAME = 'go'
GO_APP_YAML = textwrap.dedent("""\
env: flex
runtime: {runtime}
api_version: go1
""")
DOCKERIGNORE = textwrap.dedent("""\
.dockerignore
Dockerfile
.git
.hg
.svn
""")
DOCKERFILE = textwrap.dedent("""\
# Dockerfile extending the generic Go image with application files for a
# single application.
FROM gcr.io/google_appengine/golang
COPY . /go/src/app
RUN go-wrapper install -tags appenginevm
""")
class GoConfigurator(ext_runtime.Configurator):
"""Generates configuration for a Go app."""
def __init__(self, path, params):
"""Constructor.
Args:
path: (str) Root path of the source tree.
params: (ext_runtime.Params) Parameters passed through to the
fingerprinters.
"""
self.root = path
self.params = params
def GetAllConfigFiles(self):
all_config_files = []
# Generate app.yaml.
if not self.params.appinfo:
app_yaml_path = os.path.join(self.root, 'app.yaml')
if not os.path.exists(app_yaml_path):
runtime = 'custom' if self.params.custom else 'go'
app_yaml_contents = GO_APP_YAML.format(runtime=runtime)
app_yaml = ext_runtime.GeneratedFile('app.yaml', app_yaml_contents)
all_config_files.append(app_yaml)
if self.params.custom or self.params.deploy:
dockerfile_path = os.path.join(self.root, images_config.DOCKERFILE)
if not os.path.exists(dockerfile_path):
dockerfile = ext_runtime.GeneratedFile(images_config.DOCKERFILE,
DOCKERFILE)
all_config_files.append(dockerfile)
# Generate .dockerignore
dockerignore_path = os.path.join(self.root, '.dockerignore')
if not os.path.exists(dockerignore_path):
dockerignore = ext_runtime.GeneratedFile('.dockerignore', DOCKERIGNORE)
all_config_files.append(dockerignore)
return all_config_files
def GenerateConfigs(self):
"""Generate config files for the module.
Returns:
(bool) True if files were created
"""
# Write "Writing file" messages to the user or to log depending on whether
# we're in "deploy."
if self.params.deploy:
notify = log.info
else:
notify = log.status.Print
cfg_files = self.GetAllConfigFiles()
created = False
for cfg_file in cfg_files:
if cfg_file.WriteTo(self.root, notify):
created = True
if not created:
notify('All config files already exist, not generating anything.')
return created
def GenerateConfigData(self):
"""Generate config files for the module.
Returns:
list(ext_runtime.GeneratedFile) list of generated files.
"""
# Write "Writing file" messages to the user or to log depending on whether
# we're in "deploy."
if self.params.deploy:
notify = log.info
else:
notify = log.status.Print
cfg_files = self.GetAllConfigFiles()
for cfg_file in cfg_files:
if cfg_file.filename == 'app.yaml':
cfg_file.WriteTo(self.root, notify)
final_cfg_files = []
for f in cfg_files:
if f.filename != 'app.yaml' and not os.path.exists(
os.path.join(self.root, f.filename)):
final_cfg_files.append(f)
return final_cfg_files
def _GoFiles(path):
"""Return list of '*.go' files under directory 'path'.
Note that os.walk by default performs a top-down search, so files higher in
the directory tree appear before others.
Args:
path: (str) Application path.
Returns:
([str, ...]) List of full pathnames for all '*.go' files under 'path' dir.
"""
go_files = []
for root, _, filenames in os.walk(six.text_type(path)):
for filename in fnmatch.filter(filenames, '*.go'):
go_files.append(os.path.join(root, filename))
return go_files
def _FindMain(filename):
"""Check filename for 'package main' and 'func main'.
Args:
filename: (str) File name to check.
Returns:
(bool) True if main is found in filename.
"""
with files.FileReader(filename) as f:
found_package = False
found_func = False
for line in f:
if re.match('^package main', line):
found_package = True
elif re.match('^func main', line):
found_func = True
if found_package and found_func:
return True
return False
def Fingerprint(path, params):
"""Check for a Go app.
Args:
path: (str) Application path.
params: (ext_runtime.Params) Parameters passed through to the
fingerprinters.
Returns:
(GoConfigurator or None) Returns a module if the path contains a
Go app.
"""
log.info('Checking for Go.')
# Test #1 - are there any '*.go' files at or below 'path'?
go_files = _GoFiles(path)
if not go_files:
return None
# Test #2 - check that one of these files has "package main" and "func main".
main_found = False
for f in go_files:
if _FindMain(f):
log.info('Found Go main in %s', f)
main_found = True
break
if not main_found:
return None
return GoConfigurator(path, params)

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Old fingerprinting module for the Java runtime.
This file is almost dead. It currently just contains constants that we use in
runtimes_test, which should also mostly go away.
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import textwrap
JAVA_APP_YAML = textwrap.dedent("""\
env: flex
runtime: {runtime}
""")
DOCKERIGNORE = textwrap.dedent("""\
.dockerignore
Dockerfile
.git
.hg
.svn
app.yaml
""")
DOCKERFILE_JAVA8_PREAMBLE = 'FROM gcr.io/google_appengine/openjdk8\n'
DOCKERFILE_JETTY9_PREAMBLE = 'FROM gcr.io/google_appengine/jetty9\n'
DOCKERFILE_JAVA_PREAMBLE = 'FROM gcr.io/google_appengine/openjdk\n'
DOCKERFILE_JETTY_PREAMBLE = 'FROM gcr.io/google_appengine/jetty\n'
DOCKERFILE_LEGACY_PREAMBLE = 'FROM gcr.io/google_appengine/java-compat\n'
DOCKERFILE_COMPAT_PREAMBLE = 'FROM gcr.io/google_appengine/jetty9-compat\n'
DOCKERFILE_JAVA8_JAR_CMD = 'CMD ["java", "-jar", "/app/{0}"]\n'
DOCKERFILE_INSTALL_APP = 'ADD {0} /app/\n'
DOCKERFILE_INSTALL_WAR = 'ADD {0} $JETTY_BASE/webapps/root.war\n'

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fingerprinting code for the node.js runtime.
WARNING WARNING WARNING: this file will shortly be removed. Don't make any
changes here. See ./ext_runtimes/runtime_defs/nodejs instead.
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import textwrap
# TODO(b/36050883): move these into the node_app directory.
NODEJS_APP_YAML = textwrap.dedent("""\
env: flex
runtime: {runtime}
""")
DOCKERIGNORE = textwrap.dedent("""\
# Copyright 2015 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
node_modules
.dockerignore
Dockerfile
npm-debug.log
yarn-error.log
.git
.hg
.svn
""")

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fingerprinting code for the Python runtime."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import textwrap
DOCKERFILE_PREAMBLE = 'FROM gcr.io/google-appengine/python\n'
DOCKERFILE_VIRTUALENV_TEMPLATE = textwrap.dedent("""\
LABEL python_version=python{python_version}
RUN virtualenv --no-download /env -p python{python_version}
# Set virtualenv environment variables. This is equivalent to running
# source /env/bin/activate
ENV VIRTUAL_ENV /env
ENV PATH /env/bin:$PATH
""")
DOCKERFILE_REQUIREMENTS_TXT = textwrap.dedent("""\
ADD requirements.txt /app/
RUN pip install -r requirements.txt
""")
DOCKERFILE_INSTALL_APP = 'ADD . /app/\n'

View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fingerprinting code for the Python runtime."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import os
import textwrap
from gae_ext_runtime import ext_runtime
from googlecloudsdk.api_lib.app.images import config
from googlecloudsdk.core import log
from googlecloudsdk.core.util import files
NAME = 'Python Compat'
ALLOWED_RUNTIME_NAMES = ('python27', 'python-compat')
PYTHON_RUNTIME_NAME = 'python27'
PYTHON_APP_YAML = textwrap.dedent("""\
env: flex
runtime: {runtime}
api_version: 1
threadsafe: false
# You must add a handlers section here. Example:
# handlers:
# - url: .*
# script: main.app
""")
APP_YAML_WARNING = ('app.yaml has been generated, but needs to be provided a '
'"handlers" section.')
DOCKERIGNORE = textwrap.dedent("""\
.dockerignore
Dockerfile
.git
.hg
.svn
""")
COMPAT_DOCKERFILE_PREAMBLE = (
'FROM gcr.io/google_appengine/python-compat-multicore\n')
PYTHON27_DOCKERFILE_PREAMBLE = 'FROM gcr.io/google_appengine/python-compat\n'
DOCKERFILE_INSTALL_APP = 'ADD . /app/\n'
# TODO(b/36057458): Do the check for requirements.txt in the source inspection
# and don't generate the pip install if it doesn't exist.
DOCKERFILE_INSTALL_REQUIREMENTS_TXT = (
'RUN if [ -s requirements.txt ]; then pip install -r requirements.txt; '
'fi\n')
class PythonConfigurator(ext_runtime.Configurator):
"""Generates configuration for a Python application."""
def __init__(self, path, params, runtime):
"""Constructor.
Args:
path: (str) Root path of the source tree.
params: (ext_runtime.Params) Parameters passed through to the
fingerprinters.
runtime: (str) The runtime name.
"""
self.root = path
self.params = params
self.runtime = runtime
def GenerateAppYaml(self, notify):
"""Generate app.yaml.
Args:
notify: depending on whether we're in deploy, write messages to the
user or to log.
Returns:
(bool) True if file was written
Note: this is not a recommended use-case,
python-compat users likely have an existing app.yaml. But users can
still get here with the --runtime flag.
"""
if not self.params.appinfo:
app_yaml = os.path.join(self.root, 'app.yaml')
if not os.path.exists(app_yaml):
notify('Writing [app.yaml] to [%s].' % self.root)
runtime = 'custom' if self.params.custom else self.runtime
files.WriteFileContents(app_yaml,
PYTHON_APP_YAML.format(runtime=runtime))
log.warning(APP_YAML_WARNING)
return True
return False
def GenerateDockerfileData(self):
"""Generates dockerfiles.
Returns:
list(ext_runtime.GeneratedFile) the list of generated dockerfiles
"""
if self.runtime == 'python-compat':
dockerfile_preamble = COMPAT_DOCKERFILE_PREAMBLE
else:
dockerfile_preamble = PYTHON27_DOCKERFILE_PREAMBLE
all_config_files = []
dockerfile_name = config.DOCKERFILE
dockerfile_components = [dockerfile_preamble, DOCKERFILE_INSTALL_APP]
if self.runtime == 'python-compat':
dockerfile_components.append(DOCKERFILE_INSTALL_REQUIREMENTS_TXT)
dockerfile_contents = ''.join(c for c in dockerfile_components)
dockerfile = ext_runtime.GeneratedFile(dockerfile_name,
dockerfile_contents)
all_config_files.append(dockerfile)
dockerignore = ext_runtime.GeneratedFile('.dockerignore', DOCKERIGNORE)
all_config_files.append(dockerignore)
return all_config_files
def GenerateConfigs(self):
"""Generate all config files for the module."""
# Write messages to user or to log depending on whether we're in "deploy."
notify = log.info if self.params.deploy else log.status.Print
self.GenerateAppYaml(notify)
created = False
if self.params.custom or self.params.deploy:
dockerfiles = self.GenerateDockerfileData()
for dockerfile in dockerfiles:
if dockerfile.WriteTo(self.root, notify):
created = True
if not created:
notify('All config files already exist, not generating anything.')
return created
def GenerateConfigData(self):
"""Generate all config files for the module.
Returns:
list(ext_runtime.GeneratedFile) A list of the config files
that were generated
"""
# Write messages to user or to log depending on whether we're in "deploy."
notify = log.info if self.params.deploy else log.status.Print
self.GenerateAppYaml(notify)
if not (self.params.custom or self.params.deploy):
return []
all_config_files = self.GenerateDockerfileData()
return [f for f in all_config_files
if not os.path.exists(os.path.join(self.root, f.filename))]
def Fingerprint(path, params):
"""Check for a Python app.
Args:
path: (str) Application path.
params: (ext_runtime.Params) Parameters passed through to the
fingerprinters.
Returns:
(PythonConfigurator or None) Returns a module if the path contains a
python app.
"""
log.info('Checking for Python Compat.')
# The only way we select these runtimes is if either the user has specified
# it or a matching runtime is specified in the app.yaml.
if (not params.runtime and
(not params.appinfo or
params.appinfo.GetEffectiveRuntime() not in ALLOWED_RUNTIME_NAMES)):
return None
if params.appinfo:
runtime = params.appinfo.GetEffectiveRuntime()
else:
runtime = params.runtime
log.info('Python Compat matches ([{0}] specified in "runtime" field)'.format(
runtime))
return PythonConfigurator(path, params, runtime)

View File

@@ -0,0 +1,545 @@
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fingerprinting code for the Ruby runtime."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import os
import re
import subprocess
import textwrap
from gae_ext_runtime import ext_runtime
from googlecloudsdk.api_lib.app import ext_runtime_adapter
from googlecloudsdk.api_lib.app.images import config
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core.console import console_io
from googlecloudsdk.core.util import files
NAME = 'Ruby'
ALLOWED_RUNTIME_NAMES = ('ruby', 'custom')
# This should be kept in sync with the default Ruby version specified in
# the base docker image.
PREFERRED_RUBY_VERSION = '2.3.0'
# Keep these up to date. You can find the latest versions by visiting
# rubygems.org and searching for "bundler" and for "foreman".
# Checking about once every month or two should be sufficient.
# (Last checked 2016-01-08.)
BUNDLER_VERSION = '1.11.2'
FOREMAN_VERSION = '0.78.0'
# Mapping from Gemfile versions to rbenv versions with patchlevel.
# Keep this up to date. The canonical version list can be found at
# https://github.com/sstephenson/ruby-build/tree/master/share/ruby-build
# Find the highest patchlevel for each version. (At this point, we expect
# only 2.0.0 to need updating, since earlier versions are end-of-lifed, and
# later versions don't seem to be using patchlevels.)
# Checking about once a quarter should be sufficient.
# (Last checked 2016-01-08.)
RUBY_VERSION_MAP = {
'1.8.6': '1.8.6-p420',
'1.8.7': '1.8.7-p375',
'1.9.1': '1.9.1-p430',
'1.9.2': '1.9.2-p330',
'1.9.3': '1.9.3-p551',
'2.0.0': '2.0.0-p648'
}
# Mapping from gems to libraries they expect.
# We should add to this list as we find more common cases.
GEM_PACKAGES = {
'rgeo': ['libgeos-dev', 'libproj-dev']
}
APP_YAML_CONTENTS = textwrap.dedent("""\
env: flex
runtime: {runtime}
entrypoint: {entrypoint}
""")
DOCKERIGNORE_CONTENTS = textwrap.dedent("""\
.dockerignore
Dockerfile
.git
.hg
.svn
""")
DOCKERFILE_HEADER = textwrap.dedent("""\
# This Dockerfile for a Ruby application was generated by gcloud.
# The base Dockerfile installs:
# * A number of packages needed by the Ruby runtime and by gems
# commonly used in Ruby web apps (such as libsqlite3)
# * A recent version of NodeJS
# * A recent version of the standard Ruby runtime to use by default
# * The bundler and foreman gems
FROM gcr.io/google_appengine/ruby
""")
DOCKERFILE_DEFAULT_INTERPRETER = textwrap.dedent("""\
# This Dockerfile uses the default Ruby interpreter installed and
# specified by the base image.
# If you want to use a specific ruby interpreter, provide a
# .ruby-version file, then delete this Dockerfile and re-run
# "gcloud app gen-config --custom" to recreate it.
""")
DOCKERFILE_CUSTOM_INTERPRETER = textwrap.dedent("""\
# Install ruby {{0}} if not already preinstalled by the base image
RUN cd /rbenv/plugins/ruby-build && \\
git pull && \\
rbenv install -s {{0}} && \\
rbenv global {{0}} && \\
gem install -q --no-rdoc --no-ri bundler --version {0} && \\
gem install -q --no-rdoc --no-ri foreman --version {1}
ENV RBENV_VERSION {{0}}
""".format(BUNDLER_VERSION, FOREMAN_VERSION))
DOCKERFILE_MORE_PACKAGES = textwrap.dedent("""\
# Install additional package dependencies needed by installed gems.
# Feel free to add any more needed by your gems.
RUN apt-get update -y && \\
apt-get install -y -q --no-install-recommends \\
{0} \\
&& apt-get clean && rm /var/lib/apt/lists/*_*
""")
DOCKERFILE_NO_MORE_PACKAGES = textwrap.dedent("""\
# To install additional packages needed by your gems, uncomment
# the "RUN apt-get update" and "RUN apt-get install" lines below
# and specify your packages.
# RUN apt-get update
# RUN apt-get install -y -q (your packages here)
""")
DOCKERFILE_GEM_INSTALL = textwrap.dedent("""\
# Install required gems.
COPY Gemfile Gemfile.lock /app/
RUN bundle install --deployment && rbenv rehash
""")
DOCKERFILE_ENTRYPOINT = textwrap.dedent("""\
# Start application on port 8080.
COPY . /app/
ENTRYPOINT {0}
""")
ENTRYPOINT_FOREMAN = 'foreman start web -p 8080'
ENTRYPOINT_PUMA = 'bundle exec puma -p 8080 -e deployment'
ENTRYPOINT_UNICORN = 'bundle exec unicorn -p 8080 -E deployment'
ENTRYPOINT_RACKUP = 'bundle exec rackup -p 8080 -E deployment config.ru'
class RubyConfigError(exceptions.Error):
"""Error during Ruby application configuration."""
class MissingGemfileError(RubyConfigError):
"""Gemfile is missing."""
class StaleBundleError(RubyConfigError):
"""Bundle is stale and needs a bundle install."""
class RubyConfigurator(ext_runtime.Configurator):
"""Generates configuration for a Ruby app."""
def __init__(self, path, params, ruby_version, entrypoint, packages):
"""Constructor.
Args:
path: (str) Root path of the source tree.
params: (ext_runtime.Params) Parameters passed through to the
fingerprinters.
ruby_version: (str) The ruby interpreter in rbenv format
entrypoint: (str) The entrypoint command
packages: ([str, ...]) A set of packages to install
"""
self.root = path
self.params = params
self.ruby_version = ruby_version
self.entrypoint = entrypoint
self.packages = packages
# Write messages to the console or to the log depending on whether we're
# doing a "deploy."
if params.deploy:
self.notify = log.info
else:
self.notify = log.status.Print
def GenerateConfigs(self):
"""Generates all config files for the module.
Returns:
(bool) True if files were written.
"""
all_config_files = []
if not self.params.appinfo:
all_config_files.append(self._GenerateAppYaml())
if self.params.custom or self.params.deploy:
all_config_files.append(self._GenerateDockerfile())
all_config_files.append(self._GenerateDockerignore())
created = [config_file.WriteTo(self.root, self.notify)
for config_file in all_config_files]
if not any(created):
self.notify('All config files already exist. No files generated.')
return any(created)
def GenerateConfigData(self):
"""Generates all config files for the module.
Returns:
list(ext_runtime.GeneratedFile):
The generated files
"""
if not self.params.appinfo:
app_yaml = self._GenerateAppYaml()
app_yaml.WriteTo(self.root, self.notify)
all_config_files = []
if self.params.custom or self.params.deploy:
all_config_files.append(self._GenerateDockerfile())
all_config_files.append(self._GenerateDockerignore())
return [f for f in all_config_files
if not os.path.exists(os.path.join(self.root, f.filename))]
def _GenerateAppYaml(self):
"""Generates an app.yaml file appropriate to this application.
Returns:
(ext_runtime.GeneratedFile) A file wrapper for app.yaml
"""
app_yaml = os.path.join(self.root, 'app.yaml')
runtime = 'custom' if self.params.custom else 'ruby'
app_yaml_contents = APP_YAML_CONTENTS.format(runtime=runtime,
entrypoint=self.entrypoint)
app_yaml = ext_runtime.GeneratedFile('app.yaml', app_yaml_contents)
return app_yaml
def _GenerateDockerfile(self):
"""Generates a Dockerfile appropriate to this application.
Returns:
(ext_runtime.GeneratedFile) A file wrapper for Dockerignore
"""
dockerfile_content = [DOCKERFILE_HEADER]
if self.ruby_version:
dockerfile_content.append(
DOCKERFILE_CUSTOM_INTERPRETER.format(self.ruby_version))
else:
dockerfile_content.append(DOCKERFILE_DEFAULT_INTERPRETER)
if self.packages:
dockerfile_content.append(
DOCKERFILE_MORE_PACKAGES.format(' '.join(self.packages)))
else:
dockerfile_content.append(DOCKERFILE_NO_MORE_PACKAGES)
dockerfile_content.append(DOCKERFILE_GEM_INSTALL)
dockerfile_content.append(
DOCKERFILE_ENTRYPOINT.format(self.entrypoint))
dockerfile = ext_runtime.GeneratedFile(config.DOCKERFILE,
'\n'.join(dockerfile_content))
return dockerfile
def _GenerateDockerignore(self):
"""Generates a .dockerignore file appropriate to this application."""
dockerignore = os.path.join(self.root, '.dockerignore')
dockerignore = ext_runtime.GeneratedFile('.dockerignore',
DOCKERIGNORE_CONTENTS)
return dockerignore
def Fingerprint(path, params):
"""Check for a Ruby app.
Args:
path: (str) Application path.
params: (ext_runtime.Params) Parameters passed through to the
fingerprinters.
Returns:
(RubyConfigurator or None) Returns a configurator if the path contains a
Ruby app, or None if not.
"""
appinfo = params.appinfo
if not _CheckForRubyRuntime(path, appinfo):
return None
bundler_available = _CheckEnvironment(path)
gems = _DetectGems(bundler_available)
ruby_version = _DetectRubyInterpreter(path, bundler_available)
packages = _DetectNeededPackages(gems)
if appinfo and appinfo.entrypoint:
entrypoint = appinfo.entrypoint
else:
default_entrypoint = _DetectDefaultEntrypoint(path, gems)
entrypoint = _ChooseEntrypoint(default_entrypoint, appinfo)
return RubyConfigurator(path, params, ruby_version, entrypoint, packages)
def _CheckForRubyRuntime(path, appinfo):
"""Determines whether to treat this application as runtime:ruby.
Honors the appinfo runtime setting; otherwise looks at the contents of the
current directory and confirms with the user.
Args:
path: (str) Application path.
appinfo: (apphosting.api.appinfo.AppInfoExternal or None) The parsed
app.yaml file for the module if it exists.
Returns:
(bool) Whether this app should be treated as runtime:ruby.
"""
if appinfo and appinfo.GetEffectiveRuntime() == 'ruby':
return True
log.info('Checking for Ruby.')
gemfile_path = os.path.join(path, 'Gemfile')
if not os.path.isfile(gemfile_path):
return False
got_ruby_message = 'This looks like a Ruby application.'
if console_io.CanPrompt():
return console_io.PromptContinue(
message=got_ruby_message,
prompt_string='Proceed to configure deployment for Ruby?')
else:
log.info(got_ruby_message)
return True
def _CheckEnvironment(path):
"""Gathers information about the local environment, and performs some checks.
Args:
path: (str) Application path.
Returns:
(bool) Whether bundler is available in the environment.
Raises:
RubyConfigError: The application is recognized as a Ruby app but
malformed in some way.
"""
if not os.path.isfile(os.path.join(path, 'Gemfile')):
raise MissingGemfileError('Gemfile is required for Ruby runtime.')
gemfile_lock_present = os.path.isfile(os.path.join(path, 'Gemfile.lock'))
bundler_available = _SubprocessSucceeds('bundle version')
if bundler_available:
if not _SubprocessSucceeds('bundle check'):
raise StaleBundleError('Your bundle is not up-to-date. '
"Install missing gems with 'bundle install'.")
if not gemfile_lock_present:
msg = ('\nNOTICE: We could not find a Gemfile.lock, which suggests this '
'application has not been tested locally, or the Gemfile.lock has '
'not been committed to source control. We have created a '
'Gemfile.lock for you, but it is recommended that you verify it '
'yourself (by installing your bundle and testing locally) to '
'ensure that the gems we deploy are the same as those you tested.')
log.status.Print(msg)
else:
msg = ('\nNOTICE: gcloud could not run bundler in your local environment, '
"and so its ability to determine your application's requirements "
'will be limited. We will still attempt to deploy your application, '
'but if your application has trouble starting up due to missing '
'requirements, we recommend installing bundler by running '
'[gem install bundler]')
log.status.Print(msg)
return bundler_available
def _DetectRubyInterpreter(path, bundler_available):
"""Determines the ruby interpreter and version expected by this application.
Args:
path: (str) Application path.
bundler_available: (bool) Whether bundler is available in the environment.
Returns:
(str or None) The interpreter version in rbenv (.ruby-version) format, or
None to use the base image default.
"""
if bundler_available:
ruby_info = _RunSubprocess('bundle platform --ruby')
if not re.match('^No ', ruby_info):
match = re.match(r'^ruby (\d+\.\d+(\.\d+)?)', ruby_info)
if match:
ruby_version = match.group(1)
ruby_version = RUBY_VERSION_MAP.get(ruby_version, ruby_version)
msg = ('\nUsing Ruby {0} as requested in the Gemfile.'.
format(ruby_version))
log.status.Print(msg)
return ruby_version
# TODO(b/12036082): Recognize JRuby
msg = 'Unrecognized platform in Gemfile: [{0}]'.format(ruby_info)
log.status.Print(msg)
ruby_version = _ReadFile(path, '.ruby-version')
if ruby_version:
ruby_version = ruby_version.strip()
msg = ('\nUsing Ruby {0} as requested in the .ruby-version file'.
format(ruby_version))
log.status.Print(msg)
return ruby_version
msg = ('\nNOTICE: We will deploy your application using a recent version of '
'the standard "MRI" Ruby runtime by default. If you want to use a '
'specific Ruby runtime, you can create a ".ruby-version" file in this '
'directory. (For best performance, we recommend MRI version {0}.)'.
format(PREFERRED_RUBY_VERSION))
log.status.Print(msg)
return None
def _DetectGems(bundler_available):
"""Returns a list of gems requested by this application.
Args:
bundler_available: (bool) Whether bundler is available in the environment.
Returns:
([str, ...]) A list of gem names.
"""
gems = []
if bundler_available:
for line in _RunSubprocess('bundle list').splitlines():
match = re.match(r'\s*\*\s+(\S+)\s+\(', line)
if match:
gems.append(match.group(1))
return gems
def _DetectDefaultEntrypoint(path, gems):
"""Returns the app server expected by this application.
Args:
path: (str) Application path.
gems: ([str, ...]) A list of gems used by this application.
Returns:
(str) The default entrypoint command, or the empty string if unknown.
"""
procfile_path = os.path.join(path, 'Procfile')
if os.path.isfile(procfile_path):
return ENTRYPOINT_FOREMAN
if 'puma' in gems:
return ENTRYPOINT_PUMA
elif 'unicorn' in gems:
return ENTRYPOINT_UNICORN
configru_path = os.path.join(path, 'config.ru')
if os.path.isfile(configru_path):
return ENTRYPOINT_RACKUP
return ''
def _ChooseEntrypoint(default_entrypoint, appinfo):
"""Prompt the user for an entrypoint.
Args:
default_entrypoint: (str) Default entrypoint determined from the app.
appinfo: (apphosting.api.appinfo.AppInfoExternal or None) The parsed
app.yaml file for the module if it exists.
Returns:
(str) The actual entrypoint to use.
Raises:
RubyConfigError: Unable to get entrypoint from the user.
"""
if console_io.CanPrompt():
if default_entrypoint:
prompt = ('\nPlease enter the command to run this Ruby app in '
'production, or leave blank to accept the default:\n[{0}] ')
entrypoint = console_io.PromptResponse(prompt.format(default_entrypoint))
else:
entrypoint = console_io.PromptResponse(
'\nPlease enter the command to run this Ruby app in production: ')
entrypoint = entrypoint.strip()
if not entrypoint:
if not default_entrypoint:
raise RubyConfigError('Entrypoint command is required.')
entrypoint = default_entrypoint
if appinfo:
msg = ('\nTo avoid being asked for an entrypoint in the future, please '
'add it to your app.yaml. e.g.\n entrypoint: {0}'.
format(entrypoint))
log.status.Print(msg)
return entrypoint
else:
msg = ("This appears to be a Ruby app. You'll need to provide the full "
'command to run the app in production, but gcloud is not running '
'interactively and cannot ask for the entrypoint{0}. Please either '
'run gcloud interactively, or create an app.yaml with '
'"runtime:ruby" and an "entrypoint" field.'.
format(ext_runtime_adapter.GetNonInteractiveErrorMessage()))
raise RubyConfigError(msg)
def _DetectNeededPackages(gems):
"""Determines additional apt-get packages required by the given gems.
Args:
gems: ([str, ...]) A list of gems used by this application.
Returns:
([str, ...]) A sorted list of strings indicating packages to install
"""
package_set = set()
for gem in gems:
if gem in GEM_PACKAGES:
package_set.update(GEM_PACKAGES[gem])
packages = list(package_set)
packages.sort()
return packages
def _RunSubprocess(cmd):
p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
if p.wait() != 0:
raise RubyConfigError('Unable to run script: [{0}]'.format(cmd))
return p.stdout.read()
def _SubprocessSucceeds(cmd):
p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
return p.wait() == 0
def _ReadFile(root, filename, required=False):
path = os.path.join(root, filename)
if not os.path.isfile(path):
if required:
raise RubyConfigError(
'Could not find required file: [{0}]'.format(filename))
return None
return files.ReadFileContents(path)