Add ovsdb-subordinate interface

Interface for use between a subordinate providing payload with OVSDB
and a consumnig princple charm.

Also fixup interface.yamls in the individual interface subdirectories; repo
locations, add subdir key for clarity and remove ignore key as it is not
needed.

Change-Id: I9eff6c6e71ca314b2c723b4aa7f482f9d297c962
This commit is contained in:
Frode Nordahl 2020-01-30 12:27:52 +01:00
parent f5580e571d
commit 41235cd678
No known key found for this signature in database
GPG Key ID: 6A5D59A3BA48373F
10 changed files with 636 additions and 24 deletions

View File

@ -1,11 +1,5 @@
name: ovsdb
summary: Interface for OVSDB
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
repo: https://github.com/openstack-charmers/charm-interface-ovsdb.git
ignore:
- 'unit_tests'
- '.stestr.conf'
- 'test-requirements.txt'
- 'tox.ini'
- '.gitignore'
- '.travis.yml'
repo: https://opendev.org/x/charm-interface-ovsdb.git
subdir: src/ovsdb

View File

@ -1,11 +1,5 @@
name: ovsdb-cluster
summary: Interface for OVSDB
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
repo: https://github.com/openstack-charmers/charm-interface-ovsdb.git
ignore:
- 'unit_tests'
- '.stestr.conf'
- 'test-requirements.txt'
- 'tox.ini'
- '.gitignore'
- '.travis.yml'
repo: https://opendev.org/x/charm-interface-ovsdb.git
subdir: src/ovsdb_cluster

View File

@ -1,11 +1,5 @@
name: ovsdb-cms
summary: Interface for OVSDB
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
repo: https://github.com/openstack-charmers/charm-interface-ovsdb.git
ignore:
- 'unit_tests'
- '.stestr.conf'
- 'test-requirements.txt'
- 'tox.ini'
- '.gitignore'
- '.travis.yml'
repo: https://opendev.org/x/charm-interface-ovsdb.git
subdir: src/ovsdb_cms

View File

@ -0,0 +1,5 @@
name: ovsdb-subordinate
summary: Interface for subordinate relation between ovn-chassis and principle
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
repo: https://opendev.org/x/charm-interface-ovsdb.git
subdir: src/ovsdb_subordinate

View File

@ -0,0 +1,30 @@
# Copyright 2020 Canonical Ltd
#
# 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.
"""Common functions for the ``ovsdb-subordinate`` interface classes"""
import hashlib
def hash_hexdigest(s):
"""Hash string using SHA-2 256/224 function and return a hexdigest
:param s: String data
:type s: str
:returns: hexdigest of hashed data
:rtype: str
:raises: TypeError
"""
if not isinstance(s, str):
raise TypeError
return hashlib.sha224(s.encode('utf8')).hexdigest()

View File

