Add tag API

API handler for creating tags.

Implements: blueprint role-decomposition
Change-Id: I04c184f287d49ad2c15803809b8025f7351b8985
This commit is contained in:
Ryan Moe 2016-09-06 10:42:59 -07:00 committed by Viacheslav Valyavskiy
parent 5c8cbd26e3
commit 327f754d90
18 changed files with 572 additions and 2 deletions

View File

@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Mirantis, Inc.
#
# 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.
"""
Handlers dealing with tags
"""
from nailgun.api.v1.handlers.base import BaseHandler
from nailgun.api.v1.handlers.base import CollectionHandler
from nailgun.api.v1.handlers.base import handle_errors
from nailgun.api.v1.handlers.base import SingleHandler
from nailgun.api.v1.validators.tag import TagValidator
from nailgun import errors
from nailgun import objects
class TagOwnerHandler(CollectionHandler):
collection = objects.TagCollection
owner_map = {
'releases': 'release',
'clusters': 'cluster',
'plugins': 'plugin'
}
@handle_errors
def GET(self, owner_type, owner_id):
""":returns: JSONized list of tags.
:http: * 200 (OK)
"""
tags = objects.TagCollection.filter_by(
None,
owner_type=self.owner_map[owner_type],
owner_id=owner_id
)
return self.collection.to_list(tags)
@handle_errors
def POST(self, owner_type, owner_id):
"""Assign tags to node
:http:
* 201 (tag successfully created)
* 400 (invalid object data specified)
"""
data = self.checked_data()
data['owner_type'] = self.owner_map[owner_type]
data['owner_id'] = owner_id
try:
tag = self.collection.create(data)
except errors.CannotCreate as exc:
raise self.http(400, exc.message)
raise self.http(201, self.collection.single.to_json(tag))
class TagHandler(SingleHandler):
"""Tag single handler"""
single = objects.Tag
validator = TagValidator
class NodeTagAssignmentHandler(BaseHandler):
@handle_errors
def POST(self, node_id):
"""Assign tags to node
:http:
* 200 (tags successfully assigned)
* 400 (invalid object data specified)
* 404 (node instance or tags not found)
"""
node = self.get_object_or_404(
objects.Node,
node_id
)
tag_ids = self.get_param_as_set('tags')
tags = self.get_objects_list_or_404(
objects.TagCollection,
tag_ids
)
objects.Node.assign_tags(node, tags)
raise self.http(200, None)
@handle_errors
def DELETE(self, node_id):
"""Unassign tags from node
:http:
* 200 (tags successfully unassigned)
* 400 (invalid object data specified)
* 404 (node instance or tags not found)
"""
node = self.get_object_or_404(
objects.Node,
node_id
)
tag_ids = self.get_param_as_set('tags')
tags = self.get_objects_list_or_404(
objects.TagCollection,
tag_ids
)
objects.Node.unassign_tags(node, tags)
raise self.http(200, None)

View File

