blob: 40a15995e53ac66154ef1aa7121f19243a84814b [file] [log] [blame]
# Copyright 2013 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""Instance specific settings."""
import logging
import posixpath
import re
from google.appengine.api import app_identity
from google.appengine.api import modules
from google.appengine.ext import ndb
from components import config
from components import gitiles
from components import net
from components import utils
from components.config import validation
from components.datastore_utils import config as ds_config
from proto import config_pb2
ConfigApi = config.ConfigApi
### Public code.
class Config(object):
"""A join of Datastore and config_service backed configs."""
def __init__(self, ds_cfg, cfg):
self._ds_cfg = ds_cfg
self._cfg = cfg
def __getattr__(self, name):
if hasattr(self._cfg, name):
return getattr(self._cfg, name)
return getattr(self._ds_cfg, name)
class GlobalConfig(ds_config.GlobalConfig):
"""Application wide settings."""
# Secret key used to sign Google Storage URLs: base64 encoded *.der file.
gs_private_key = ndb.StringProperty(indexed=False, default='')
def settings(fresh=False):
"""Loads GlobalConfig or a default one if not present.
If fresh=True, a full fetch from NDB is done.
"""
if fresh:
GlobalConfig.clear_cache()
cfg = _get_settings_with_defaults()[1]
else:
cfg = _get_settings_cached()
ds_cfg = GlobalConfig.cached()
return Config(ds_cfg, cfg)
def settings_info():
"""Returns information about the settings file.
Returns a dict with keys:
'cfg': parsed SettingsCfg message
'rev': revision of cfg
'rev_url': URL of a human-consumable page that displays the config
'config_service_url': URL of the config_service.
"""
GlobalConfig.clear_cache()
ds_cfg = GlobalConfig.cached()
rev, cfg = _get_settings_with_defaults()
rev_url = _gitiles_url(_get_configs_url(), rev, _SETTINGS_CFG_FILENAME)
cfg_service_hostname = config.config_service_hostname()
return {
'cfg': ds_cfg,
'config_service_url':
'https://%s' % cfg_service_hostname if cfg_service_hostname else '',
'luci_cfg': cfg,
'rev': rev,
'rev_url': rev_url,
}
def get_local_dev_server_host():
"""Returns 'hostname:port' for a default module on a local dev server."""
assert utils.is_local_dev_server()
return modules.get_hostname(module='default')
def warmup():
"""Precaches configuration in local memory, to be called from warmup handler.
This call is optional. Everything works even if 'warmup' is never called.
"""
settings()
utils.get_task_queue_host()
utils.get_app_version()
### Private code.
_SETTINGS_CFG_FILENAME = 'settings.cfg'
_GS_BUCKET_RE = re.compile(r'^[a-z0-9A-Z\-]+$')
_EMAIL_RE = re.compile(r'^[a-z0-9A-Z\-\._+]+@[a-z0-9A-Z\-\._]+$')
@validation.self_rule(_SETTINGS_CFG_FILENAME, config_pb2.SettingsCfg)
def _validate_settings(cfg, ctx):
"""Validates settings.cfg file against proto message schema."""
with ctx.prefix('default_expiration '):
if cfg.default_expiration < 0:
ctx.error('cannot be negative')
with ctx.prefix('sharding_letters '):
if not (0 <= cfg.sharding_letters and cfg.sharding_letters <= 5):
ctx.error('must be within [0..5]')
with ctx.prefix('gs_bucket '):
if not _GS_BUCKET_RE.match(cfg.gs_bucket):
ctx.error('invalid value: %s', cfg.gs_bucket)
if cfg.HasField('gs_client_id_email'):
with ctx.prefix('gs_client_id_email '):
if not _EMAIL_RE.match(cfg.gs_client_id_email):
ctx.error('invalid value: %s', cfg.gs_client_id_email)
@utils.memcache('config:get_configs_url', time=60)
def _get_configs_url():
"""Returns URL where luci-config fetches configs from."""
try:
return config.get_config_set_location(config.self_config_set())
except net.Error:
logging.info(
'Could not get configs URL. Possibly config directory for this '
'instance of swarming does not exist')
def _gitiles_url(configs_url, rev, path):
"""URL to a directory in gitiles -> URL to a file at concrete revision."""
try:
loc = gitiles.Location.parse(configs_url or '')
return str(loc._replace(
treeish=rev or loc.treeish,
path=posixpath.join(loc.path, path)))
except ValueError:
# Not a gitiles URL, return as is.
return configs_url
def _get_settings():
"""Returns (rev, cfg) where cfg is a parsed SettingsCfg message.
If config does not exists, returns (None, None).
Mock this method in tests to inject changes to the defaults.
"""
# store_last_good=True tells config component to update the config file
# in a cron job. Here we just read from the datastore.
return config.get_self_config(
_SETTINGS_CFG_FILENAME, config_pb2.SettingsCfg, store_last_good=True)
def _get_settings_with_defaults():
"""Returns (rev, cfg) where cfg is a parsed SettingsCfg message.
If config does not exists, returns (None, <cfg with defaults>).
The config is cached in the datastore.
"""
rev, cfg = _get_settings()
cfg = cfg or config_pb2.SettingsCfg()
cfg.default_expiration = cfg.default_expiration or 30*24*60*60
cfg.sharding_letters = cfg.sharding_letters or 4
cfg.gs_bucket = cfg.gs_bucket or app_identity.get_application_id()
cfg.auth.full_access_group = cfg.auth.full_access_group or 'administrators'
cfg.auth.readonly_access_group = \
cfg.auth.readonly_access_group or 'administrators'
return rev, cfg
@utils.cache_with_expiration(60)
def _get_settings_cached():
"""Loads settings from an NDB-based cache or a default one if not present."""
return _get_settings_with_defaults()[1]