Support links for task status

- Some status changes in a task may have additional information that
  is referenced by a URI link. Support describing these links and
  returning them via API.
- Refactor alembic stuff to better handle table schema updates
- Add unit tests

Change-Id: Iae63a9716f2522578be0244925fc274a4338eac4
This commit is contained in:
Scott Hussey 2018-07-02 16:20:07 -05:00
parent e27eaf94f5
commit cff7420cff
9 changed files with 244 additions and 14 deletions

View File

@ -0,0 +1,29 @@
"""add task status links
Revision ID: 4713e7ebca9
Revises: 4a5bef3702b
Create Date: 2018-07-05 14:54:18.381988
"""
# revision identifiers, used by Alembic.
revision = '4713e7ebca9'
down_revision = '4a5bef3702b'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from drydock_provisioner.statemgmt.db import tables
def upgrade():
for c in tables.Tasks.__add_result_links__:
op.add_column(tables.Tasks.__tablename__, c)
def downgrade():
for c in tables.Tasks.__add_result_links__:
op.drop_column(tables.Tasks.__tablename__, c.name)

View File

@ -19,7 +19,7 @@ from drydock_provisioner.statemgmt.db import tables
def upgrade(): def upgrade():
op.create_table(tables.BuildData.__tablename__, op.create_table(tables.BuildData.__tablename__,
*tables.BuildData.__schema__) *tables.BuildData.__baseschema__)
def downgrade(): def downgrade():

View File

@ -18,15 +18,15 @@ from drydock_provisioner.statemgmt.db import tables
def upgrade(): def upgrade():
op.create_table(tables.Tasks.__tablename__, *tables.Tasks.__schema__) op.create_table(tables.Tasks.__tablename__, *tables.Tasks.__baseschema__)
op.create_table(tables.ResultMessage.__tablename__, op.create_table(tables.ResultMessage.__tablename__,
*tables.ResultMessage.__schema__) *tables.ResultMessage.__baseschema__)
op.create_table(tables.ActiveInstance.__tablename__, op.create_table(tables.ActiveInstance.__tablename__,
*tables.ActiveInstance.__schema__) *tables.ActiveInstance.__baseschema__)
op.create_table(tables.BootAction.__tablename__, op.create_table(tables.BootAction.__tablename__,
*tables.BootAction.__schema__) *tables.BootAction.__baseschema__)
op.create_table(tables.BootActionStatus.__tablename__, op.create_table(tables.BootActionStatus.__tablename__,
*tables.BootActionStatus.__schema__) *tables.BootActionStatus.__baseschema__)
def downgrade(): def downgrade():

View File

@ -0,0 +1,30 @@
# Copyright 2018 AT&T Intellectual Property. All other 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.
"""Reusable utility functions for API access."""
from drydock_provisioner.error import ApiError
from drydock_provisioner.drydock_client.session import KeystoneClient
from drydock_provisioner.util import KeystoneUtils
def get_internal_api_href(ver):
"""Get the internal API href for Drydock API version ``ver``."""
# TODO(sh8121att) Support versioned service registration
supported_versions = ['v1.0']
if ver in supported_versions:
ks_sess = KeystoneUtils.get_session()
url = KeystoneClient.get_endpoint(
"physicalprovisioner", ks_sess=ks_sess, interface='internal')
return url
else:
raise ApiError("API version %s unknown." % ver)

View File

@ -180,12 +180,13 @@ class DrydockSession(object):
class KeystoneClient(object): class KeystoneClient(object):
@staticmethod @staticmethod
def get_endpoint(endpoint, ks_sess=None, auth_info=None): def get_endpoint(endpoint, ks_sess=None, auth_info=None, interface='internal'):
""" """
Wraps calls to keystone for lookup of an endpoint by service type Wraps calls to keystone for lookup of an endpoint by service type
:param endpoint: The endpoint to look up :param endpoint: The endpoint to look up
:param ks_sess: A keystone session to use for accessing endpoint catalogue :param ks_sess: A keystone session to use for accessing endpoint catalogue
:param auth_info: Authentication info to use for building a token if a ``ks_sess`` is not specified :param auth_info: Authentication info to use for building a token if a ``ks_sess`` is not specified
:param str interface: Which registered endpoint to return
:returns: The url string of the endpoint :returns: The url string of the endpoint
:rtype: str :rtype: str
""" """
@ -193,7 +194,7 @@ class KeystoneClient(object):
ks_sess = KeystoneClient.get_ks_session(**auth_info) ks_sess = KeystoneClient.get_ks_session(**auth_info)
return ks_sess.get_endpoint( return ks_sess.get_endpoint(
interface='internal', service_type=endpoint) interface=interface, service_type=endpoint)
@staticmethod @staticmethod
def get_token(ks_sess=None, auth_info=None): def get_token(ks_sess=None, auth_info=None):

View File