@ -111,6 +111,10 @@ from nailgun.api.v1.handlers.role import ClusterRolesHandler
from nailgun.api.v1.handlers.role import RoleCollectionHandler
from nailgun.api.v1.handlers.role import RoleHandler
from nailgun.api.v1.handlers.tag import NodeTagAssignmentHandler
from nailgun.api.v1.handlers.tag import TagHandler
from nailgun.api.v1.handlers.tag import TagOwnerHandler
from nailgun.api.v1.handlers.tasks import TaskCollectionHandler
from nailgun.api.v1.handlers.tasks import TaskHandler
from nailgun.api.v1.handlers.transactions import TransactionClusterSettings
@ -289,6 +293,12 @@ urls = (
NodeAttributesHandler,
r'/nodes/allocation/stats/?$',
NodesAllocationStatsHandler,
r'/nodes/(?P<node_id>\d+)/tags/?$',
NodeTagAssignmentHandler,
r'/tags/(?P<obj_id>\d+)/?$',
TagHandler,
r'/(releases|clusters|plugins)/(?P<owner_id>\d+)/tags/?$',
TagOwnerHandler,
r'/tasks/?$',
TaskCollectionHandler,
r'/tasks/(?P<obj_id>\d+)/?$',

View File

@ -0,0 +1,27 @@
# Copyright 2016 Mirantis, Inc.
#
# 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.
TAG_CREATION_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Tag",
"description": "Serialized Tag object",
"type": "object",
"properties": {
"id": {"type": "integer"},
"tag": {"type": "string"},
"has_primary": {"type": "boolean"}
},
"required": ["tag"],
}

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Mirantis, Inc.
#
# 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 nailgun.api.v1.validators.base import BasicValidator
from nailgun.api.v1.validators.json_schema import tag
from nailgun import errors
class TagValidator(BasicValidator):
single_schema = tag.TAG_CREATION_SCHEMA
@classmethod
def validate_delete(cls, data, instance):
if instance.read_only:
raise errors.CannotDelete(
"Read-only tags cannot be deleted."
)

View File

@ -526,3 +526,9 @@ HYPERVISORS = Enum(
"kvm",
"qemu"
)
TAG_OWNER_TYPES = Enum(
'release',
'cluster',
'plugin'
)

View File