@ -0,0 +1,149 @@
# Copyright 2020 Canonical Ltd
#
# 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 charms.reactive as reactive
# the reactive framework unfortunately does not grok `import as` in conjunction
# with decorators on class instance methods, so we have to revert to `from ...`
# imports
from charms.reactive import (
Endpoint,
when,
when_not,
)
from .ovsdb_subordinate_common import hash_hexdigest
class OVSDBSubordinateProvides(Endpoint):
"""This interface is used on a principle charm to connect to subordinate
"""
@property
def chassis_name(self):
"""Retrieve chassis-name from relation data
:returns: Chassis name as provided on subordinate relation
:rtype: str
"""
return self.all_joined_units.received.get('chassis-name', '')
@property
def ovn_configured(self):
"""Retrieve whether OVN is configured from relation data
:returns: True or False
:rtype: bool
"""
return self.all_joined_units.received.get('ovn-configured', False)
def _add_interface_request(self, bridge, ifname, ifdata):
"""Retrieve interface requests from relation and add/update requests
:param bridge: Name of bridge
:type bridge: str
:param ifname: Name of interface
:type ifname: str
:param ifdata: Data to be attached to interface in Open vSwitch
:type ifdata: Dict[str,Union[str,Dict[str,str]]]
"""
for relation in self.relations:
relation_ifs = relation.to_publish.get('create-interfaces', {})
relation_ifs.update({bridge: {ifname: ifdata}})
relation.to_publish['create-interfaces'] = relation_ifs
def _interface_requests(self):
"""Retrieve interface requests from relation
:returns: Current interface requests
:rtype: Optional[Dict[str,Union[str,Dict[str,str]]]]
"""
for relation in self.relations:
return relation.to_publish_raw.get('create-interfaces')
def create_interface(self, bridge, ifname, ethaddr, ifid,
iftype=None, ifstatus=None):
"""Request system interface created and attach it to CMS
Calls to this function are additive so a principle charm can request to
have multiple interfaces created and maintained.
The flag {endpoint_name}.{interface_name}.created will be set when
ready.
:param bridge: Bridge the new interface should be created on
:type bridge: str
:param ifname: Interface name we want the new netdev to get
:type ifname: str
:param ethaddr: Ethernet address we want to attach to the netdev
:type ethaddr: str
:param ifid: Unique identifier for port from CMS
:type ifid: str
:param iftype: Interface type, defaults to 'internal'
:type iftype: Optional[str]
:param ifstatus: Interface status, defaults to 'active'
:type ifstatus: Optional[str]
"""
# The keys in the ifdata dictionary map directly to column names in the
# OpenvSwitch Interface table as defined in DB-SCHEMA [0] referenced in
# RFC 7047 [1]
#
# There are some established conventions for keys in the external-ids
# column of various tables, consult the OVS Integration Guide [2] for
# more details.
#
# NOTE(fnordahl): Technically the ``external-ids`` column is called
# ``external_ids`` (with an underscore) and we rely on ``ovs-vsctl``'s
# behaviour of transforming dashes to underscores for us [3] so we can
# have a more pleasant data structure.
#
# 0: http://www.openvswitch.org/ovs-vswitchd.conf.db.5.pdf
# 1: https://tools.ietf.org/html/rfc7047
# 2: http://docs.openvswitch.org/en/latest/topics/integration/
# 3: https://github.com/openvswitch/ovs/blob/
# 20dac08fdcce4b7fda1d07add3b346aa9751cfbc/
# lib/db-ctl-base.c#L189-L215
ifdata = {
'type': iftype or 'internal',
'external-ids': {
'iface-id': ifid,
'iface-status': ifstatus or 'active',
'attached-mac': ethaddr,
},
}
self._add_interface_request(bridge, ifname, ifdata)
reactive.clear_flag(
self.expand_name('{endpoint_name}.interfaces.created'))
@when('endpoint.{endpoint_name}.joined')
def joined(self):
reactive.set_flag(self.expand_name('{endpoint_name}.connected'))
reactive.set_flag(self.expand_name('{endpoint_name}.available'))
@when_not('endpoint.{endpoint_name}.joined')
def broken(self):
reactive.clear_flag(self.expand_name('{endpoint_name}.available'))
reactive.clear_flag(self.expand_name('{endpoint_name}.connected'))
@when('endpoint.{endpoint_name}.changed.interfaces-created')
def new_requests(self):
ifreq = self._interface_requests()
if ifreq is not None and self.all_joined_units.received[
'interfaces-created'] == hash_hexdigest(ifreq):
reactive.set_flag(
self.expand_name('{endpoint_name}.interfaces.created'))
reactive.clear_flag(
self.expand_name(
'endpoint.{endpoint_name}.changed.interfaces-created'))

View File

