From 7abae80c61f5c6779357c8bcf7e9dcaaa4ed20a3 Mon Sep 17 00:00:00 2001 From: Endre Karlson Date: Mon, 15 Feb 2016 16:46:55 +0100 Subject: [PATCH] Add support for getting Service Status Change-Id: Iaf10d8486ac8015ecf9f394dfbf074bfb863fb78 --- designate/api/service.py | 18 +++ designate/api/v2/controllers/root.py | 2 + .../api/v2/controllers/service_status.py | 61 +++++++ designate/central/rpcapi.py | 26 ++- designate/central/service.py | 41 ++++- designate/exceptions.py | 8 + designate/objects/__init__.py | 1 + designate/objects/adapters/__init__.py | 1 + .../objects/adapters/api_v2/service_status.py | 65 ++++++++ designate/objects/base.py | 6 + designate/objects/service_status.py | 61 +++++++ designate/service.py | 20 ++- designate/service_status.py | 114 ++++++++++++++ designate/storage/base.py | 34 ++++ designate/storage/impl_sqlalchemy/__init__.py | 29 ++++ .../migrate_repo/versions/097_add_services.py | 52 ++++++ designate/storage/impl_sqlalchemy/tables.py | 23 +++ designate/tests/__init__.py | 30 ++++ .../test_api/test_v2/test_service_status.py | 84 ++++++++++ .../tests/test_storage/test_sqlalchemy.py | 1 + designate/tests/unit/test_service_status.py | 105 ++++++++++++ doc/service_status.rst | 43 +++++ doc/source/index.rst | 1 + doc/source/rest.rst | 1 + doc/source/rest/v2/service_status.rst | 149 ++++++++++++++++++ etc/designate/policy.json | 5 +- setup.cfg | 5 + 27 files changed, 981 insertions(+), 5 deletions(-) create mode 100644 designate/api/v2/controllers/service_status.py create mode 100644 designate/objects/adapters/api_v2/service_status.py create mode 100644 designate/objects/service_status.py create mode 100644 designate/service_status.py create mode 100644 designate/storage/impl_sqlalchemy/migrate_repo/versions/097_add_services.py create mode 100644 designate/tests/test_api/test_v2/test_service_status.py create mode 100644 designate/tests/unit/test_service_status.py create mode 100644 doc/service_status.rst create mode 100644 doc/source/rest/v2/service_status.rst diff --git a/designate/api/service.py b/designate/api/service.py index eca2ec4ab..2c238bc8e 100644 --- a/designate/api/service.py +++ b/designate/api/service.py @@ -21,6 +21,7 @@ from designate.i18n import _LI from designate import exceptions from designate import utils from designate import service +from designate import service_status LOG = logging.getLogger(__name__) @@ -30,6 +31,23 @@ class Service(service.WSGIService, service.Service): def __init__(self, threads=None): super(Service, self).__init__(threads=threads) + emitter_cls = service_status.HeartBeatEmitter.get_driver( + cfg.CONF.heartbeat_emitter.emitter_type + ) + self.heartbeat_emitter = emitter_cls( + self.service_name, self.tg, status_factory=self._get_status + ) + + def start(self): + super(Service, self).start() + self.heartbeat_emitter.start() + + def _get_status(self): + status = "UP" + stats = {} + capabilities = {} + return status, stats, capabilities + @property def service_name(self): return 'api' diff --git a/designate/api/v2/controllers/root.py b/designate/api/v2/controllers/root.py index 2dfacad7e..b7579cf24 100644 --- a/designate/api/v2/controllers/root.py +++ b/designate/api/v2/controllers/root.py @@ -23,6 +23,7 @@ from designate.api.v2.controllers import tlds from designate.api.v2.controllers import blacklists from designate.api.v2.controllers import errors from designate.api.v2.controllers import pools +from designate.api.v2.controllers import service_status from designate.api.v2.controllers import zones from designate.api.v2.controllers import tsigkeys @@ -57,4 +58,5 @@ class RootController(object): blacklists = blacklists.BlacklistsController() errors = errors.ErrorsController() pools = pools.PoolsController() + service_statuses = service_status.ServiceStatusController() tsigkeys = tsigkeys.TsigKeysController() diff --git a/designate/api/v2/controllers/service_status.py b/designate/api/v2/controllers/service_status.py new file mode 100644 index 000000000..253bd9aeb --- /dev/null +++ b/designate/api/v2/controllers/service_status.py @@ -0,0 +1,61 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# 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. + +import pecan +from oslo_log import log as logging + +from designate import utils +from designate.api.v2.controllers import rest +from designate.objects.adapters import DesignateAdapter + +LOG = logging.getLogger(__name__) + + +class ServiceStatusController(rest.RestController): + SORT_KEYS = ['created_at', 'id', 'updated_at', 'hostname', 'service_name', + 'status'] + + @pecan.expose(template='json:', content_type='application/json') + def get_all(self, **params): + request = pecan.request + context = pecan.request.environ['context'] + + marker, limit, sort_key, sort_dir = utils.get_paging_params( + params, self.SORT_KEYS) + + accepted_filters = ["hostname", "service_name", "status"] + criterion = self._apply_filter_params( + params, accepted_filters, {}) + + service_statuses = self.central_api.find_service_statuses( + context, criterion, ) + + return DesignateAdapter.render( + 'API_v2', + service_statuses, + request=request) + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('service_id') + def get_one(self, service_id): + """Get Service Status""" + request = pecan.request + context = request.environ['context'] + + criterion = {"id": service_id} + service_status = self.central_api.find_service_status( + context, criterion) + + return DesignateAdapter.render( + 'API_v2', service_status, request=request) diff --git a/designate/central/rpcapi.py b/designate/central/rpcapi.py index 1f29e5945..c87fd6b65 100644 --- a/designate/central/rpcapi.py +++ b/designate/central/rpcapi.py @@ -54,14 +54,15 @@ class CentralAPI(object): 5.5 - Add deleted zone purging task 5.6 - Changed 'purge_zones' function args 6.0 - Renamed domains to zones + 6.1 - Add ServiceStatus methods """ - RPC_API_VERSION = '6.0' + RPC_API_VERSION = '6.1' def __init__(self, topic=None): topic = topic if topic else cfg.CONF.central_topic target = messaging.Target(topic=topic, version=self.RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap='6.0') + self.client = rpc.get_client(target, version_cap='6.1') @classmethod def get_instance(cls): @@ -578,3 +579,24 @@ class CentralAPI(object): "delete_zone_export.")) return self.client.call(context, 'delete_zone_export', zone_export_id=zone_export_id) + + def find_service_status(self, context, criterion=None): + LOG.info(_LI("find_service_status: Calling central's " + "find_service_status.")) + return self.client.call(context, 'find_service_status', + criterion=criterion) + + def find_service_statuses(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + LOG.info(_LI("find_service_statuses: Calling central's " + "find_service_statuses.")) + return self.client.call(context, 'find_service_statuses', + criterion=criterion, marker=marker, + limit=limit, sort_key=sort_key, + sort_dir=sort_dir) + + def update_service_status(self, context, service_status): + LOG.info(_LI("update_service_status: Calling central's " + "update_service_status.")) + self.client.cast(context, 'update_service_status', + service_status=service_status) diff --git a/designate/central/service.py b/designate/central/service.py index 4756d549a..4d1fbafc4 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -263,7 +263,7 @@ def notification(notification_type): class Service(service.RPCService, service.Service): - RPC_API_VERSION = '6.0' + RPC_API_VERSION = '6.1' target = messaging.Target(version=RPC_API_VERSION) @@ -272,6 +272,10 @@ class Service(service.RPCService, service.Service): self.network_api = network_api.get_network_api(cfg.CONF.network_api) + # update_service_status needs is called by the emitter so we pass + # ourselves as the rpc_api. + self.heartbeat_emitter.rpc_api = self + @property def scheduler(self): if not hasattr(self, '_scheduler'): @@ -2880,3 +2884,38 @@ class Service(service.RPCService, service.Service): zone_export = self.storage.delete_zone_export(context, zone_export_id) return zone_export + + def find_service_statuses(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + """List service statuses. + """ + policy.check('find_service_statuses', context) + + return self.storage.find_service_statuses( + context, criterion, marker, limit, sort_key, sort_dir) + + def find_service_status(self, context, criterion=None): + policy.check('find_service_status', context) + + return self.storage.find_service_status(context, criterion) + + def update_service_status(self, context, service_status): + policy.check('update_service_status', context) + + criterion = { + "service_name": service_status.service_name, + "hostname": service_status.hostname + } + + if service_status.obj_attr_is_set('id'): + criterion["id"] = service_status.id + + try: + db_status = self.storage.find_service_status( + context, criterion) + db_status.update(dict(service_status)) + + return self.storage.update_service_status(context, db_status) + except exceptions.ServiceStatusNotFound: + return self.storage.create_service_status( + context, service_status) diff --git a/designate/exceptions.py b/designate/exceptions.py index beb7dea68..7462c1416 100644 --- a/designate/exceptions.py +++ b/designate/exceptions.py @@ -247,6 +247,10 @@ class Duplicate(Base): error_type = 'duplicate' +class DuplicateServiceStatus(Duplicate): + error_type = 'duplicate_service_status' + + class DuplicateQuota(Duplicate): error_type = 'duplicate_quota' @@ -351,6 +355,10 @@ class NotFound(Base): error_type = 'not_found' +class ServiceStatusNotFound(NotFound): + error_type = 'service_status_not_found' + + class QuotaNotFound(NotFound): error_type = 'quota_not_found' diff --git a/designate/objects/__init__.py b/designate/objects/__init__.py index f0a631c4b..24c1b55d2 100644 --- a/designate/objects/__init__.py +++ b/designate/objects/__init__.py @@ -34,6 +34,7 @@ from designate.objects.quota import Quota, QuotaList # noqa from designate.objects.record import Record, RecordList # noqa from designate.objects.recordset import RecordSet, RecordSetList # noqa from designate.objects.server import Server, ServerList # noqa +from designate.objects.service_status import ServiceStatus, ServiceStatusList # noqa from designate.objects.tenant import Tenant, TenantList # noqa from designate.objects.tld import Tld, TldList # noqa from designate.objects.tsigkey import TsigKey, TsigKeyList # noqa diff --git a/designate/objects/adapters/__init__.py b/designate/objects/adapters/__init__.py index 17aa830a5..ab32a766d 100644 --- a/designate/objects/adapters/__init__.py +++ b/designate/objects/adapters/__init__.py @@ -27,6 +27,7 @@ from designate.objects.adapters.api_v2.pool_ns_record import PoolNsRecordAPIv2Ad from designate.objects.adapters.api_v2.tld import TldAPIv2Adapter, TldListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.tsigkey import TsigKeyAPIv2Adapter, TsigKeyListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.quota import QuotaAPIv2Adapter, QuotaListAPIv2Adapter # noqa +from designate.objects.adapters.api_v2.service_status import ServiceStatusAPIv2Adapter, ServiceStatusListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.zone_transfer_accept import ZoneTransferAcceptAPIv2Adapter, ZoneTransferAcceptListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.zone_transfer_request import ZoneTransferRequestAPIv2Adapter, ZoneTransferRequestListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.validation_error import ValidationErrorAPIv2Adapter, ValidationErrorListAPIv2Adapter # noqa diff --git a/designate/objects/adapters/api_v2/service_status.py b/designate/objects/adapters/api_v2/service_status.py new file mode 100644 index 000000000..fdd84bd6c --- /dev/null +++ b/designate/objects/adapters/api_v2/service_status.py @@ -0,0 +1,65 @@ +# Copyright 2016 Hewlett-Packard Development Company, L.P. +# +# 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. +from oslo_log import log as logging + +from designate.objects.adapters.api_v2 import base +from designate import objects +LOG = logging.getLogger(__name__) + + +class ServiceStatusAPIv2Adapter(base.APIv2Adapter): + + ADAPTER_OBJECT = objects.ServiceStatus + + MODIFICATIONS = { + 'fields': { + "id": {}, + "hostname": {}, + "service_name": {}, + "status": {}, + "stats": {}, + "capabilities": {}, + "heartbeated_at": {}, + "created_at": {}, + "updated_at": {}, + }, + 'options': { + 'links': True, + 'resource_name': 'service_status', + 'collection_name': 'service_statuses', + } + } + + @classmethod + def _render_object(cls, object, *args, **kwargs): + obj = super(ServiceStatusAPIv2Adapter, cls)._render_object( + object, *args, **kwargs) + + obj['links']['self'] = \ + '%s/v2/%s/%s' % (cls.BASE_URI, 'service_statuses', obj['id']) + + return obj + + +class ServiceStatusListAPIv2Adapter(base.APIv2Adapter): + + ADAPTER_OBJECT = objects.ServiceStatusList + + MODIFICATIONS = { + 'options': { + 'links': True, + 'resource_name': 'service_status', + 'collection_name': 'service_statuses', + } + } diff --git a/designate/objects/base.py b/designate/objects/base.py index a94941cd2..dac356272 100644 --- a/designate/objects/base.py +++ b/designate/objects/base.py @@ -18,6 +18,7 @@ import six from six.moves.urllib import parse import jsonschema from oslo_log import log as logging +from oslo_utils import timeutils from designate import exceptions from designate.schema import validators @@ -179,6 +180,11 @@ class DesignateObject(object): if isinstance(value, dict) and 'designate_object.name' in value: setattr(instance, field, DesignateObject.from_primitive(value)) else: + # data typically doesn't have a schema.. + schema = cls.FIELDS[field].get("schema", None) + if schema is not None and value is not None: + if "format" in schema and schema["format"] == "date-time": + value = timeutils.parse_strtime(value) setattr(instance, field, value) instance._obj_changes = set(primitive['designate_object.changes']) diff --git a/designate/objects/service_status.py b/designate/objects/service_status.py new file mode 100644 index 000000000..91c7bc761 --- /dev/null +++ b/designate/objects/service_status.py @@ -0,0 +1,61 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# 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. +from designate.objects import base + + +class ServiceStatus(base.PersistentObjectMixin, + base.DictObjectMixin, + base.DesignateObject): + FIELDS = { + "service_name": { + "schema": { + "type": "string" + } + }, + "hostname": { + "schema": { + "type": "string" + } + }, + "heartbeated_at": { + "schema": { + 'type': ['string', 'null'], + 'format': 'date-time' + } + }, + "status": { + "schema": { + "type": "string", + "enum": ["UP", "DOWN", "WARNING"] + } + }, + "stats": { + "schema": { + "type": "object", + } + }, + "capabilities": { + "schema": { + "type": "object" + } + } + } + + STRING_FIELDS = [ + 'service_name', 'hostname', 'status' + ] + + +class ServiceStatusList(base.ListObjectMixin, base.DesignateObject): + LIST_ITEM_TYPE = ServiceStatus diff --git a/designate/service.py b/designate/service.py index 98c7f789d..de4f70a0d 100644 --- a/designate/service.py +++ b/designate/service.py @@ -35,11 +35,13 @@ from designate.i18n import _ from designate.i18n import _LE from designate.i18n import _LI from designate.i18n import _LW -from designate import rpc from designate import policy +from designate import rpc +from designate import service_status from designate import version from designate import utils + # TODO(kiall): These options have been cut+paste from the old WSGI code, and # should be moved into service:api etc.. wsgi_socket_opts = [ @@ -108,6 +110,19 @@ class RPCService(object): messaging.Target(topic=self._rpc_topic, server=self._host), self._rpc_endpoints) + emitter_cls = service_status.HeartBeatEmitter.get_driver( + cfg.CONF.heartbeat_emitter.emitter_type + ) + self.heartbeat_emitter = emitter_cls( + self.service_name, self.tg, status_factory=self._get_status + ) + + def _get_status(self): + status = "UP" + stats = {} + capabilities = {} + return status, stats, capabilities + @property def _rpc_endpoints(self): return [self] @@ -130,8 +145,11 @@ class RPCService(object): if e != self and hasattr(e, 'start'): e.start() + self.heartbeat_emitter.start() + def stop(self): LOG.debug("Stopping RPC server on topic '%s'" % self._rpc_topic) + self.heartbeat_emitter.stop() for e in self._rpc_endpoints: if e != self and hasattr(e, 'stop'): diff --git a/designate/service_status.py b/designate/service_status.py new file mode 100644 index 000000000..0ce7a2af7 --- /dev/null +++ b/designate/service_status.py @@ -0,0 +1,114 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# 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. +import abc + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import timeutils + +from designate import context +from designate import objects +from designate import plugin +from designate.central import rpcapi as central_rpcapi + +heartbeat_opts = [ + cfg.FloatOpt('heartbeat_interval', + default=5.0, + help='Number of seconds between heartbeats for reporting ' + 'state'), + cfg.StrOpt('emitter_type', default="rpc", help="Emitter to use") +] + +CONF = cfg.CONF +CONF.register_opts(heartbeat_opts, group="heartbeat_emitter") + +LOG = logging.getLogger(__name__) + + +class HeartBeatEmitter(plugin.DriverPlugin): + __plugin_ns__ = 'designate.heartbeat_emitter' + __plugin_type__ = 'heartbeat_emitter' + + def __init__(self, service, threadgroup, status_factory=None): + super(HeartBeatEmitter, self).__init__() + + self._service = service + self._hostname = CONF.host + + self._running = False + self._tg = threadgroup + self._tg.add_timer( + cfg.CONF.heartbeat_emitter.heartbeat_interval, + self._emit_heartbeat) + + self._status_factory = status_factory + + def _get_status(self): + if self._status_factory is not None: + return self._status_factory() + + return True, {}, {} + + def _emit_heartbeat(self): + """ + Returns Status, Stats, Capabilities + """ + if not self._running: + return + + LOG.debug("Emitting heartbeat...") + + status, stats, capabilities = self._get_status() + + service_status = objects.ServiceStatus( + service_name=self._service, + hostname=self._hostname, + status=status, + stats=stats, + capabilities=capabilities, + heartbeated_at=timeutils.utcnow() + ) + + self._transmit(service_status) + + @abc.abstractmethod + def _transmit(self, status): + pass + + def start(self): + self._running = True + + def stop(self): + self._running = False + + +class NoopEmitter(HeartBeatEmitter): + __plugin_name__ = 'noop' + + def _transmit(self, status): + LOG.debug(status) + + +class RpcEmitter(HeartBeatEmitter): + __plugin_name__ = 'rpc' + + def __init__(self, service, thread_group, rpc_api=None, *args, **kwargs): + super(RpcEmitter, self).__init__( + service, thread_group, *args, **kwargs) + self.rpc_api = rpc_api + + def _transmit(self, status): + admin_context = context.DesignateContext.get_admin_context() + api = self.rpc_api or central_rpcapi.CentralAPI.get_instance() + api.update_service_status(admin_context, status) diff --git a/designate/storage/base.py b/designate/storage/base.py index f3fbe3334..b2856f6f6 100644 --- a/designate/storage/base.py +++ b/designate/storage/base.py @@ -766,3 +766,37 @@ class Storage(DriverPlugin): return { 'status': None } + + @abc.abstractmethod + def find_service_statuses(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + """ + Retrieve status for services + + :param context: RPC Context. + :param criterion: Criteria to filter by. + :param marker: Resource ID from which after the requested page will + start after + :param limit: Integer limit of objects of the page size after the + marker + :param sort_key: Key from which to sort after. + :param sort_dir: Direction to sort after using sort_key. + """ + + @abc.abstractmethod + def find_service_status(self, context, criterion): + """ + Find a single Service Status. + + :param context: RPC Context. + :param criterion: Criteria to filter by. + """ + + @abc.abstractmethod + def update_service_status(self, context, service_status): + """ + Update the Service status for a service. + + :param context: RPC Context. + :param service_status: Set the status for a service. + """ diff --git a/designate/storage/impl_sqlalchemy/__init__.py b/designate/storage/impl_sqlalchemy/__init__.py index 8e2c153e0..b60dc7ddb 100644 --- a/designate/storage/impl_sqlalchemy/__init__.py +++ b/designate/storage/impl_sqlalchemy/__init__.py @@ -1922,6 +1922,35 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage): return result[0] + # Service Status Methods + def _find_service_statuses(self, context, criterion, one=False, + marker=None, limit=None, sort_key=None, + sort_dir=None): + return self._find( + context, tables.service_status, objects.ServiceStatus, + objects.ServiceStatusList, exceptions.ServiceStatusNotFound, + criterion, one, marker, limit, sort_key, sort_dir) + + def find_service_status(self, context, criterion): + return self._find_service_statuses(context, criterion, one=True) + + def find_service_statuses(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + return self._find_service_statuses(context, criterion, marker=marker, + limit=limit, sort_key=sort_key, + sort_dir=sort_dir) + + def create_service_status(self, context, service_status): + return self._create( + tables.service_status, service_status, + exceptions.DuplicateServiceStatus) + + def update_service_status(self, context, service_status): + return self._update( + context, tables.service_status, service_status, + exceptions.DuplicateServiceStatus, + exceptions.ServiceStatusNotFound) + # diagnostics def ping(self, context): start_time = time.time() diff --git a/designate/storage/impl_sqlalchemy/migrate_repo/versions/097_add_services.py b/designate/storage/impl_sqlalchemy/migrate_repo/versions/097_add_services.py new file mode 100644 index 000000000..97b415427 --- /dev/null +++ b/designate/storage/impl_sqlalchemy/migrate_repo/versions/097_add_services.py @@ -0,0 +1,52 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# 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. + +"""Add Service Status tables""" + + +from oslo_log import log as logging +from sqlalchemy import String, DateTime, Enum, Text +from sqlalchemy.schema import Table, Column, MetaData + +from designate import utils +from designate.sqlalchemy.types import UUID + +LOG = logging.getLogger() + +meta = MetaData() + +SERVICE_STATES = [ + "UP", "DOWN", "WARNING" +] + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + + status_enum = Enum(name='service_statuses', metadata=meta, *SERVICE_STATES) + status_enum.create() + + service_status_table = Table('service_statuses', meta, + Column('id', UUID(), default=utils.generate_uuid, primary_key=True), + Column('created_at', DateTime), + Column('updated_at', DateTime), + + Column('service_name', String(40), nullable=False), + Column('hostname', String(255), nullable=False), + Column('heartbeated_at', DateTime, nullable=True), + Column('status', status_enum, nullable=False), + Column('stats', Text, nullable=False), + Column('capabilities', Text, nullable=False), + ) + service_status_table.create() diff --git a/designate/storage/impl_sqlalchemy/tables.py b/designate/storage/impl_sqlalchemy/tables.py index 5ab1672c7..0cd003037 100644 --- a/designate/storage/impl_sqlalchemy/tables.py +++ b/designate/storage/impl_sqlalchemy/tables.py @@ -18,6 +18,7 @@ from sqlalchemy import (Table, MetaData, Column, String, Text, Integer, UniqueConstraint, ForeignKeyConstraint) from oslo_config import cfg +from oslo_db.sqlalchemy import types from oslo_utils import timeutils from designate import utils @@ -39,6 +40,9 @@ ACTIONS = ['CREATE', 'DELETE', 'UPDATE', 'NONE'] ZONE_TYPES = ('PRIMARY', 'SECONDARY',) ZONE_TASK_TYPES = ['IMPORT', 'EXPORT'] +SERVICE_STATES = [ + "UP", "DOWN", "WARNING" +] metadata = MetaData() @@ -51,6 +55,25 @@ def default_shard(context, id_col): return int(context.current_parameters[id_col][0:3], 16) +service_status = Table("service_statuses", metadata, + Column('id', UUID, default=utils.generate_uuid, primary_key=True), + Column('created_at', DateTime, default=lambda: timeutils.utcnow()), + Column('updated_at', DateTime, onupdate=lambda: timeutils.utcnow()), + + Column('service_name', String(40), nullable=False), + Column('hostname', String(255), nullable=False), + Column('heartbeated_at', DateTime, nullable=True), + + Column('status', Enum(name='service_statuses', *SERVICE_STATES), + nullable=False), + Column('stats', types.JsonEncodedDict, nullable=False), + Column('capabilities', types.JsonEncodedDict, nullable=False), + + mysql_engine='InnoDB', + mysql_charset='utf8', +) + + quotas = Table('quotas', metadata, Column('id', UUID, default=utils.generate_uuid, primary_key=True), Column('version', Integer, default=1, nullable=False), diff --git a/designate/tests/__init__.py b/designate/tests/__init__.py index 25447abda..9c800c91c 100644 --- a/designate/tests/__init__.py +++ b/designate/tests/__init__.py @@ -62,6 +62,14 @@ class TestTimeoutError(Exception): class TestCase(base.BaseTestCase): + service_status_fixtures = [{ + 'service_name': 'foo', + 'hostname': 'bar', + 'status': "UP", + 'stats': {}, + 'capabilities': {}, + }] + quota_fixtures = [{ 'resource': 'zones', 'hard_limit': 5, @@ -320,6 +328,11 @@ class TestCase(base.BaseTestCase): group='service:central' ) + self.config( + emitter_type="noop", + group="heartbeat_emitter" + ) + self.config( auth_strategy='noauth', group='service:api' @@ -604,6 +617,23 @@ class TestCase(base.BaseTestCase): _values.update(values) return _values + def get_service_status_fixture(self, fixture=0, values=None): + values = values or {} + + _values = copy.copy(self.service_status_fixtures[fixture]) + _values.update(values) + return _values + + def update_service_status(self, **kwargs): + context = kwargs.pop('context', self.admin_context) + fixture = kwargs.pop('fixture', 0) + + values = self.get_service_status_fixture( + fixture=fixture, values=kwargs) + + return self.central_service.update_service_status( + context, objects.ServiceStatus.from_dict(values)) + def create_tld(self, **kwargs): context = kwargs.pop('context', self.admin_context) fixture = kwargs.pop('fixture', 0) diff --git a/designate/tests/test_api/test_v2/test_service_status.py b/designate/tests/test_api/test_v2/test_service_status.py new file mode 100644 index 000000000..5877fc88a --- /dev/null +++ b/designate/tests/test_api/test_v2/test_service_status.py @@ -0,0 +1,84 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# 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. +from oslo_log import log as logging + +from designate.tests.test_api.test_v2 import ApiV2TestCase + +LOG = logging.getLogger(__name__) + + +class ApiV2ServiceStatusTest(ApiV2TestCase): + def setUp(self): + super(ApiV2ServiceStatusTest, self).setUp() + + def test_get_service_statuses(self): + # Set the policy file as this is an admin-only API + self.policy({'find_service_statuses': '@'}) + + response = self.client.get('/service_statuses/') + + # Check the headers are what we expect + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + # Check the body structure is what we expect + self.assertIn('service_statuses', response.json) + self.assertIn('links', response.json) + self.assertIn('self', response.json['links']) + + # Test with 0 service_statuses + # Seeing that Central is started there will be 1 here already.. + self.assertEqual(0, len(response.json['service_statuses'])) + + data = [self.update_service_status( + hostname="foo%s" % i, service_name="bar") for i in range(0, 10)] + + self._assert_paging(data, '/service_statuses', key='service_statuses') + + def test_get_service_status(self): + service_status = self.update_service_status(fixture=0) + + # Set the policy file as this is an admin-only API + self.policy({'find_service_status': '@'}) + + response = self.client.get( + '/service_statuses/%s' % service_status['id'], + headers=[('Accept', 'application/json')]) + + # Verify the headers + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + # Verify the body structure + self.assertIn('links', response.json) + self.assertIn('self', response.json['links']) + + # Verify the returned values + self.assertIn('id', response.json) + self.assertIn('created_at', response.json) + self.assertIsNone(response.json['updated_at']) + + fixture = self.get_service_status_fixture(0) + self.assertEqual(fixture['hostname'], response.json['hostname']) + self.assertEqual(fixture['service_name'], + response.json['service_name']) + self.assertEqual(fixture['capabilities'], + response.json['capabilities']) + self.assertEqual(fixture['stats'], response.json['stats']) + self.assertEqual(fixture['status'], response.json['status']) + self.assertEqual(None, response.json['heartbeated_at']) + + def test_get_service_status_invalid_id(self): + self.policy({'find_service_status': '@'}) + self._assert_invalid_uuid(self.client.get, '/service_statuses/%s') diff --git a/designate/tests/test_storage/test_sqlalchemy.py b/designate/tests/test_storage/test_sqlalchemy.py index 02def7e36..cfa00b4d1 100644 --- a/designate/tests/test_storage/test_sqlalchemy.py +++ b/designate/tests/test_storage/test_sqlalchemy.py @@ -52,6 +52,7 @@ class SqlalchemyStorageTest(StorageTestCase, TestCase): u'quotas', u'records', u'recordsets', + u'service_statuses', u'tlds', u'tsigkeys', u'zone_attributes', diff --git a/designate/tests/unit/test_service_status.py b/designate/tests/unit/test_service_status.py new file mode 100644 index 000000000..2a001445b --- /dev/null +++ b/designate/tests/unit/test_service_status.py @@ -0,0 +1,105 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# 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. +import mock +import oslotest.base +from oslo_config import cfg + +from designate import objects +from designate import service_status + + +class NoopEmitterTest(oslotest.base.BaseTestCase): + def setUp(self): + super(NoopEmitterTest, self).setUp() + self.mock_tg = mock.Mock() + + def test_init(self): + service_status.NoopEmitter("svc", self.mock_tg) + + def test_start(self): + emitter = service_status.NoopEmitter("svc", self.mock_tg) + emitter.start() + + self.mock_tg.add_timer.assert_called_once_with( + 5.0, emitter._emit_heartbeat) + + def test_stop(self): + mock_pulse = mock.Mock() + self.mock_tg.add_timer.return_value = mock_pulse + + emitter = service_status.NoopEmitter("svc", self.mock_tg) + emitter.start() + emitter.stop() + + self.assertFalse(emitter._running) + + +class RpcEmitterTest(oslotest.base.BaseTestCase): + def setUp(self): + super(RpcEmitterTest, self).setUp() + self.mock_tg = mock.Mock() + + @mock.patch.object(objects, "ServiceStatus") + @mock.patch("designate.context.DesignateContext.get_admin_context") + def test_emit_no_status_factory(self, mock_context, mock_service_status): + emitter = service_status.RpcEmitter("svc", self.mock_tg) + emitter.start() + + central = mock.Mock() + with mock.patch("designate.central.rpcapi.CentralAPI.get_instance", + return_value=central): + emitter._emit_heartbeat() + + mock_service_status.assert_called_once_with( + service_name="svc", + hostname=cfg.CONF.host, + status=True, + stats={}, + capabilities={}, + heartbeated_at=mock.ANY + ) + + central.update_service_status.assert_called_once_with( + mock_context.return_value, mock_service_status.return_value + ) + + @mock.patch.object(objects, "ServiceStatus") + @mock.patch("designate.context.DesignateContext.get_admin_context") + def test_emit_status_factory(self, mock_context, mock_service_status): + status = False + stats = {"a": 1} + capabilities = {"b": 2} + + status_factory = mock.Mock(return_value=(status, stats, capabilities,)) + emitter = service_status.RpcEmitter("svc", self.mock_tg, + status_factory=status_factory) + emitter.start() + + central = mock.Mock() + with mock.patch("designate.central.rpcapi.CentralAPI.get_instance", + return_value=central): + emitter._emit_heartbeat() + + mock_service_status.assert_called_once_with( + service_name="svc", + hostname=cfg.CONF.host, + status=status, + stats=stats, + capabilities=capabilities, + heartbeated_at=mock.ANY + ) + + central.update_service_status.assert_called_once_with( + mock_context.return_value, mock_service_status.return_value + ) diff --git a/doc/service_status.rst b/doc/service_status.rst new file mode 100644 index 000000000..a3aeb4a94 --- /dev/null +++ b/doc/service_status.rst @@ -0,0 +1,43 @@ +.. + Copyright 2016 Hewlett Packard Enterprise Development Company LP + 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. + + + Service Statuses + ================ + + Overview + ----------------------- + The Service Status entries are used to track the health state of the services + in the Designate system. Each service will report in it's health via RPC or + using HTTP. + + +Explanation +----------- + +============ ============================================================== +Attribute Description +============ ============================================================== +service_name The name of the service, typically `central` or alike. +hostname The hostname where the service is running. +capabilities Service capabilities, used to tell a service of the same type + apart. +stats Statistics are optional per service metrics. +status An enum describing the status of the service. + UP for health and ok, DOWN for down (Ie the service hasn't + reported in for a while) and WARNING if the service is having + issues. +============ ============================================================== diff --git a/doc/source/index.rst b/doc/source/index.rst index fd421be09..142bc2e43 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -68,6 +68,7 @@ Reference Documentation support-matrix pools memory-caching + service_status Source Documentation ==================== diff --git a/doc/source/rest.rst b/doc/source/rest.rst index dfda87b73..95229f8e8 100644 --- a/doc/source/rest.rst +++ b/doc/source/rest.rst @@ -93,6 +93,7 @@ V2 API rest/v2/pools rest/v2/limits rest/v2/reverse + rest/v2/service_status rest/v2/tsigkeys Admin API diff --git a/doc/source/rest/v2/service_status.rst b/doc/source/rest/v2/service_status.rst new file mode 100644 index 000000000..f95d2c06d --- /dev/null +++ b/doc/source/rest/v2/service_status.rst @@ -0,0 +1,149 @@ +.. + Copyright 2016 Hewlett Packard Enterprise Development Company LP + 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. + +Service Statuses +================ + +Overview +----------------------- +The Service Status entries are used to track the health state of the services +in the Designate system. + + +Get a Service Status +-------------------- + +.. http:get:: /service_statuses/(uuid:id) + + Lists a particular Service Status + + **Example request**: + + .. sourcecode:: http + + GET /service_statuses/5abe514c-9fb5-41e8-ab73-5ed25f8a73e9 HTTP/1.1 + Host: example.com + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + { + "capabilities": {}, + "created_at": "2016-03-08T09:20:23.000000", + "heartbeated_at": "2016-03-08T09:26:18.000000", + "hostname": "vagrant-ubuntu-trusty-64", + "id": "769e8ca2-f71e-48be-8dee-631492c91e41", + "links": { + "self": "http://192.168.27.100:9001/v2/service_statuses/769e8ca2-f71e-48be-8dee-631492c91e41", + "service_status": "http://192.168.27.100:9001/v2/service_statuses/769e8ca2-f71e-48be-8dee-631492c91e41" + }, + "service_name": "pool_manager", + "stats": {}, + "status": "UP", + "updated_at": "2016-03-08T09:26:18.000000" + } + + :form created_at: timestamp + :form updated_at: timestamp + :form id: uuid + :form description: UTF-8 text field + :form links: links to traverse the list + :form service_name: Service name + :form hostname: Service hostname + :form capabilities: Service capabilities - dict of capabilities + :form stats: Service stats - dict of stats + :form status: Service status - UP, DOWN or WARNING + :statuscode 200: OK + :statuscode 401: Access Denied + :statuscode 404: Service Status not found + +List Service Statuses +--------------------- + +.. http:get:: /service_statuses + + Lists all Service Statuses + + **Example request**: + + .. sourcecode:: http + + GET /service_statuses HTTP/1.1 + Host: example.com + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + { + "service_statuses":[ + { + "capabilities": {}, + "created_at": "2016-03-08T09:20:23.000000", + "heartbeated_at": "2016-03-08T09:26:18.000000", + "hostname": "vagrant-ubuntu-trusty-64", + "id": "769e8ca2-f71e-48be-8dee-631492c91e41", + "links": { + "self": "http://192.168.27.100:9001/v2/service_statuses/769e8ca2-f71e-48be-8dee-631492c91e41", + "service_status": "http://192.168.27.100:9001/v2/service_statuses/769e8ca2-f71e-48be-8dee-631492c91e41" + }, + "service_name": "pool_manager", + "stats": {}, + "status": "UP", + "updated_at": "2016-03-08T09:26:18.000000" + }, + { + "capabilities": {}, + "created_at": "2016-03-08T09:20:26.000000", + "heartbeated_at": "2016-03-08T09:26:16.000000", + "hostname": "vagrant-ubuntu-trusty-64", + "id": "adcf580b-ea1c-4ebc-8a95-37ccdeed11ae", + "links": { + "self": "http://192.168.27.100:9001/v2/service_statuses/adcf580b-ea1c-4ebc-8a95-37ccdeed11ae", + "service_status": "http://192.168.27.100:9001/v2/service_statuses/adcf580b-ea1c-4ebc-8a95-37ccdeed11ae" + }, + "service_name": "zone_manager", + "stats": {}, + "status": "UP", + "updated_at": "2016-03-08T09:26:17.000000" + } + ], + "links":{ + "self":"http://127.0.0.1:9001/v2/service_statuses" + } + } + + :form created_at: timestamp + :form updated_at: timestamp + :form id: uuid + :form description: UTF-8 text field + :form links: links to traverse the list + :form service_name: Service name + :form hostname: Service hostname + :form capabilities: Service capabilities - dict of capabilities + :form stats: Service stats - dict of stats + :form status: Service status - UP, DOWN or WARNING + :statuscode 200: OK + :statuscode 401: Access Denied diff --git a/etc/designate/policy.json b/etc/designate/policy.json index 5b9b842ab..0eeb7a1d4 100644 --- a/etc/designate/policy.json +++ b/etc/designate/policy.json @@ -122,5 +122,8 @@ "find_zone_exports": "rule:admin_or_owner", "get_zone_export": "rule:admin_or_owner", "update_zone_export": "rule:admin_or_owner", - "delete_zone_export": "rule:admin_or_owner" + + "find_service_status": "rule:admin", + "find_service_statuses": "rule:admin", + "update_service_service_status": "rule:admin" } diff --git a/setup.cfg b/setup.cfg index 7bc901af6..a4046444c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -122,6 +122,11 @@ designate.zone_manager_tasks = periodic_secondary_refresh = designate.zone_manager.tasks:PeriodicSecondaryRefreshTask delayed_notify = designate.zone_manager.tasks:PeriodicGenerateDelayedNotifyTask +designate.heartbeat_emitter = + noop = designate.service_status:NoopEmitter + rpc = designate.service_status:RpcEmitter + + [build_sphinx] all_files = 1 build-dir = doc/build