blob: a2e61c49d88db3e7e62301475ecb4ab7e5d3b6e2 [file] [log] [blame]
# Copyright 2015 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.
"""Cloud Endpoints API for configs.
* Reads/writes config service location.
* Validates configs.
* Provides service metadata.
"""
import logging
from protorpc import messages
from protorpc import message_types
from protorpc import remote
import endpoints
from components import auth
from . import common
from . import validation
METADATA_FORMAT_VERSION = "1.0"
class ConfigSettingsMessage(messages.Message):
"""Configuration service location. Resembles common.ConfigSettings"""
# Example: 'luci-config.appspot.com'
service_hostname = messages.StringField(1)
# Example: 'user:luci-config@appspot.gserviceaccount.com'
trusted_config_account = messages.StringField(2)
class ValidateRequestMessage(messages.Message):
config_set = messages.StringField(1, required=True)
path = messages.StringField(2, required=True)
content = messages.BytesField(3, required=True)
class ValidationMessage(messages.Message):
path = messages.StringField(1)
severity = messages.EnumField(common.Severity, 2, required=True)
text = messages.StringField(3, required=True)
class ValidateResponseMessage(messages.Message):
messages = messages.MessageField(ValidationMessage, 1, repeated=True)
def is_trusted_requester():
"""Returns True if the requester can see the service metadata.
Used in metadata endpoint.
Returns:
True if the current identity is an admin or the config service.
"""
if auth.is_superuser() or auth.is_admin():
return True
settings = common.ConfigSettings.cached()
if settings and settings.trusted_config_account:
identity = auth.get_current_identity()
if identity == settings.trusted_config_account:
return True
# TODO(yiwzhang): Temporarily allow both old and new LUCI Config service
# account to make the validation request. Revert this change after the old
# LUCI Config service is fully deprecated and all traffic have been
# migrated to the new LUCI Config service.
allowed_group = 'service-accounts-luci-config'
if 'dev' in settings.service_hostname:
allowed_group += '-dev'
if auth.is_group_member(allowed_group, identity):
return True
return False
class ConfigPattern(messages.Message):
"""A pattern for one config file. See ServiceDynamicMetadata."""
config_set = messages.StringField(1, required=True)
path = messages.StringField(2, required=True)
class ServiceDynamicMetadata(messages.Message):
"""Equivalent of config_service's ServiceDynamicMetadata proto message.
Keep this class in sync with:
* ServiceDynamicMetadata message in
appengine/config_service/proto/service_config.proto
* validation.validate_service_dynamic_metadata_blob()
* services._dict_to_dynamic_metadata()
"""
class Validator(messages.Message):
patterns = messages.MessageField(ConfigPattern, 1, repeated=True)
url = messages.StringField(2, required=True)
version = messages.StringField(1, required=True)
validation = messages.MessageField(Validator, 2)
supports_gzip_compression = messages.BooleanField(3, default=False)
@auth.endpoints_api(name='config', version='v1', title='Configuration service')
class ConfigApi(remote.Service):
"""Configuration service."""
@auth.endpoints_method(
ConfigSettingsMessage, ConfigSettingsMessage,
http_method='POST')
@auth.require(lambda: auth.is_superuser() or auth.is_admin())
def settings(self, request):
"""Reads/writes config service location. Accessible only by admins."""
settings = common.ConfigSettings.fetch() or common.ConfigSettings()
delta = {}
if request.service_hostname is not None:
delta['service_hostname'] = request.service_hostname
if request.trusted_config_account is not None:
try:
delta['trusted_config_account'] = auth.Identity.from_bytes(
request.trusted_config_account)
except ValueError as ex:
raise endpoints.BadRequestException(
'Invalid trusted_config_account %s: %s' % (
request.trusted_config_account,
ex.message))
changed = settings.modify(
updated_by=auth.get_current_identity().to_bytes(),
**delta)
if changed:
logging.warning('Updated config settings')
settings = common.ConfigSettings.fetch() or settings
return ConfigSettingsMessage(
service_hostname=settings.service_hostname,
trusted_config_account=(
settings.trusted_config_account.to_bytes()
if settings.trusted_config_account else None)
)
@auth.endpoints_method(
ValidateRequestMessage, ValidateResponseMessage, http_method='POST')
@auth.require(is_trusted_requester)
def validate(self, request):
"""Validates a config.
Compatible with validation protocol described in ValidationCfg message of
/appengine/config_service/proto/service_config.proto.
"""
ctx = validation.Context()
validation.validate(request.config_set, request.path, request.content, ctx)
res = ValidateResponseMessage()
for m in ctx.result().messages:
text = m.text
if isinstance(m.text, str):
text = text.decode('ascii', errors='replace')
res.messages.append(ValidationMessage(
path=request.path,
severity=common.Severity.lookup_by_number(m.severity),
text=text,
))
return res
@auth.endpoints_method(
message_types.VoidMessage, ServiceDynamicMetadata,
http_method='GET', path='metadata')
@auth.require(is_trusted_requester)
def get_metadata(self, _request):
"""Describes a service. Used by config service to discover other services.
"""
meta = ServiceDynamicMetadata(version=METADATA_FORMAT_VERSION)
http_headers = dict(self.request_state.headers)
assert 'host' in http_headers or 'Host' in http_headers, http_headers
meta.validation = meta.Validator(
url='https://{hostname}/_ah/api/{name}/{version}/{path}validate'.format(
hostname=http_headers.get('host') or http_headers['Host'],
name=self.api_info.name,
version=self.api_info.version,
path=self.api_info.path or '',
)
)
for p in sorted(validation.DEFAULT_RULE_SET.patterns()):
meta.validation.patterns.append(ConfigPattern(**p._asdict()))
return meta