@ -0,0 +1,115 @@
# Copyright 2020 Canonical Ltd
#
# 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 subprocess
import charms.reactive as reactive
# the reactive framework unfortunately does not grok `import as` in conjunction
# with decorators on class instance methods, so we have to revert to `from ...`
# imports
from charms.reactive import (
Endpoint,
when,
when_not,
)
from .ovsdb_subordinate_common import hash_hexdigest
class OVSDBSubordinateRequires(Endpoint):
"""This interface is used on the subordinate side of the relation"""
def _get_ovs_value(self, tbl, col, rec=None):
"""Get value of column in record in table
:param tbl: Name of table
:type tbl: str
:param col: Name of column
:type col: str
:param rec: Record ID
:type rec: Optional[str]
:raises: subprocess.CalledProcessError
"""
cp = subprocess.run(('ovs-vsctl', 'get', tbl, rec or '.', col),
stdout=subprocess.PIPE,
check=True, universal_newlines=True)
return cp.stdout.rstrip().replace('"', '').replace("'", '')
def publish_chassis_name(self):
"""Publish chassis name"""
ovs_hostname = self._get_ovs_value('Open_vSwitch',
'external_ids:hostname')
for relation in self.relations:
relation.to_publish['chassis-name'] = ovs_hostname
def publish_ovn_configured(self):
"""Publish whether OVN is configured in the local OVSDB"""
ovn_configured = False
try:
self._get_ovs_value('Open_vSwitch', 'external_ids:ovn-remote')
ovn_configured = True
except subprocess.CalledProcessError:
# No OVN
pass
for relation in self.relations:
relation.to_publish['ovn-configured'] = ovn_configured
@property
def interface_requests(self):
"""Retrieve current interface requests
:returns: Current interface requests
:rtype: Dict[str,Union[str,Dict[str,str]]]
"""
return self.all_joined_units.received.get('create-interfaces', {})
def interface_requests_handled(self):
"""Notify peer that interface requests has been dealt with
Sets a hash of request data back on relation to signal to the other end
it has been dealt with so it can proceed.
Note that we do not use the reactive request response pattern library
as we do not have use for per-unit granularity and we do not have
actual useful data to return.
"""
# The raw data is a json dump using sorted keys
ifreq_hexdigest = hash_hexdigest(
self.all_joined_units.received_raw['create-interfaces'])
for relation in self.relations:
relation.to_publish['interfaces-created'] = ifreq_hexdigest
reactive.clear_flag(
self.expand_name('{endpoint_name}.interfaces.new_requests'))
@when('endpoint.{endpoint_name}.joined')
def joined(self):
self.publish_chassis_name()
self.publish_ovn_configured()
reactive.set_flag(self.expand_name('{endpoint_name}.connected'))
reactive.set_flag(self.expand_name('{endpoint_name}.available'))
@when_not('endpoint.{endpoint_name}.joined')
def broken(self):
reactive.clear_flag(self.expand_name('{endpoint_name}.available'))
reactive.clear_flag(self.expand_name('{endpoint_name}.connected'))
@when('endpoint.{endpoint_name}.changed.create-interfaces')
def new_requests(self):
reactive.set_flag(
self.expand_name('{endpoint_name}.interfaces.new_requests'))
reactive.clear_flag(
self.expand_name(
'endpoint.{endpoint_name}.changed.create-interfaces'))

View File

@ -0,0 +1,42 @@
# Copyright 2020 Canonical Ltd
#
# 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 hashlib
from ovsdb_subordinate import ovsdb_subordinate_common as common
import charms_openstack.test_utils as test_utils
class TestOVSDBSubordinateCommon(test_utils.PatchHelper):
def setUp(self):
super().setUp()
self._patches = {}
self._patches_start = {}
def tearDown(self):
for k, v in self._patches.items():
v.stop()
setattr(self, k, None)
self._patches = None
self._patches_start = None
def test_hash_hexdigest(self):
s = 's'
self.assertEquals(
common.hash_hexdigest(s),
hashlib.sha224(s.encode('utf8')).hexdigest())
with self.assertRaises(TypeError):
common.hash_hexdigest({})

View File