@ -371,6 +371,8 @@ class Task(object):
self.result.successes, self.result.successes,
'result_failures': 'result_failures':
self.result.failures, self.result.failures,
'result_links':
self.result.links,
'status': 'status':
self.status, self.status,
'created': 'created':
@ -486,6 +488,7 @@ class Task(object):
i.result.status = d.get('result_status') i.result.status = d.get('result_status')
i.result.successes = d.get('result_successes', []) i.result.successes = d.get('result_successes', [])
i.result.failures = d.get('result_failures', []) i.result.failures = d.get('result_failures', [])
i.result.links = d.get('result_links', [])
# Deserialize the request context for this task # Deserialize the request context for this task
if i.request_context is not None: if i.request_context is not None:
@ -506,6 +509,8 @@ class TaskStatus(object):
self.reason = None self.reason = None
self.status = hd_fields.ActionResult.Incomplete self.status = hd_fields.ActionResult.Incomplete
self.links = dict()
# For tasks operating on multiple contexts (nodes, networks, etc...) # For tasks operating on multiple contexts (nodes, networks, etc...)
# track which contexts ended successfully and which failed # track which contexts ended successfully and which failed
self.successes = [] self.successes = []
@ -515,6 +520,31 @@ class TaskStatus(object):
def obj_name(cls): def obj_name(cls):
return cls.__name__ return cls.__name__
def add_link(self, relation, uri):
"""Add a external reference link to this status.
:param str relation: The relation of the link
:param str uri: A valid URI that references the external content
"""
self.links.setdefault(relation, [])
self.links[relation].append(uri)
def get_links(self, relation=None):
"""Get one or more links of this status.
If ``relation`` is None, then return all links.
:param str relation: Return only links that exhibit this relation
:returns: a list of str URIs or empty list
"""
if relation:
return self.links.get(relation, [])
else:
all_links = list()
for v in self.links.values():
all_links.extend(v)
return all_links
def set_message(self, msg): def set_message(self, msg):
self.message = msg self.message = msg
@ -560,6 +590,11 @@ class TaskStatus(object):
return new_msg return new_msg
def to_dict(self): def to_dict(self):
links = list()
if self.links:
for k, v in self.links.items():
for r in v:
links.append(dict(rel=k, href=r))
return { return {
'kind': 'Status', 'kind': 'Status',
'apiVersion': 'v1.0', 'apiVersion': 'v1.0',
@ -569,6 +604,7 @@ class TaskStatus(object):
'status': self.status, 'status': self.status,
'successes': self.successes, 'successes': self.successes,
'failures': self.failures, 'failures': self.failures,
'links': links,
'details': { 'details': {
'errorCount': self.error_count, 'errorCount': self.error_count,
'messageList': [x.to_dict() for x in self.message_list], 'messageList': [x.to_dict() for x in self.message_list],

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Definitions for Drydock database tables.""" """Definitions for Drydock database tables."""
import copy
from sqlalchemy.schema import Table, Column from sqlalchemy.schema import Table, Column
from sqlalchemy.types import Boolean, DateTime, String, Integer, Text from sqlalchemy.types import Boolean, DateTime, String, Integer, Text
@ -30,7 +31,7 @@ class Tasks(ExtendTable):
__tablename__ = 'tasks' __tablename__ = 'tasks'
__schema__ = [ __baseschema__ = [
Column('task_id', pg.BYTEA(16), primary_key=True), Column('task_id', pg.BYTEA(16), primary_key=True),
Column('parent_task_id', pg.BYTEA(16)), Column('parent_task_id', pg.BYTEA(16)),
Column('subtask_id_list', pg.ARRAY(pg.BYTEA(16))), Column('subtask_id_list', pg.ARRAY(pg.BYTEA(16))),
@ -54,13 +55,19 @@ class Tasks(ExtendTable):
Column('terminate', Boolean, default=False) Column('terminate', Boolean, default=False)
] ]
__add_result_links__ = [
Column('result_links', pg.JSON),
]
__schema__ = copy.copy(__baseschema__)
__schema__.extend(__add_result_links__)
class ResultMessage(ExtendTable): class ResultMessage(ExtendTable):
"""Table for tracking result/status messages.""" """Table for tracking result/status messages."""
__tablename__ = 'result_message' __tablename__ = 'result_message'
__schema__ = [ __baseschema__ = [
Column('sequence', Integer, primary_key=True), Column('sequence', Integer, primary_key=True),
Column('task_id', pg.BYTEA(16)), Column('task_id', pg.BYTEA(16)),
Column('message', String(1024)), Column('message', String(1024)),
@ -71,37 +78,43 @@ class ResultMessage(ExtendTable):
Column('extra', pg.JSON) Column('extra', pg.JSON)
] ]
__schema__ = copy.copy(__baseschema__)
class ActiveInstance(ExtendTable): class ActiveInstance(ExtendTable):
"""Table to organize multiple orchestrator instances.""" """Table to organize multiple orchestrator instances."""
__tablename__ = 'active_instance' __tablename__ = 'active_instance'
__schema__ = [ __baseschema__ = [
Column('dummy_key', Integer, primary_key=True), Column('dummy_key', Integer, primary_key=True),
Column('identity', pg.BYTEA(16)), Column('identity', pg.BYTEA(16)),
Column('last_ping', DateTime), Column('last_ping', DateTime),
] ]
__schema__ = copy.copy(__baseschema__)
class BootAction(ExtendTable): class BootAction(ExtendTable):
"""Table persisting node build data.""" """Table persisting node build data."""
__tablename__ = 'boot_action' __tablename__ = 'boot_action'
__schema__ = [ __baseschema__ = [
Column('node_name', String(280), primary_key=True), Column('node_name', String(280), primary_key=True),
Column('task_id', pg.BYTEA(16)), Column('task_id', pg.BYTEA(16)),
Column('identity_key', pg.BYTEA(32)), Column('identity_key', pg.BYTEA(32)),
] ]
__schema__ = copy.copy(__baseschema__)
class BootActionStatus(ExtendTable): class BootActionStatus(ExtendTable):
"""Table tracking status of node boot actions.""" """Table tracking status of node boot actions."""
__tablename__ = 'boot_action_status' __tablename__ = 'boot_action_status'
__schema__ = [ __baseschema__ = [
Column('node_name', String(280), index=True), Column('node_name', String(280), index=True),
Column('action_id', pg.BYTEA(16), primary_key=True), Column('action_id', pg.BYTEA(16), primary_key=True),
Column('action_name', String(64)), Column('action_name', String(64)),
@ -110,13 +123,15 @@ class BootActionStatus(ExtendTable):
Column('action_status', String(32)), Column('action_status', String(32)),
] ]
__schema__ = copy.copy(__baseschema__)
class BuildData(ExtendTable): class BuildData(ExtendTable):
"""Table for persisting node build data.""" """Table for persisting node build data."""
__tablename__ = 'build_data' __tablename__ = 'build_data'
__schema__ = [ __baseschema__ = [
Column('node_name', String(32), index=True), Column('node_name', String(32), index=True),
Column('task_id', pg.BYTEA(16), index=True), Column('task_id', pg.BYTEA(16), index=True),
Column('collected_date', DateTime), Column('collected_date', DateTime),
@ -124,3 +139,5 @@ class BuildData(ExtendTable):
Column('data_format', String(32)), Column('data_format', String(32)),
Column('data_element', Text), Column('data_element', Text),
] ]
__schema__ = copy.copy(__baseschema__)

View File

@ -26,6 +26,9 @@
# The URI database connect string. (string value) # The URI database connect string. (string value)
#database_connect_string = <None> #database_connect_string = <None>
# The SQLalchemy database connection pool size. (integer value)
#pool_size = 15
[keystone_authtoken] [keystone_authtoken]

View File

@ -0,0 +1,114 @@
# Copyright 2018 AT&T Intellectual Property. All other 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.
'''Tests the functions for adding and retrieving task status links.'''
from drydock_provisioner.objects import TaskStatus
class TestTaskStatusLinks():
def test_links_add(self):
'''Add a link to a task status.'''
ts = TaskStatus()
relation = 'test'
uri = 'http://foo.com/test'
ts.add_link(relation, uri)
assert relation in ts.links
assert uri in ts.links.get(relation, [])
def test_links_get_empty(self):
'''Get links with an empty list.'''
ts = TaskStatus()
links = ts.get_links()
assert len(links) == 0
relation = 'test'
uri = 'http://foo.com/test'
ts.add_link(relation, uri)
links = ts.get_links(relation='none')
assert len(links) == 0
def test_links_get_all(self):
'''Get all links in a task status.'''
ts = TaskStatus()
relation = 'test'
uri = 'http://foo.com/test'
ts.add_link(relation, uri)
links = ts.get_links()
assert len(links) == 1
assert uri in links
def test_links_get_all_duplicate_relation(self):
'''Get all links where a relation has multiple uris.'''
ts = TaskStatus()
relation = 'test'
uri = 'http://foo.com/test'
uri2 = 'http://baz.com/test'
ts.add_link(relation, uri)
ts.add_link(relation, uri2)
links = ts.get_links()
assert len(links) == 2
assert uri in links
assert uri2 in links
def test_links_get_filter(self):
'''Get links with a filter.'''
ts = TaskStatus()
relation = 'test'
uri = 'http://foo.com/test'
relation2 = 'test2'
uri2 = 'http://baz.com/test'
ts.add_link(relation, uri)
ts.add_link(relation2, uri2)
links = ts.get_links(relation=relation)
assert len(links) == 1
assert uri in links
links = ts.get_links(relation=relation2)
assert len(links) == 1
assert uri2 in links
def test_links_serialization(self):
'''Check that task status serilization contains links correctly.'''
ts = TaskStatus()
relation = 'test'
uri = 'http://bar.com'
ts.set_message('foo')
ts.set_reason('bar')
ts.add_link(relation, uri)
ts_dict = ts.to_dict()
assert isinstance(ts_dict.get('links'), list)
assert {'rel': relation, 'href': uri} in ts_dict.get('links', [])