546 lines
18 KiB
Python
546 lines
18 KiB
Python
# -*- 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)
|