@ -0,0 +1,145 @@
# Copyright 2020 Canonical Ltd
#
# 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
from ovsdb_subordinate import provides
import charms_openstack.test_utils as test_utils
_hook_args = {}
class TestOVSDBSubordinateProvides(test_utils.PatchHelper):
def setUp(self):
super().setUp()
self.target = provides.OVSDBSubordinateProvides('some-relation', [])
self._patches = {}
self._patches_start = {}
def tearDown(self):
self.target = None
for k, v in self._patches.items():
v.stop()
setattr(self, k, None)
self._patches = None
self._patches_start = None
def patch_target(self, attr, return_value=None):
mocked = mock.patch.object(self.target, attr)
self._patches[attr] = mocked
started = mocked.start()
started.return_value = return_value
self._patches_start[attr] = started
setattr(self, attr, started)
def patch_topublish(self):
self.patch_target('_relations')
relation = mock.MagicMock()
to_publish = mock.PropertyMock()
type(relation).to_publish = to_publish
self._relations.__iter__.return_value = [relation]
return relation.to_publish
def test_chassis_name(self):
self.patch_target('_all_joined_units')
self._all_joined_units.received.get.return_value = 'fakename'
self.assertEquals(self.target.chassis_name, 'fakename')
self._all_joined_units.received.get.assert_called_once_with(
'chassis-name', '')
def test_ovn_configured(self):
self.patch_target('_all_joined_units')
self._all_joined_units.received.get.return_value = True
self.assertEquals(self.target.ovn_configured, True)
self._all_joined_units.received.get.assert_called_once_with(
'ovn-configured', False)
def test__add_interface_request(self):
to_publish = self.patch_topublish()
to_publish.get.return_value = {}
self.target._add_interface_request('br-ex', 'eth0', {'data': ''})
to_publish.__setitem__.assert_called_once_with(
'create-interfaces', {'br-ex': {'eth0': {'data': ''}}})
def test__interfrace_requests(self):
self.patch_target('_relations')
relation = mock.MagicMock()
self._relations.__iter__.return_value = [relation]
relation.to_publish_raw.get.return_value = 'aValue'
self.assertEquals(self.target._interface_requests(), 'aValue')
relation.to_publish_raw.get.assert_called_once_with(
'create-interfaces')
def test_create_interface(self):
self.patch_target('_add_interface_request')
self.patch_object(provides.reactive, 'clear_flag')
ifdata = {
'type': 'internal',
'external-ids': {
'iface-id': 'fakeuuid',
'iface-status': 'active',
'attached-mac': 'fakemac',
},
}
self.target.create_interface('br-ex', 'eth0', 'fakemac', 'fakeuuid')
self._add_interface_request.assert_called_once_with(
'br-ex', 'eth0', ifdata)
self.clear_flag.assert_called_once_with(
'some-relation.interfaces.created')
self._add_interface_request.reset_mock()
ifdata['type'] = 'someothervalue'
ifdata['external-ids']['iface-status'] = 'inactive'
self.target.create_interface('br-ex', 'eth0', 'fakemac', 'fakeuuid',
iftype='someothervalue',
ifstatus='inactive')
self._add_interface_request.assert_called_once_with(
'br-ex', 'eth0', ifdata)
def test_joined(self):
self.patch_object(provides.reactive, 'set_flag')
self.target.joined()
self.set_flag.assert_has_calls([
mock.call('some-relation.connected'),
mock.call('some-relation.available'),
])
def test_broken(self):
self.patch_object(provides.reactive, 'clear_flag')
self.target.broken()
self.clear_flag.assert_has_calls([
mock.call('some-relation.available'),
mock.call('some-relation.connected'),
])
def test_new_requests(self):
self.patch_target('_interface_requests')
self.patch_target('_all_joined_units')
self.patch_object(provides, 'hash_hexdigest')
self.hash_hexdigest.return_value = 'fakehash'
self._interface_requests.return_value = 'fakerequests'
self.patch_object(provides.reactive, 'set_flag')
self.patch_object(provides.reactive, 'clear_flag')
self.target.new_requests()
self.hash_hexdigest.assert_called_once_with('fakerequests')
self.assertFalse(self.set_flag.called)
self.assertFalse(self.clear_flag.called)
self._all_joined_units.received.__getitem__.return_value = 'fakehash'
self.target.new_requests()
self.set_flag.assert_called_once_with(
'some-relation.interfaces.created')
self.clear_flag.assert_called_once_with(
'endpoint.some-relation.changed.interfaces-created')

View File