@ -22,9 +22,12 @@ Create Date: 2016-04-08 15:20:43.989472
from alembic import op
from oslo_serialization import jsonutils
import sqlalchemy as sa
from nailgun import consts
from nailgun.db.sqlalchemy.models import fields
from nailgun.utils.migration import drop_enum
# revision identifiers, used by Alembic.
@ -35,13 +38,116 @@ down_revision = 'f2314e5d63c9'
def upgrade():
upgrade_plugin_links_constraints()
upgrade_release_required_component_types()
upgrade_node_tagging()
upgrade_tags_existing_nodes()
def downgrade():
downgrade_node_tagging()
downgrade_release_required_component_types()
downgrade_plugin_links_constraints()
def upgrade_tags_existing_nodes():
connection = op.get_bind()
node_query = sa.sql.text(
"SELECT n.id as n_id, unnest(roles || pending_roles) AS role, "
"primary_roles, r.id AS release_id FROM nodes n "
"JOIN clusters c ON n.cluster_id=c.id "
"JOIN releases r ON r.id=c.release_id"
)
tag_assign_query = sa.sql.text(
"INSERT INTO node_tags (node_id, tag_id, is_primary) "
"VALUES(:node_id, :tag_id, :is_primary)"
)
tag_select_query = sa.sql.text(
"SELECT id FROM tags WHERE owner_id=:id AND "
"owner_type='release' AND tag=:tag"
)
select_query = sa.sql.text(
"SELECT id, roles_metadata FROM releases "
"WHERE roles_metadata IS NOT NULL"
)
insert_query = sa.sql.text(
"INSERT INTO tags (tag, owner_id, owner_type, has_primary, read_only) "
"VALUES(:tag, :owner_id, 'release', :has_primary, true) RETURNING id"
)
# Create tags for all release roles
for id, roles_metadata in connection.execute(select_query):
roles_metadata = jsonutils.loads(roles_metadata)
for role_name, role_metadata in roles_metadata.items():
connection.execute(
insert_query,
tag=role_name,
owner_id=id,
has_primary=roles_metadata.get('has_primary', False)
)
for id, role, primary_roles, release_id in connection.execute(node_query):
tag = connection.execute(
tag_select_query,
id=release_id,
tag=role
).fetchone()
if not tag:
continue
connection.execute(
tag_assign_query,
node_id=id,
tag_id=tag.id,
is_primary=role in primary_roles
)
def upgrade_node_tagging():
op.create_table(
'tags',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tag', sa.String(64), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column(
'owner_type',
sa.Enum(
*consts.TAG_OWNER_TYPES,
name='tag_owner_type'),
nullable=False),
sa.Column('has_primary', sa.Boolean),
sa.Column('read_only', sa.Boolean),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'owner_type', 'owner_id', 'tag',
name='__tag_owner_uc'),
)
op.create_table(
'node_tags',
sa.Column('id', sa.Integer()),
sa.Column('node_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.Column('is_primary', sa.Boolean, default=False),
sa.ForeignKeyConstraint(
['node_id'], ['nodes.id'], ondelete='CASCADE'
),
sa.ForeignKeyConstraint(
['tag_id'], ['tags.id'], ondelete='CASCADE'
)
)
op.add_column(
'releases',
sa.Column('tags_metadata', fields.JSON(), nullable=True),
)
def downgrade_node_tagging():
op.drop_table('node_tags')
op.drop_table('tags')
drop_enum('tag_owner_type')
op.drop_column('releases', 'tags_metadata')
def upgrade_plugin_links_constraints():
connection = op.get_bind()

View File

@ -52,7 +52,9 @@ from nailgun.extensions.network_manager.models.network import \
from nailgun.extensions.network_manager.models import network
from nailgun.db.sqlalchemy.models.node import Node
from nailgun.db.sqlalchemy.models.node import NodeGroup
from nailgun.db.sqlalchemy.models.node import NodeTag
from nailgun.db.sqlalchemy.models.tag import Tag
from nailgun.extensions.network_manager.models.network_config import \
NetworkingConfig

View File

@ -55,6 +55,24 @@ class NodeGroup(Base):
)
class NodeTag(Base):
__tablename__ = 'node_tags'
# NOTE(rmoe): This is required to satisfy some tests. When DEVELOPMENT=1
# in settings.yaml ObserverModelBase is used as the declarative base
# class. __setattr__ defined in that class requires a mapped object to
# have an id field.
id = Column(Integer)
node_id = Column(
ForeignKey('nodes.id', ondelete='CASCADE'), primary_key=True
)
tag_id = Column(
ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True
)
is_primary = Column(Boolean, default=False)
tag = relationship('Tag')
class Node(Base):
__tablename__ = 'nodes'
__table_args__ = (
@ -95,13 +113,13 @@ class Node(Base):
online = Column(Boolean, default=True)
labels = Column(
MutableDict.as_mutable(JSON), nullable=False, server_default='{}')
tags = relationship('NodeTag', cascade='delete, delete-orphan')
roles = Column(psql.ARRAY(String(consts.ROLE_NAME_MAX_SIZE)),
default=[], nullable=False, server_default='{}')
pending_roles = Column(psql.ARRAY(String(consts.ROLE_NAME_MAX_SIZE)),
default=[], nullable=False, server_default='{}')
primary_roles = Column(psql.ARRAY(String(consts.ROLE_NAME_MAX_SIZE)),
default=[], nullable=False, server_default='{}')
nic_interfaces = relationship("NodeNICInterface", backref="node",
cascade="all, delete-orphan",
order_by="NodeNICInterface.name")
@ -169,6 +187,10 @@ class Node(Base):
def full_name(self):
return u'%s (id=%s, mac=%s)' % (self.name, self.id, self.mac)
@property
def tag_names(self):
return (t.tag.tag for t in self.tags)
@property
def all_roles(self):
"""Returns all roles, self.roles and self.pending_roles."""

View File

@ -54,6 +54,7 @@ class Release(Base):
volumes_metadata = Column(MutableDict.as_mutable(JSON), default={})
modes_metadata = Column(MutableDict.as_mutable(JSON), default={})
roles_metadata = Column(MutableDict.as_mutable(JSON), default={})
tags_metadata = Column(MutableDict.as_mutable(JSON), default={})
network_roles_metadata = Column(
MutableList.as_mutable(JSON), default=[], server_default='[]')
vmware_attributes_metadata = Column(

View File

@ -0,0 +1,26 @@
from sqlalchemy import Boolean
from sqlalchemy import Column
from sqlalchemy import Enum
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import UniqueConstraint
from nailgun import consts
from nailgun.db.sqlalchemy.models.base import Base
class Tag(Base):
__tablename__ = 'tags'
__table_args__ = (
UniqueConstraint('owner_type', 'owner_id', 'tag',
name='_tag_owner_uc'),
)
id = Column(Integer, primary_key=True)
tag = Column(String(64), nullable=False)
owner_id = Column(Integer, nullable=False)
owner_type = Column(
Enum(*consts.TAG_OWNER_TYPES, name='tag_owner_type'),
nullable=False
)
has_primary = Column(Boolean, default=False)
read_only = Column(Boolean, default=False)

View File

@ -66,6 +66,9 @@ from nailgun.extensions.network_manager.objects.interface import NICCollection
from nailgun.extensions.network_manager.objects.bond import Bond
from nailgun.extensions.network_manager.objects.bond import BondCollection
from nailgun.objects.tag import Tag
from nailgun.objects.tag import TagCollection
from nailgun.objects.node import Node
from nailgun.objects.node import NodeAttributes
from nailgun.objects.node import NodeCollection

View File

@ -595,6 +595,7 @@ class Node(NailgunObject):
"""
data.pop("id", None)
data.pop("network_data", None)
data.pop("tags", None)
roles = data.pop("roles", None)
pending_roles = data.pop("pending_roles", None)
@ -1030,6 +1031,43 @@ class Node(NailgunObject):
db().flush()
db().refresh(instance)
@classmethod
def assign_tags(cls, instance, tags):
"""Assign tags to node.
Assigns tags to node skipping already assigned
tags.
:param instance: Node instance
:param tags: List of tags
:returns: None
"""
node_tags = set(t.tag for t in instance.tags)
tags_to_assign = set(tags) - node_tags
for tag in tags_to_assign:
t = models.NodeTag(tag=tag, node_id=instance.id)
db().add(t)
instance.tags.append(t)
db().flush()
@classmethod
def unassign_tags(cls, instance, tags):
"""Remove tags from node.
:param instance: Node instance
:param tags: List of tags
:returns: None
"""
node_tags = set(t.tag for t in instance.tags)
tags_to_remove = set(tags) & node_tags
tags = copy.copy(instance.tags)
for assoc in tags:
if assoc.tag in tags_to_remove:
instance.tags.remove(assoc)
db().flush()
@classmethod
def move_roles_to_pending_roles(cls, instance):
"""Move roles to pending_roles"""

View File

@ -50,4 +50,5 @@ class NodeSerializer(BasicSerializer):
data_dict = super(NodeSerializer, cls).serialize(instance, fields)
data_dict['fqdn'] = Node.get_node_fqdn(instance)
data_dict['status'] = Node.get_status(instance)
data_dict['tags'] = instance.tag_names
return data_dict

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Mirantis, Inc.
#
# 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 nailgun.objects.serializers.base import BasicSerializer
class TagSerializer(BasicSerializer):
fields = (
"id",
"tag",
"owner_id",
"owner_type",
"has_primary"
)

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Mirantis, Inc.
#
# 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.
"""
Tag object and collection
"""
from nailgun.db.sqlalchemy import models
from nailgun.objects import NailgunCollection
from nailgun.objects import NailgunObject
from nailgun.objects.serializers.tag import TagSerializer
class Tag(NailgunObject):
model = models.Tag
serializer = TagSerializer
class TagCollection(NailgunCollection):
single = Tag

View File

@ -529,7 +529,8 @@ class TestInstallationInfo(BaseTestCase):
'nodegroups', 'ip_addrs', 'node_nic_interfaces',
'node_bond_interfaces', 'network_groups',
'node_nic_interface_cluster_plugins',
'node_bond_interface_cluster_plugins', 'node_cluster_plugins'
'node_bond_interface_cluster_plugins', 'node_cluster_plugins',
'node_tags'
)
for field in remove_fields:
node_schema.pop(field)

View File

@ -109,3 +109,12 @@ class TestRequiredComponentTypesField(base.BaseAlembicMigrationTest):
def test_downgrade_release_required_component_types(self):
releases_table = self.meta.tables['releases']
self.assertNotIn('required_component_types', releases_table.c)
class TestNodeTagging(base.BaseAlembicMigrationTest):
def test_downgrade_release_tags_metadata(self):
releases_table = self.meta.tables['releases']
self.assertNotIn('tags_metadata', releases_table.c)
self.assertNotIn('tags', self.meta.tables)
self.assertNotIn('node_tags', self.meta.tables)

View File

@ -175,9 +175,11 @@ def prepare():
}])
cluster_ids.append(result.inserted_primary_key[0])
node_id = 1
result = db.execute(
meta.tables['nodes'].insert(),
[{
'id': node_id,
'uuid': '26b508d0-0d76-4159-bce9-f67ec2765480',
'cluster_id': None,
'group_id': None,
@ -324,10 +326,102 @@ def prepare():
]
)
db.execute(
meta.tables['node_nic_interfaces'].insert(),
[{
'id': 1,
'node_id': node_id,
'name': 'test_interface',
'mac': '00:00:00:00:00:01',
'max_speed': 200,
'current_speed': 100,
'ip_addr': '10.20.0.2',
'netmask': '255.255.255.0',
'state': 'test_state',
'interface_properties': jsonutils.dumps(
{'test_property': 'test_value'}),
'driver': 'test_driver',
'bus_info': 'some_test_info'
}]
)
db.execute(
meta.tables['node_bond_interfaces'].insert(),
[{
'node_id': node_id,
'name': 'test_bond_interface',
'mode': 'active-backup',
'bond_properties': jsonutils.dumps(
{'test_property': 'test_value'})
}]
)
result = db.execute(
meta.tables['tasks'].insert(),
[
{
'id': 55,
'uuid': '219eaafe-01a1-4f26-8edc-b9d9b0df06b3',
'name': 'deployment',
'status': 'running',
'deployment_info': jsonutils.dumps(DEPLOYMENT_INFO[55])
},
{
'id': 56,
'uuid': 'a45fbbcd-792c-4245-a619-f4fb2f094d38',
'name': 'deployment',
'status': 'running',
'deployment_info': jsonutils.dumps(DEPLOYMENT_INFO[56])
}
]
)
result = db.execute(
meta.tables['nodes'].insert(),
[{
'id': 2,
'uuid': 'fcd49872-3917-4a18-98f9-3f5acfe3fdec',
'cluster_id': cluster_ids[0],
'group_id': None,
'status': 'ready',
'roles': ['controller', 'ceph-osd'],
'meta': '{}',
'mac': 'bb:aa:aa:aa:aa:aa',
'timestamp': datetime.datetime.utcnow(),
}]
)
TestRequiredComponentTypesField.prepare(meta)
db.commit()
class TestTagExistingNodes(base.BaseAlembicMigrationTest):
def test_tags_created_on_upgrade(self):
tags_count = db.execute(
sa.select(
[sa.func.count(self.meta.tables['tags'].c.id)]
)).fetchone()[0]
self.assertEqual(tags_count, 11)
def test_nodes_assigned_tags(self):
tags = self.meta.tables['tags']
node_tags = self.meta.tables['node_tags']
query = sa.select([tags.c.tag]).select_from(
sa.join(
tags, node_tags,
tags.c.id == node_tags.c.tag_id
)
).where(
node_tags.c.node_id == 2
)
res = db.execute(query)
tags = [t[0] for t in res]
self.assertItemsEqual(tags, ['controller', 'ceph-osd'])
class TestPluginLinksConstraints(base.BaseAlembicMigrationTest):
# see initial data in setup section
def test_plugin_links_duplicate_cleanup(self):