From d0d3d3edf513f95d4ae0f5c6f58b3c02acd000db Mon Sep 17 00:00:00 2001 From: Robert Gildein Date: Tue, 2 Mar 2021 11:14:37 +0100 Subject: [PATCH] Add an action to provide information about AZ The 'get-availability-zone' action will get information about an availability zone that will contain information about the CRUSH structure. Specifically 'rack' and 'row'. Closes-Bug: #1911006 Change-Id: I99ebbef5f23d6efe3c848b089c7f2b0d26ad0077 --- README.md | 1 + actions.yaml | 15 ++ actions/get-availability-zone | 1 + actions/get_availability_zone.py | 136 ++++++++++++++++++ hooks/install | 2 +- unit_tests/__init__.py | 4 + .../test_actions_get_availability_zone.py | 119 +++++++++++++++ 7 files changed, 277 insertions(+), 1 deletion(-) create mode 120000 actions/get-availability-zone create mode 100755 actions/get_availability_zone.py create mode 100644 unit_tests/test_actions_get_availability_zone.py diff --git a/README.md b/README.md index 5404838a..b1840933 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ is not deployed then see file `actions.yaml`. * `add-disk` * `blacklist-add-disk` * `blacklist-remove-disk` +* `get-availibility-zone` * `list-disks` * `osd-in` * `osd-out` diff --git a/actions.yaml b/actions.yaml index 6d6667fa..7c405bad 100644 --- a/actions.yaml +++ b/actions.yaml @@ -116,3 +116,18 @@ stop: - osds security-checklist: description: Validate the running configuration against the OpenStack security guides checklist +get-availability-zone: + description: | + Obtain information about the availability zone, which will contain information about the CRUSH + structure. Specifically 'rack' and 'row'. + params: + format: + type: string + default: text + enum: + - text + - json + description: Specify output format (text|json). + show-all: + type: boolean + description: Option to view information for all units. Default is 'false'. diff --git a/actions/get-availability-zone b/actions/get-availability-zone new file mode 120000 index 00000000..47227f6f --- /dev/null +++ b/actions/get-availability-zone @@ -0,0 +1 @@ +get_availability_zone.py \ No newline at end of file diff --git a/actions/get_availability_zone.py b/actions/get_availability_zone.py new file mode 100755 index 00000000..bcbeade6 --- /dev/null +++ b/actions/get_availability_zone.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# +# Copyright 2021 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 json +import sys + +from tabulate import tabulate + +sys.path.append("hooks") +sys.path.append("lib") + +from charms_ceph.utils import get_osd_tree +from charmhelpers.core import hookenv +from utils import get_unit_hostname + + +CRUSH_MAP_HIERARCHY = [ + "root", # 10 + "region", # 9 + "datacenter", # 8 + "room", # 7 + "pod", # 6 + "pdu", # 5 + "row", # 4 + "rack", # 3 + "chassis", # 2 + "host", # 1 + "osd", # 0 +] + + +def _get_human_readable(availability_zones): + """Get human readable table format. + + :param availability_zones: information about the availability zone + :type availability_zones: Dict[str, Dict[str, str]] + :returns: formatted data as table + :rtype: str + """ + data = availability_zones.get( + "all-units", {get_unit_hostname(): availability_zones["unit"]} + ) + data = [[unit, *crush_map.values()] for unit, crush_map in data.items()] + return tabulate( + data, tablefmt="grid", headers=["unit", *CRUSH_MAP_HIERARCHY] + ) + + +def _get_crush_map(crush_location): + """Get Crush Map hierarchy from CrushLocation. + + :param crush_location: CrushLocation from function get_osd_tree + :type crush_location: charms_ceph.utils.CrushLocation + :returns: dictionary contains the Crush Map hierarchy, where + the keys are according to the defined types of the + Ceph Map Hierarchy + :rtype: Dict[str, str] + """ + return { + crush_map_type: getattr(crush_location, crush_map_type) + for crush_map_type in CRUSH_MAP_HIERARCHY + if getattr(crush_location, crush_map_type, None) + } + + +def get_availability_zones(show_all=False): + """Get information about the availability zones. + + Returns dictionary contains the unit as the current unit and other_units + (if the action was executed with the parameter show-all) that provide + information about other units. + + :param show_all: define whether the result should contain AZ information + for all units + :type show_all: bool + :returns: {"unit": , + "all-units": {: }} + :rtype: Dict[str, Dict[str, str]] + """ + results = {"unit": {}, "all-units": {}} + osd_tree = get_osd_tree(service="osd-upgrade") + + this_unit_host = get_unit_hostname() + for crush_location in osd_tree: + crush_map = _get_crush_map(crush_location) + if this_unit_host == crush_location.name: + results["unit"] = crush_map + + results["all-units"][crush_location.name] = crush_map + + if not show_all: + results.pop("all-units") + + return results + + +def format_availability_zones(availability_zones, human_readable=True): + """Format availability zones to action output format.""" + if human_readable: + return _get_human_readable(availability_zones) + + return json.dumps(availability_zones) + + +def main(): + try: + show_all = hookenv.action_get("show-all") + human_readable = hookenv.action_get("format") == "text" + availability_zones = get_availability_zones(show_all) + if not availability_zones["unit"]: + hookenv.log( + "Availability zone information for current unit not found.", + hookenv.DEBUG + ) + + formatted_azs = format_availability_zones(availability_zones, + human_readable) + hookenv.action_set({"availability-zone": formatted_azs}) + except Exception as error: + hookenv.action_fail("Action failed: {}".format(str(error))) + + +if __name__ == "__main__": + main() diff --git a/hooks/install b/hooks/install index 8aded7f5..0064ac5f 100755 --- a/hooks/install +++ b/hooks/install @@ -2,7 +2,7 @@ # Wrapper to deal with newer Ubuntu versions that don't have py2 installed # by default. -declare -a DEPS=('apt' 'pip' 'yaml') +declare -a DEPS=('apt' 'pip' 'yaml' 'tabulate') check_and_install() { pkg="${1}-${2}" diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py index 633fa7da..b8024d8a 100644 --- a/unit_tests/__init__.py +++ b/unit_tests/__init__.py @@ -13,7 +13,11 @@ # limitations under the License. import sys +from unittest.mock import MagicMock + sys.path.append('hooks') sys.path.append('lib') sys.path.append('actions') sys.path.append('unit_tests') + +sys.modules["tabulate"] = MagicMock() diff --git a/unit_tests/test_actions_get_availability_zone.py b/unit_tests/test_actions_get_availability_zone.py new file mode 100644 index 00000000..cec7b477 --- /dev/null +++ b/unit_tests/test_actions_get_availability_zone.py @@ -0,0 +1,119 @@ +# Copyright 2021 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 json + +from actions import get_availability_zone +from lib.charms_ceph.utils import CrushLocation + +from test_utils import CharmTestCase + + +TABULATE_OUTPUT = """ ++-------------+---------+-------------+ +| unit | root | region | ++=============+=========+=============+ +| juju-ceph-0 | default | juju-ceph-0 | ++-------------+---------+-------------+ +| juju-ceph-1 | default | juju-ceph-1 | ++-------------+---------+-------------+ +| juju-ceph-2 | default | juju-ceph-2 | ++-------------+---------+-------------+ +""" + +AVAILABILITY_ZONES = { + "unit": {"root": "default", "host": "juju-ceph-0"}, + "all-units": { + "juju-ceph-0": {"root": "default", "host": "juju-ceph-0"}, + "juju-ceph-1": {"root": "default", "host": "juju-ceph-1"}, + "juju-ceph-2": {"root": "default", "host": "juju-ceph-2"} + } +} + + +class GetAvailabilityZoneActionTests(CharmTestCase): + def setUp(self): + super(GetAvailabilityZoneActionTests, self).setUp( + get_availability_zone, + ["get_osd_tree", "get_unit_hostname", "tabulate"] + ) + self.tabulate.return_value = TABULATE_OUTPUT + self.get_unit_hostname.return_value = "juju-ceph-0" + + def test_get_human_readable(self): + """Test formatting as human readable.""" + table = get_availability_zone._get_human_readable(AVAILABILITY_ZONES) + self.assertTrue(table == TABULATE_OUTPUT) + + def test_get_crush_map(self): + """Test get Crush Map hierarchy from CrushLocation.""" + crush_location = CrushLocation( + name="test", identifier="t1", host="test", rack=None, row=None, + datacenter=None, chassis=None, root="default") + crush_map = get_availability_zone._get_crush_map(crush_location) + self.assertDictEqual(crush_map, {"root": "default", "host": "test"}) + + crush_location = CrushLocation( + name="test", identifier="t1", host="test", rack="AZ", + row="customAZ", datacenter=None, chassis=None, root="default") + crush_map = get_availability_zone._get_crush_map(crush_location) + self.assertDictEqual(crush_map, {"root": "default", "row": "customAZ", + "rack": "AZ", "host": "test"}) + + def test_get_availability_zones(self): + """Test function to get information about availability zones.""" + self.get_unit_hostname.return_value = "test_1" + self.get_osd_tree.return_value = [ + CrushLocation(name="test_1", identifier="t1", host="test_1", + rack="AZ1", row="AZ", datacenter=None, + chassis=None, root="default"), + CrushLocation(name="test_2", identifier="t2", host="test_2", + rack="AZ1", row="AZ", datacenter=None, + chassis=None, root="default"), + CrushLocation(name="test_3", identifier="t3", host="test_3", + rack="AZ2", row="AZ", datacenter=None, + chassis=None, root="default"), + CrushLocation(name="test_4", identifier="t4", host="test_4", + rack="AZ2", row="AZ", datacenter=None, + chassis=None, root="default"), + ] + results = get_availability_zone.get_availability_zones() + + self.assertDictEqual(results, { + "unit": dict(root="default", row="AZ", rack="AZ1", host="test_1")}) + + results = get_availability_zone.get_availability_zones(show_all=True) + self.assertDictEqual(results, { + "unit": dict(root="default", row="AZ", rack="AZ1", host="test_1"), + "all-units": { + "test_1": dict(root="default", row="AZ", rack="AZ1", + host="test_1"), + "test_2": dict(root="default", row="AZ", rack="AZ1", + host="test_2"), + "test_3": dict(root="default", row="AZ", rack="AZ2", + host="test_3"), + "test_4": dict(root="default", row="AZ", rack="AZ2", + host="test_4"), + }}) + + def test_format_availability_zones(self): + """Test function to formatted availability zones.""" + # human readable format + results_table = get_availability_zone.format_availability_zones( + AVAILABILITY_ZONES, True) + self.assertEqual(results_table, TABULATE_OUTPUT) + + # json format + results_json = get_availability_zone.format_availability_zones( + AVAILABILITY_ZONES, False) + self.assertDictEqual(json.loads(results_json), AVAILABILITY_ZONES)