@ -0,0 +1,144 @@
# Copyright 2020 Canonical Ltd
#
# 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
from ovsdb_subordinate import requires
import charms_openstack.test_utils as test_utils
_hook_args = {}
class TestOVSDBSubordinateProvides(test_utils.PatchHelper):
def setUp(self):
super().setUp()
self.target = requires.OVSDBSubordinateRequires('some-relation', [])
self._patches = {}
self._patches_start = {}
def tearDown(self):
self.target = None
for k, v in self._patches.items():
v.stop()
setattr(self, k, None)
self._patches = None
self._patches_start = None
def patch_target(self, attr, return_value=None):
mocked = mock.patch.object(self.target, attr)
self._patches[attr] = mocked
started = mocked.start()
started.return_value = return_value
self._patches_start[attr] = started
setattr(self, attr, started)
def patch_topublish(self):
self.patch_target('_relations')
relation = mock.MagicMock()
to_publish = mock.PropertyMock()
type(relation).to_publish = to_publish
self._relations.__iter__.return_value = [relation]
return relation.to_publish
def test__get_ovs_value(self):
self.patch_object(requires.subprocess, 'run')
cp = mock.MagicMock()
cp.stdout = '"hostname-42"\n'
self.run.return_value = cp
self.assertEquals(
self.target._get_ovs_value('tbl', 'col'),
'hostname-42')
self.run.assert_called_once_with(
('ovs-vsctl', 'get', 'tbl', '.', 'col'),
stdout=mock.ANY, check=True, universal_newlines=True)
self.run.reset_mock()
self.target._get_ovs_value('tbl', 'col', rec='rec')
self.run.assert_called_once_with(
('ovs-vsctl', 'get', 'tbl', 'rec', 'col'),
stdout=mock.ANY, check=True, universal_newlines=True)
def test_publish_chassis_name(self):
self.patch_target('_get_ovs_value')
to_publish = self.patch_topublish()
self._get_ovs_value.return_value = 'aHostname'
self.target.publish_chassis_name()
to_publish.__setitem__.assert_called_once_with(
'chassis-name', 'aHostname')
def test_publish_ovn_configured(self):
self.patch_object(requires, 'subprocess')
self.subprocess.CalledProcessError = Exception
self.patch_target('_get_ovs_value')
to_publish = self.patch_topublish()
self._get_ovs_value.side_effect = Exception
self.target.publish_ovn_configured()
to_publish.__setitem__.assert_called_once_with('ovn-configured', False)
self._get_ovs_value.assert_called_once_with(
'Open_vSwitch', 'external_ids:ovn-remote')
to_publish.__setitem__.reset_mock()
self._get_ovs_value.side_effect = None
self.target.publish_ovn_configured()
to_publish.__setitem__.assert_called_once_with('ovn-configured', True)
def test_interface_requests(self):
self.patch_target('_all_joined_units')
self._all_joined_units.received.get.return_value = 'fakereq'
self.assertEquals(
self.target.interface_requests, 'fakereq')
def test_interface_requests_handled(self):
self.patch_object(requires, 'hash_hexdigest')
self.hash_hexdigest.return_value = 'fakehash'
self.patch_target('_all_joined_units')
self._all_joined_units.received_raw.__getitem__.return_value = 'ifreq'
to_publish = self.patch_topublish()
self.patch_object(requires.reactive, 'clear_flag')
self.target.interface_requests_handled()
self.hash_hexdigest.assert_called_once_with('ifreq')
to_publish.__setitem__.assert_called_once_with(
'interfaces-created', 'fakehash')
self.clear_flag.assert_called_once_with(
'some-relation.interfaces.new_requests')
def test_joined(self):
self.patch_target('publish_chassis_name')
self.patch_target('publish_ovn_configured')
self.patch_object(requires.reactive, 'set_flag')
self.target.joined()
self.publish_chassis_name.assert_called_once_with()
self.publish_ovn_configured.assert_called_once_with()
self.set_flag.assert_has_calls([
mock.call('some-relation.connected'),
mock.call('some-relation.available'),
])
def test_broken(self):
self.patch_object(requires.reactive, 'clear_flag')
self.target.broken()
self.clear_flag.assert_has_calls([
mock.call('some-relation.available'),
mock.call('some-relation.connected'),
])
def test_new_requests(self):
self.patch_object(requires.reactive, 'set_flag')
self.patch_object(requires.reactive, 'clear_flag')
self.target.new_requests()
self.set_flag.assert_called_once_with(
'some-relation.interfaces.new_requests')
self.clear_flag.assert_called_once_with(
'endpoint.some-relation.changed.create-interfaces')