diff --git a/iotronic/api/controllers/v1/board.py b/iotronic/api/controllers/v1/board.py index 6539c85..054e7a3 100644 --- a/iotronic/api/controllers/v1/board.py +++ b/iotronic/api/controllers/v1/board.py @@ -152,11 +152,50 @@ class InjectionCollection(collection.Collection): return collection +class ExposedService(base.APIBase): + service = types.uuid_or_name + board_uuid = types.uuid_or_name + public_port = wsme.types.IntegerType() + + def __init__(self, **kwargs): + self.fields = [] + fields = list(objects.ExposedService.fields) + fields.remove('board_uuid') + for k in fields: + # Skip fields we do not expose. + if not hasattr(self, k): + continue + self.fields.append(k) + setattr(self, k, kwargs.get(k, wtypes.Unset)) + setattr(self, 'service', kwargs.get('service_uuid', wtypes.Unset)) + + +class ExposedCollection(collection.Collection): + """API representation of a collection of injection.""" + + exposed = [ExposedService] + + def __init__(self, **kwargs): + self._type = 'exposed' + + @staticmethod + def get_list(exposed, fields=None): + collection = ExposedCollection() + collection.exposed = [ExposedService(**n.as_dict()) + for n in exposed] + return collection + + class PluginAction(base.APIBase): action = wsme.wsattr(wtypes.text) parameters = types.jsontype +class ServiceAction(base.APIBase): + action = wsme.wsattr(wtypes.text) + parameters = types.jsontype + + class BoardPluginsController(rest.RestController): def __init__(self, board_ident): self.board_ident = board_ident @@ -282,11 +321,72 @@ class BoardPluginsController(rest.RestController): rpc_board.uuid) +class BoardServicesController(rest.RestController): + _custom_actions = { + 'action': ['POST'], + } + + def __init__(self, board_ident): + self.board_ident = board_ident + + def _get_services_on_board_collection(self, board_uuid, fields=None): + services = objects.ExposedService.list(pecan.request.context, + board_uuid) + + return ExposedCollection.get_list(services, + fields=fields) + + @expose.expose(ExposedCollection, + status_code=200) + def get_all(self): + """Retrieve a list of services of a board. + + """ + rpc_board = api_utils.get_rpc_board(self.board_ident) + + cdict = pecan.request.context.to_policy_values() + cdict['project_id'] = rpc_board.project + policy.authorize('iot:service_on_board:get', cdict, cdict) + + return self._get_services_on_board_collection(rpc_board.uuid) + + @expose.expose(wtypes.text, types.uuid_or_name, body=ServiceAction, + status_code=200) + def action(self, service_ident, ServiceAction): + + if not ServiceAction.action: + raise exception.MissingParameterValue( + ("Action is not specified.")) + + if not ServiceAction.parameters: + ServiceAction.parameters = {} + + rpc_board = api_utils.get_rpc_board(self.board_ident) + rpc_service = api_utils.get_rpc_service(service_ident) + + try: + cdict = pecan.request.context.to_policy_values() + cdict['owner'] = rpc_board.owner + policy.authorize('iot:service_action:post', cdict, cdict) + + except exception: + return exception + + rpc_board.check_if_online() + + result = pecan.request.rpcapi.action_service(pecan.request.context, + rpc_service.uuid, + rpc_board.uuid, + ServiceAction.action) + return result + + class BoardsController(rest.RestController): """REST controller for Boards.""" _subcontroller_map = { 'plugins': BoardPluginsController, + 'services': BoardServicesController, } invalid_sort_key_list = ['extra', 'location'] diff --git a/iotronic/common/exception.py b/iotronic/common/exception.py index f786645..76d24d2 100644 --- a/iotronic/common/exception.py +++ b/iotronic/common/exception.py @@ -597,3 +597,15 @@ class ErrorExecutionOnBoard(IotronicException): class ServiceNotFound(NotFound): message = _("Service %(Service)s could not be found.") + + +class ServiceAlreadyExists(Conflict): + message = _("A Service with UUID %(uuid)s already exists.") + + +class ServiceAlreadyExposed(Conflict): + message = _("A Service with UUID %(uuid)s already exposed.") + + +class ExposedServiceNotFound(NotFound): + message = _("ExposedService %(uuid)s could not be found.") diff --git a/iotronic/common/policy.py b/iotronic/common/policy.py index 5fff673..38555aa 100644 --- a/iotronic/common/policy.py +++ b/iotronic/common/policy.py @@ -136,6 +136,20 @@ service_policies = [ ] +exposed_service_policies = [ + policy.RuleDefault('iot:service_on_board:get', + 'rule:admin_or_owner', + description='Retrieve Service records'), + policy.RuleDefault('iot:service_remove:delete', 'rule:admin_or_owner', + description='Delete Service records'), + policy.RuleDefault('iot:service_action:post', + 'rule:admin_or_owner', + description='Create Service records'), + policy.RuleDefault('iot:service_inject:put', 'rule:admin_or_owner', + description='Retrieve a Service record'), + +] + def list_policies(): policies = (default_policies @@ -143,6 +157,7 @@ def list_policies(): + plugin_policies + injection_plugin_policies + service_policies + + exposed_service_policies ) return policies diff --git a/iotronic/conductor/endpoints.py b/iotronic/conductor/endpoints.py index 73226c8..5e10c49 100644 --- a/iotronic/conductor/endpoints.py +++ b/iotronic/conductor/endpoints.py @@ -39,6 +39,26 @@ def get_best_agent(ctx): return agent.hostname +def random_public_port(): + return random.randint(6000, 7000) + + +def manage_result(res, wamp_rpc_call, board_uuid): + if res.result == wm.SUCCESS: + return res.message + elif res.result == wm.WARNING: + LOG.warning('Warning in the execution of %s on %s', wamp_rpc_call, + board_uuid) + return res.message + elif res.result == wm.ERROR: + LOG.error('Error in the execution of %s on %s: %s', wamp_rpc_call, + board_uuid, res.message) + raise exception.ErrorExecutionOnBoard(call=wamp_rpc_call, + board=board_uuid, + error=res.message) + return res.message + + class ConductorEndpoint(object): def __init__(self, ragent): transport = oslo_messaging.get_transport(cfg.CONF) @@ -119,10 +139,12 @@ class ConductorEndpoint(object): board_id, 'destroyBoard', (p,)) + except exception: return exception board.destroy() if result: + result = manage_result(result, 'destroyBoard', board_id) LOG.debug(result) return result return @@ -164,18 +186,7 @@ class ConductorEndpoint(object): data=wamp_rpc_args) res = wm.deserialize(res) - if res.result == wm.SUCCESS: - return res.message - elif res.result == wm.WARNING: - LOG.warning('Warning in the execution of %s on %s', wamp_rpc_call, - board_uuid) - return res.message - elif res.result == wm.ERROR: - LOG.error('Error in the execution of %s on %s: %s', wamp_rpc_call, - board_uuid, res.message) - raise exception.ErrorExecutionOnBoard(call=wamp_rpc_call, - board=board.uuid, - error=res.message) + return res def destroy_plugin(self, ctx, plugin_id): LOG.info('Destroying plugin with id %s', @@ -231,7 +242,9 @@ class ConductorEndpoint(object): injection = objects.InjectionPlugin(ctx, **inj_data) injection.create() + result = manage_result(result, 'PluginInject', board_uuid) LOG.debug(result) + return result def remove_plugin(self, ctx, plugin_uuid, board_uuid): @@ -247,7 +260,7 @@ class ConductorEndpoint(object): (plugin.uuid,)) except exception: return exception - + result = manage_result(result, 'PluginRemove', board_uuid) LOG.debug(result) injection.destroy() return result @@ -267,7 +280,7 @@ class ConductorEndpoint(object): (plugin.uuid,)) except exception: return exception - + result = manage_result(result, action, board_uuid) LOG.debug(result) return result @@ -290,3 +303,151 @@ class ConductorEndpoint(object): LOG.debug('Updating service %s', service.name) service.save() return serializer.serialize_entity(ctx, service) + + def action_service(self, ctx, service_uuid, board_uuid, action): + LOG.info('Enable service with id %s into the board %s', + service_uuid, board_uuid) + service = objects.Service.get(ctx, service_uuid) + objects.service.is_valid_action(action) + + if action == "ServiceEnable": + try: + objects.ExposedService.get(ctx, + board_uuid, + service_uuid) + return exception.ServiceAlreadyExposed(uuid=service_uuid) + except Exception: + name = service.name + public_port = random_public_port() + port = service.port + + res = self.execute_on_board(ctx, board_uuid, action, + (name, public_port, port)) + + if res.result == wm.SUCCESS: + pid = res.message[0] + + exp_data = { + 'board_uuid': board_uuid, + 'service_uuid': service_uuid, + 'public_port': public_port, + 'pid': pid, + } + exposed = objects.ExposedService(ctx, **exp_data) + exposed.create() + + res.message = res.message[1] + elif res.result == wm.ERROR: + LOG.error('Error in the execution of %s on %s: %s', + action, + board_uuid, res.message) + raise exception.ErrorExecutionOnBoard(call=action, + board=board_uuid, + error=res.message) + LOG.debug(res.message) + return res.message + + elif action == "ServiceDisable": + exposed = objects.ExposedService.get(ctx, + board_uuid, + service_uuid) + + res = self.execute_on_board(ctx, board_uuid, action, + (service.name, exposed.pid)) + + result = manage_result(res, action, board_uuid) + LOG.debug(res.message) + exposed.destroy() + return result + + elif action == "ServiceRestore": + + exposed = objects.ExposedService.get(ctx, board_uuid, + service_uuid) + + print(exposed) + + res = self.execute_on_board(ctx, board_uuid, action, + (service.name, exposed.public_port, + service.port, exposed.pid)) + + if res.result == wm.SUCCESS: + pid = res.message[0] + + exp_data = { + 'id': exposed.id, + 'board_uuid': board_uuid, + 'service_uuid': service_uuid, + 'public_port': exposed.public_port, + 'pid': pid, + } + + exposed = objects.ExposedService(ctx, **exp_data) + exposed.save() + + res.message = res.message[1] + elif res.result == wm.ERROR: + LOG.error('Error in the execution of %s on %s: %s', + action, + board_uuid, res.message) + raise exception.ErrorExecutionOnBoard(call=action, + board=board_uuid, + error=res.message) + LOG.debug(res.message) + return res.message + + # try: + # + # + # return exception.ServiceAlreadyExposed(uuid=service_uuid) + # except: + # name=service.name + # public_port=random_public_port() + # port=service.port + # + # res = self.execute_on_board(ctx, board_uuid, action, + # (name, public_port, port)) + # + # if res.result == wm.SUCCESS: + # pid = res.message[0] + # + # exp_data = { + # 'board_uuid': board_uuid, + # 'service_uuid': service_uuid, + # 'public_port': public_port, + # 'pid': pid, + # } + # exposed = objects.ExposedService(ctx, **exp_data) + # exposed.create() + # + # res.message = res.message[1] + # elif res.result == wm.ERROR: + # LOG.error('Error in the execution of %s on %s: %s', + # action, + # board_uuid, res.message) + # raise exception.ErrorExecutionOnBoard(call=action, + # board=board_uuid, + # error=res.message) + # LOG.debug(res.message) + # return res.message + # + # + # + # + # + # + # + # + # + # + # + # exposed = objects.ExposedService.get(ctx, board_uuid, + # service_uuid) + # + # res = self.execute_on_board(ctx, board_uuid, action, + # (service.name, exposed.pid)) + # + # result=manage_result(res,action,board_uuid) + # LOG.debug(res.message) + # exposed.destroy() + # return result diff --git a/iotronic/conductor/rpcapi.py b/iotronic/conductor/rpcapi.py index 4d5b333..223c993 100644 --- a/iotronic/conductor/rpcapi.py +++ b/iotronic/conductor/rpcapi.py @@ -249,3 +249,17 @@ class ConductorAPI(object): """ cctxt = self.client.prepare(topic=topic or self.topic, version='1.0') return cctxt.call(context, 'update_service', service_obj=service_obj) + + def action_service(self, context, service_uuid, + board_uuid, action, topic=None): + """Action on a service into a board. + + :param context: request context. + :param service_uuid: service id or uuid. + :param board_uuid: board id or uuid. + + """ + cctxt = self.client.prepare(topic=topic or self.topic, version='1.0') + + return cctxt.call(context, 'action_service', service_uuid=service_uuid, + board_uuid=board_uuid, action=action) diff --git a/iotronic/db/api.py b/iotronic/db/api.py index 51c3e6a..82fafff 100644 --- a/iotronic/db/api.py +++ b/iotronic/db/api.py @@ -473,3 +473,57 @@ class Connection(object): :raises: ServiceAssociated :raises: ServiceNotFound """ + + @abc.abstractmethod + def get_exposed_service_by_board_uuid(self, board_uuid): + """get an exposed of a service using a board_uuid + + :param board_uuid: The id or uuid of a board. + :returns: An exposed_service. + + """ + + @abc.abstractmethod + def get_exposed_service_by_uuids(self, board_uuid, service_uuid): + """get an exposed of a service using a board_uuid and service_uuid + + :param board_uuid: The id or uuid of a board. + :param service_uuid: The id or uuid of a service. + :returns: An exposed_service. + + """ + + @abc.abstractmethod + def create_exposed_service(self, values): + """Create a new exposed_service. + + :param values: A dict containing several items used to identify + and track the service + :returns: An exposed service. + """ + + @abc.abstractmethod + def destroy_exposed_service(self, exposed_service_id): + """Destroy an exposed service and all associated interfaces. + + :param exposed_service_id: The id or uuid of a service. + """ + + @abc.abstractmethod + def update_exposed_service(self, service_exposed_id, values): + """Update properties of a service. + + :param service_id: The id or uuid of a service. + :param values: Dict of values to update. + :returns: A service. + :raises: ServiceAssociated + :raises: ServiceNotFound + """ + + @abc.abstractmethod + def get_exposed_service_list(self, board_uuid): + """Return a list of exposed_services. + + :param board_uuid: The id or uuid of a service. + :returns: A list of ExposedServices on the board. + """ diff --git a/iotronic/db/sqlalchemy/api.py b/iotronic/db/sqlalchemy/api.py index 50132ca..275f674 100644 --- a/iotronic/db/sqlalchemy/api.py +++ b/iotronic/db/sqlalchemy/api.py @@ -761,3 +761,84 @@ class Connection(api.Connection): ref.update(values) return ref + + # EXPOSED SERVICE api + + def get_exposed_service_by_board_uuid(self, board_uuid): + query = model_query( + models.ExposedService).filter_by( + board_uuid=board_uuid) + try: + return query.one() + except NoResultFound: + raise exception.ExposedServiceNotFound() + + def create_exposed_service(self, values): + # ensure defaults are present for new services + if 'uuid' not in values: + values['uuid'] = uuidutils.generate_uuid() + exp_serv = models.ExposedService() + exp_serv.update(values) + try: + exp_serv.save() + except db_exc.DBDuplicateEntry: + raise exception.ServiceAlreadyExposed(uuid=values['uuid']) + return exp_serv + + def update_exposed_service(self, service_exposed_id, values): + + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing Service.") + raise exception.InvalidParameterValue(err=msg) + try: + return self._do_update_exposed_service( + service_exposed_id, values) + + except db_exc.DBDuplicateEntry as e: + if 'name' in e.columns: + raise exception.DuplicateName(name=values['name']) + elif 'uuid' in e.columns: + raise exception.ServiceAlreadyExists(uuid=values['uuid']) + else: + raise e + + def get_exposed_service_by_uuids(self, board_uuid, service_uuid): + query = model_query( + models.ExposedService).filter_by( + board_uuid=board_uuid).filter_by( + service_uuid=service_uuid) + try: + return query.one() + except NoResultFound: + raise exception.ExposedServiceNotFound(uuid=service_uuid) + + def destroy_exposed_service(self, exposed_service_id): + + session = get_session() + with session.begin(): + query = model_query(models.ExposedService, session=session) + query = add_identity_filter(query, exposed_service_id) + try: + query.delete() + + except NoResultFound: + raise exception.ExposedServiceNotFound() + + def get_exposed_service_list(self, board_uuid): + query = model_query( + models.ExposedService).filter_by( + board_uuid=board_uuid) + return query.all() + + def _do_update_exposed_service(self, service_id, values): + session = get_session() + with session.begin(): + query = model_query(models.ExposedService, session=session) + query = add_identity_filter(query, service_id) + try: + ref = query.with_lockmode('update').one() + except NoResultFound: + raise exception.ServiceNotFoundNotFound(uuid=service_id) + + ref.update(values) + return ref diff --git a/iotronic/db/sqlalchemy/models.py b/iotronic/db/sqlalchemy/models.py index 5fd78ed..e0d67cb 100644 --- a/iotronic/db/sqlalchemy/models.py +++ b/iotronic/db/sqlalchemy/models.py @@ -248,3 +248,4 @@ class ExposedService(Base): board_uuid = Column(String(36), ForeignKey('boards.uuid')) service_uuid = Column(String(36), ForeignKey('services.uuid')) public_port = Column(Integer) + pid = Column(Integer) diff --git a/iotronic/objects/__init__.py b/iotronic/objects/__init__.py index 2cfbf31..55f54d9 100644 --- a/iotronic/objects/__init__.py +++ b/iotronic/objects/__init__.py @@ -14,6 +14,7 @@ from iotronic.objects import board from iotronic.objects import conductor +from iotronic.objects import exposedservice from iotronic.objects import injectionplugin from iotronic.objects import location from iotronic.objects import plugin @@ -26,6 +27,7 @@ Board = board.Board Location = location.Location Plugin = plugin.Plugin InjectionPlugin = injectionplugin.InjectionPlugin +ExposedService = exposedservice.ExposedService SessionWP = sessionwp.SessionWP WampAgent = wampagent.WampAgent Service = service.Service @@ -39,4 +41,5 @@ __all__ = ( Service, Plugin, InjectionPlugin, + ExposedService ) diff --git a/iotronic/objects/exposedservice.py b/iotronic/objects/exposedservice.py new file mode 100644 index 0000000..7e96cda --- /dev/null +++ b/iotronic/objects/exposedservice.py @@ -0,0 +1,165 @@ +# coding=utf-8 +# +# +# 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 iotronic.db import api as db_api +from iotronic.objects import base +from iotronic.objects import utils as obj_utils + + +class ExposedService(base.IotronicObject): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = db_api.get_instance() + + fields = { + 'id': int, + 'board_uuid': obj_utils.str_or_none, + 'service_uuid': obj_utils.str_or_none, + 'public_port': int, + 'pid': int + } + + @staticmethod + def _from_db_object(exposed_service, db_exposed_service): + """Converts a database entity to a formal object.""" + for field in exposed_service.fields: + exposed_service[field] = db_exposed_service[field] + exposed_service.obj_reset_changes() + return exposed_service + + @base.remotable_classmethod + def get_by_id(cls, context, exposed_service_id): + """Find a exposed_service based on its integer id and return a Board object. + + :param exposed_service_id: the id of a exposed_service. + :returns: a :class:`exposed_service` object. + """ + db_exp_service = cls.dbapi.get_exposed_service_by_id( + exposed_service_id) + exp_service = ExposedService._from_db_object(cls(context), + db_exp_service) + return exp_service + + @base.remotable_classmethod + def get_by_board_uuid(cls, context, board_uuid): + """Find a exposed_service based on uuid and return a Board object. + + :param board_uuid: the uuid of a exposed_service. + :returns: a :class:`exposed_service` object. + """ + db_exp_service = cls.dbapi.get_exposed_service_by_board_uuid( + board_uuid) + exp_service = ExposedService._from_db_object(cls(context), + db_exp_service) + return exp_service + + @base.remotable_classmethod + def get_by_service_uuid(cls, context, service_uuid): + """Find a exposed_service based on uuid and return a Board object. + + :param service_uuid: the uuid of a exposed_service. + :returns: a :class:`exposed_service` object. + """ + db_exp_service = cls.dbapi.get_exposed_service_by_service_uuid( + service_uuid) + exp_service = ExposedService._from_db_object(cls(context), + db_exp_service) + return exp_service + + @base.remotable_classmethod + def get(cls, context, board_uuid, service_uuid): + """Find a exposed_service based on uuid and return a Service object. + + :param board_uuid: the uuid of a exposed_service. + :returns: a :class:`exposed_service` object. + """ + db_exp_service = cls.dbapi.get_exposed_service_by_uuids(board_uuid, + service_uuid) + exp_service = ExposedService._from_db_object(cls(context), + db_exp_service) + return exp_service + + @base.remotable_classmethod + def list(cls, context, board_uuid): + """Return a list of ExposedService objects. + + :param context: Security context. + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :param filters: Filters to apply. + :returns: a list of :class:`ExposedService` object. + + """ + db_exps = cls.dbapi.get_exposed_service_list(board_uuid) + return [ExposedService._from_db_object(cls(context), obj) + for obj in db_exps] + + @base.remotable + def create(self, context=None): + """Create a ExposedService record in the DB. + + Column-wise updates will be made based on the result of + self.what_changed(). If target_power_state is provided, + it will be checked against the in-database copy of the + exposed_service before updates are made. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: ExposedService(context) + + """ + values = self.obj_get_changes() + db_exposed_service = self.dbapi.create_exposed_service(values) + self._from_db_object(self, db_exposed_service) + + @base.remotable + def destroy(self, context=None): + """Delete the ExposedService from the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: ExposedService(context) + """ + self.dbapi.destroy_exposed_service(self.id) + self.obj_reset_changes() + + @base.remotable + def save(self, context=None): + """Save updates to this ExposedService. + + Column-wise updates will be made based on the result of + self.what_changed(). If target_power_state is provided, + it will be checked against the in-database copy of the + exposed_service before updates are made. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: ExposedService(context) + """ + updates = self.obj_get_changes() + self.dbapi.update_exposed_service(self.id, updates) + self.obj_reset_changes() diff --git a/iotronic/objects/service.py b/iotronic/objects/service.py index b894e4b..0b1c3a8 100644 --- a/iotronic/objects/service.py +++ b/iotronic/objects/service.py @@ -21,11 +21,8 @@ from iotronic.db import api as db_api from iotronic.objects import base from iotronic.objects import utils as obj_utils -""" -ACTIONS = ['ServiceCall', 'ServiceStop', 'ServiceStart', - 'ServiceStatus', 'ServiceReboot'] -CUSTOM_PARAMS = ['ServiceCall', 'ServiceStart', 'ServiceReboot'] -NO_PARAMS = ['ServiceStatus'] + +ACTIONS = ['ServiceEnable', 'ServiceDisable', 'ServiceRestore'] def is_valid_action(action): @@ -34,16 +31,6 @@ def is_valid_action(action): return True -def want_customs_params(action): - return True if action in CUSTOM_PARAMS else False - - -def want_params(action): - return False if action in NO_PARAMS else True - -""" - - class Service(base.IotronicObject): # Version 1.0: Initial version VERSION = '1.0' @@ -154,6 +141,7 @@ class Service(base.IotronicObject): object, e.g.: Service(context) """ + values = self.obj_get_changes() db_service = self.dbapi.create_service(values) self._from_db_object(self, db_service) diff --git a/utils/iotronic.sql b/utils/iotronic.sql index 180a254..c6ead5b 100644 --- a/utils/iotronic.sql +++ b/utils/iotronic.sql @@ -167,8 +167,11 @@ CREATE TABLE IF NOT EXISTS `iotronic`.`exposed_services` ( `board_uuid` VARCHAR(36) NOT NULL, `service_uuid` VARCHAR(36) NOT NULL, `public_port` INT(5) NOT NULL, + `pid` INT(5) NOT NULL, PRIMARY KEY (`id`), INDEX `board_uuid` (`board_uuid` ASC), + CONSTRAINT unique_index + UNIQUE (service_uuid, board_uuid, pid), CONSTRAINT `fk_board_uuid` FOREIGN KEY (`board_uuid`) REFERENCES `iotronic`.`boards` (`uuid`)