blob: 48079df0b00cab3082fa3570ea9a2a192438e397 [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# 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.
#
"""Stores application configuration taken from e.g. app.yaml, queues.yaml."""
# TODO: Support more than just app.yaml.
import errno
import logging
import os
import os.path
import random
import string
import threading
import types
from google.appengine.api import appinfo
from google.appengine.api import appinfo_includes
from google.appengine.api import backendinfo
from google.appengine.api import dispatchinfo
from google.appengine.tools.devappserver2 import errors
# Constants passed to functions registered with
# ModuleConfiguration.add_change_callback.
NORMALIZED_LIBRARIES_CHANGED = 1
SKIP_FILES_CHANGED = 2
HANDLERS_CHANGED = 3
INBOUND_SERVICES_CHANGED = 4
ENV_VARIABLES_CHANGED = 5
ERROR_HANDLERS_CHANGED = 6
NOBUILD_FILES_CHANGED = 7
class ModuleConfiguration(object):
"""Stores module configuration information.
Most configuration options are mutable and may change any time
check_for_updates is called. Client code must be able to cope with these
changes.
Other properties are immutable (see _IMMUTABLE_PROPERTIES) and are guaranteed
to be constant for the lifetime of the instance.
"""
_IMMUTABLE_PROPERTIES = [
('application', 'application'),
('version', 'major_version'),
('runtime', 'runtime'),
('threadsafe', 'threadsafe'),
('module', 'module_name'),
('basic_scaling', 'basic_scaling'),
('manual_scaling', 'manual_scaling'),
('automatic_scaling', 'automatic_scaling')]
def __init__(self, yaml_path):
"""Initializer for ModuleConfiguration.
Args:
yaml_path: A string containing the full path of the yaml file containing
the configuration for this module.
"""
self._yaml_path = yaml_path
self._app_info_external = None
self._application_root = os.path.realpath(os.path.dirname(yaml_path))
self._last_failure_message = None
self._app_info_external, files_to_check = self._parse_configuration(
self._yaml_path)
self._mtimes = self._get_mtimes([self._yaml_path] + files_to_check)
self._application = 'dev~%s' % self._app_info_external.application
self._api_version = self._app_info_external.api_version
self._module_name = self._app_info_external.module
self._version = self._app_info_external.version
self._threadsafe = self._app_info_external.threadsafe
self._basic_scaling = self._app_info_external.basic_scaling
self._manual_scaling = self._app_info_external.manual_scaling
self._automatic_scaling = self._app_info_external.automatic_scaling
self._runtime = self._app_info_external.runtime
if self._runtime == 'python':
logging.warning(
'The "python" runtime specified in "%s" is not supported - the '
'"python27" runtime will be used instead. A description of the '
'differences between the two can be found here:\n'
'https://developers.google.com/appengine/docs/python/python25/diff27',
self._yaml_path)
self._minor_version_id = ''.join(random.choice(string.digits) for _ in
range(18))
@property
def application_root(self):
"""The directory containing the application e.g. "/home/user/myapp"."""
return self._application_root
@property
def application(self):
return self._application
@property
def api_version(self):
return self._api_version
@property
def module_name(self):
return self._module_name or appinfo.DEFAULT_MODULE
@property
def major_version(self):
return self._version
@property
def version_id(self):
if self.module_name == appinfo.DEFAULT_MODULE:
return '%s.%s' % (
self.major_version,
self._minor_version_id)
else:
return '%s:%s.%s' % (
self.module_name,
self.major_version,
self._minor_version_id)
@property
def runtime(self):
return self._runtime
@property
def threadsafe(self):
return self._threadsafe
@property
def basic_scaling(self):
return self._basic_scaling
@property
def manual_scaling(self):
return self._manual_scaling
@property
def automatic_scaling(self):
return self._automatic_scaling
@property
def normalized_libraries(self):
return self._app_info_external.GetNormalizedLibraries()
@property
def skip_files(self):
return self._app_info_external.skip_files
@property
def nobuild_files(self):
return self._app_info_external.nobuild_files
@property
def error_handlers(self):
return self._app_info_external.error_handlers
@property
def handlers(self):
return self._app_info_external.handlers
@property
def inbound_services(self):
return self._app_info_external.inbound_services
@property
def env_variables(self):
return self._app_info_external.env_variables
@property
def is_backend(self):
return False
def check_for_updates(self):
"""Return any configuration changes since the last check_for_updates call.
Returns:
A set containing the changes that occured. See the *_CHANGED module
constants.
"""
new_mtimes = self._get_mtimes(self._mtimes.keys())
if new_mtimes == self._mtimes:
return set()
try:
app_info_external, files_to_check = self._parse_configuration(
self._yaml_path)
except Exception, e:
failure_message = str(e)
if failure_message != self._last_failure_message:
logging.error('Configuration is not valid: %s', failure_message)
self._last_failure_message = failure_message
return set()
self._last_failure_message = None
self._mtimes = self._get_mtimes([self._yaml_path] + files_to_check)
for app_info_attribute, self_attribute in self._IMMUTABLE_PROPERTIES:
app_info_value = getattr(app_info_external, app_info_attribute)
self_value = getattr(self, self_attribute)
if (app_info_value == self_value or
app_info_value == getattr(self._app_info_external,
app_info_attribute)):
# Only generate a warning if the value is both different from the
# immutable value *and* different from the last loaded value.
continue
if isinstance(app_info_value, types.StringTypes):
logging.warning('Restart the development module to see updates to "%s" '
'["%s" => "%s"]',
app_info_attribute,
self_value,
app_info_value)
else:
logging.warning('Restart the development module to see updates to "%s"',
app_info_attribute)
changes = set()
if (app_info_external.GetNormalizedLibraries() !=
self.normalized_libraries):
changes.add(NORMALIZED_LIBRARIES_CHANGED)
if app_info_external.skip_files != self.skip_files:
changes.add(SKIP_FILES_CHANGED)
if app_info_external.nobuild_files != self.nobuild_files:
changes.add(NOBUILD_FILES_CHANGED)
if app_info_external.handlers != self.handlers:
changes.add(HANDLERS_CHANGED)
if app_info_external.inbound_services != self.inbound_services:
changes.add(INBOUND_SERVICES_CHANGED)
if app_info_external.env_variables != self.env_variables:
changes.add(ENV_VARIABLES_CHANGED)
if app_info_external.error_handlers != self.error_handlers:
changes.add(ERROR_HANDLERS_CHANGED)
self._app_info_external = app_info_external
if changes:
self._minor_version_id = ''.join(random.choice(string.digits) for _ in
range(18))
return changes
@staticmethod
def _get_mtimes(filenames):
filename_to_mtime = {}
for filename in filenames:
try:
filename_to_mtime[filename] = os.path.getmtime(filename)
except OSError as e:
# Ignore deleted includes.
if e.errno != errno.ENOENT:
raise
return filename_to_mtime
@staticmethod
def _parse_configuration(configuration_path):
# TODO: It probably makes sense to catch the exception raised
# by Parse() and re-raise it using a module-specific exception.
with open(configuration_path) as f:
return appinfo_includes.ParseAndReturnIncludePaths(f)
class BackendsConfiguration(object):
"""Stores configuration information for a backends.yaml file."""
def __init__(self, app_yaml_path, backend_yaml_path):
"""Initializer for BackendsConfiguration.
Args:
app_yaml_path: A string containing the full path of the yaml file
containing the configuration for this module.
backend_yaml_path: A string containing the full path of the backends.yaml
file containing the configuration for backends.
"""
self._update_lock = threading.RLock()
self._base_module_configuration = ModuleConfiguration(app_yaml_path)
backend_info_external = self._parse_configuration(
backend_yaml_path)
self._backends_name_to_backend_entry = {}
for backend in backend_info_external.backends or []:
self._backends_name_to_backend_entry[backend.name] = backend
self._changes = dict(
(backend_name, set())
for backend_name in self._backends_name_to_backend_entry)
@staticmethod
def _parse_configuration(configuration_path):
# TODO: It probably makes sense to catch the exception raised
# by Parse() and re-raise it using a module-specific exception.
with open(configuration_path) as f:
return backendinfo.LoadBackendInfo(f)
def get_backend_configurations(self):
return [BackendConfiguration(self._base_module_configuration, self, entry)
for entry in self._backends_name_to_backend_entry.values()]
def check_for_updates(self, backend_name):
"""Return any configuration changes since the last check_for_updates call.
Args:
backend_name: A str containing the name of the backend to be checked for
updates.
Returns:
A set containing the changes that occured. See the *_CHANGED module
constants.
"""
with self._update_lock:
module_changes = self._base_module_configuration.check_for_updates()
if module_changes:
for backend_changes in self._changes.values():
backend_changes.update(module_changes)
changes = self._changes[backend_name]
self._changes[backend_name] = set()
return changes
class BackendConfiguration(object):
"""Stores backend configuration information.
This interface is and must remain identical to ModuleConfiguration.
"""
def __init__(self, module_configuration, backends_configuration,
backend_entry):
"""Initializer for BackendConfiguration.
Args:
module_configuration: A ModuleConfiguration to use.
backends_configuration: The BackendsConfiguration that tracks updates for
this BackendConfiguration.
backend_entry: A backendinfo.BackendEntry containing the backend
configuration.
"""
self._module_configuration = module_configuration
self._backends_configuration = backends_configuration
self._backend_entry = backend_entry
if backend_entry.dynamic:
self._basic_scaling = appinfo.BasicScaling(
max_instances=backend_entry.instances or 1)
self._manual_scaling = None
else:
self._basic_scaling = None
self._manual_scaling = appinfo.ManualScaling(
instances=backend_entry.instances or 1)
self._minor_version_id = ''.join(random.choice(string.digits) for _ in
range(18))
@property
def application_root(self):
"""The directory containing the application e.g. "/home/user/myapp"."""
return self._module_configuration.application_root
@property
def application(self):
return self._module_configuration.application
@property
def api_version(self):
return self._module_configuration.api_version
@property
def module_name(self):
return self._backend_entry.name
@property
def major_version(self):
return self._module_configuration.major_version
@property
def version_id(self):
return '%s:%s.%s' % (
self.module_name,
self.major_version,
self._minor_version_id)
@property
def runtime(self):
return self._module_configuration.runtime
@property
def threadsafe(self):
return self._module_configuration.threadsafe
@property
def basic_scaling(self):
return self._basic_scaling
@property
def manual_scaling(self):
return self._manual_scaling
@property
def automatic_scaling(self):
return None
@property
def normalized_libraries(self):
return self._module_configuration.normalized_libraries
@property
def skip_files(self):
return self._module_configuration.skip_files
@property
def nobuild_files(self):
return self._module_configuration.nobuild_files
@property
def error_handlers(self):
return self._module_configuration.error_handlers
@property
def handlers(self):
if self._backend_entry.start:
return [appinfo.URLMap(
url='/_ah/start',
script=self._backend_entry.start,
login='admin')] + self._module_configuration.handlers
return self._module_configuration.handlers
@property
def inbound_services(self):
return self._module_configuration.inbound_services
@property
def env_variables(self):
return self._module_configuration.env_variables
@property
def is_backend(self):
return True
def check_for_updates(self):
"""Return any configuration changes since the last check_for_updates call.
Returns:
A set containing the changes that occured. See the *_CHANGED module
constants.
"""
changes = self._backends_configuration.check_for_updates(
self._backend_entry.name)
if changes:
self._minor_version_id = ''.join(random.choice(string.digits) for _ in
range(18))
return changes
class DispatchConfiguration(object):
"""Stores dispatcher configuration information."""
def __init__(self, yaml_path):
self._yaml_path = yaml_path
self._mtime = os.path.getmtime(self._yaml_path)
self._process_dispatch_entries(self._parse_configuration(self._yaml_path))
@staticmethod
def _parse_configuration(configuration_path):
# TODO: It probably makes sense to catch the exception raised
# by LoadSingleDispatch() and re-raise it using a module-specific exception.
with open(configuration_path) as f:
return dispatchinfo.LoadSingleDispatch(f)
def check_for_updates(self):
mtime = os.path.getmtime(self._yaml_path)
if mtime > self._mtime:
self._mtime = mtime
try:
dispatch_info_external = self._parse_configuration(self._yaml_path)
except Exception, e:
failure_message = str(e)
logging.error('Configuration is not valid: %s', failure_message)
return
self._process_dispatch_entries(dispatch_info_external)
def _process_dispatch_entries(self, dispatch_info_external):
path_only_entries = []
hostname_entries = []
for entry in dispatch_info_external.dispatch:
parsed_url = dispatchinfo.ParsedURL(entry.url)
if parsed_url.host:
hostname_entries.append(entry)
else:
path_only_entries.append((parsed_url, entry.module))
if hostname_entries:
logging.warning(
'Hostname routing is not supported by the development server. The '
'following dispatch entries will not match any requests:\n%s',
'\n\t'.join(str(entry) for entry in hostname_entries))
self._entries = path_only_entries
@property
def dispatch(self):
return self._entries
class ApplicationConfiguration(object):
"""Stores application configuration information."""
def __init__(self, yaml_paths):
"""Initializer for ApplicationConfiguration.
Args:
yaml_paths: A list of strings containing the paths to yaml files.
"""
self.modules = []
self.dispatch = None
if len(yaml_paths) == 1 and os.path.isdir(yaml_paths[0]):
directory_path = yaml_paths[0]
for app_yaml_path in [os.path.join(directory_path, 'app.yaml'),
os.path.join(directory_path, 'app.yml')]:
if os.path.exists(app_yaml_path):
yaml_paths = [app_yaml_path]
break
else:
raise errors.AppConfigNotFoundError(
'no app.yaml file at %r' % directory_path)
for backends_yaml_path in [os.path.join(directory_path, 'backends.yaml'),
os.path.join(directory_path, 'backends.yml')]:
if os.path.exists(backends_yaml_path):
yaml_paths.append(backends_yaml_path)
break
for yaml_path in yaml_paths:
if os.path.isdir(yaml_path):
raise errors.InvalidAppConfigError(
'"%s" is a directory and a yaml configuration file is required' %
yaml_path)
elif (yaml_path.endswith('backends.yaml') or
yaml_path.endswith('backends.yml')):
# TODO: Reuse the ModuleConfiguration created for the app.yaml
# instead of creating another one for the same file.
self.modules.extend(
BackendsConfiguration(yaml_path.replace('backends.y', 'app.y'),
yaml_path).get_backend_configurations())
elif (yaml_path.endswith('dispatch.yaml') or
yaml_path.endswith('dispatch.yml')):
if self.dispatch:
raise errors.InvalidAppConfigError(
'Multiple dispatch.yaml files specified')
self.dispatch = DispatchConfiguration(yaml_path)
else:
module_configuration = ModuleConfiguration(yaml_path)
self.modules.append(module_configuration)
application_ids = set(module.application
for module in self.modules)
if len(application_ids) > 1:
raise errors.InvalidAppConfigError(
'More than one application ID found: %s' %
', '.join(sorted(application_ids)))
self._app_id = application_ids.pop()
module_names = set()
for module in self.modules:
if module.module_name in module_names:
raise errors.InvalidAppConfigError('Duplicate module: %s' %
module.module_name)
module_names.add(module.module_name)
if self.dispatch:
if appinfo.DEFAULT_MODULE not in module_names:
raise errors.InvalidAppConfigError(
'A default module must be specified.')
missing_modules = (
set(module_name for _, module_name in self.dispatch.dispatch) -
module_names)
if missing_modules:
raise errors.InvalidAppConfigError(
'Modules %s specified in dispatch.yaml are not defined by a yaml '
'file.' % sorted(missing_modules))
@property
def app_id(self):
return self._app_id