diff --git a/actions.yaml b/actions.yaml index da514587..42af2953 100644 --- a/actions.yaml +++ b/actions.yaml @@ -49,5 +49,11 @@ run-deferred-hooks: . NOTE: Service will be restarted as needed irrespective of enable-auto-restarts show-deferred-events: - descrpition: | + description: | Show the outstanding restarts +show-routers: + description: Shows a list of routers hosted on the neutron-gateway unit. +show-dhcp-networks: + description: Shows a list of DHCP networks hosted on the neutron-gateway unit. +show-loadbalancers: + description: Shows a list of LBaasV2 load-balancers hosted on the neutron-gateway unit. diff --git a/actions/actions.py b/actions/actions.py index 78d81f99..6ad5d015 100755 --- a/actions/actions.py +++ b/actions/actions.py @@ -1,8 +1,14 @@ #!/usr/bin/env python3 import os +import socket import sys +from keystoneauth1 import identity +from keystoneauth1 import session +from neutronclient.v2_0 import client +import yaml + _path = os.path.dirname(os.path.realpath(__file__)) _hooks_dir = os.path.abspath(os.path.join(_path, "..", "hooks")) @@ -14,12 +20,18 @@ def _add_path(path): _add_path(_hooks_dir) +from charmhelpers.core.hookenv import ( + relation_get, + relation_ids, + related_units, +) import charmhelpers.contrib.openstack.utils as os_utils from charmhelpers.core.hookenv import ( DEBUG, action_get, action_fail, + function_set, log, ) from neutron_utils import ( @@ -87,11 +99,207 @@ def show_deferred_events(args): os_utils.show_deferred_events_action_helper() +def get_neutron(): + """Return authenticated neutron client. + + :return: neutron client + :rtype: neutronclient.v2_0.client.Client + :raises RuntimeError: Exception is raised if authentication of neutron + client fails. This can be either because this unit does not have a + "neutron-plugin-api" relation which should contain credentials or + the credentials are wrong or keystone service is not available. + """ + rel_name = "neutron-plugin-api" + neutron = None + + for rid in relation_ids(rel_name): + for unit in related_units(rid): + rdata = relation_get(rid=rid, unit=unit) + if rdata is None: + continue + + protocol = rdata.get("auth_protocol") + host = rdata.get("auth_host") + port = rdata.get("auth_port") + username = rdata.get("service_username") + password = rdata.get("service_password") + project = rdata.get("service_tenant") + project_domain = rdata.get("service_domain", "default") + user_domain_name = rdata.get("service_domain", "default") + if protocol and host and port \ + and username and password and project: + auth_url = "{}://{}:{}/".format(protocol, + host, + port) + auth = identity.Password(auth_url=auth_url, + username=username, + password=password, + project_name=project, + project_domain_name=project_domain, + user_domain_name=user_domain_name) + sess = session.Session(auth=auth) + neutron = client.Client(session=sess) + break + + if neutron is not None: + break + + if neutron is None: + raise RuntimeError("Relation '{}' is either missing or does not " + "contain neutron credentials".format(rel_name)) + return neutron + + +def get_network_agents_on_host(hostname, neutron, agent_type=None): + """Fetch list of neutron agents on specified host. + + :param hostname: name of host on which the agents are running + :param neutron: authenticated neutron client + :param agent_type: If provided, filter only agents of selected type + :return: List of agents matching given criteria + :rtype: list[dict] + """ + params = {'host': hostname} + if agent_type is not None: + params['agent_type'] = agent_type + + agent_list = neutron.list_agents(**params)["agents"] + + return agent_list + + +def get_resource_list_on_agents(agent_list, + list_resource_function, + resource_name): + """Fetch resources hosted on neutron agents combined into single list. + + :param agent_list: List of agents on which to search for resources + :type agent_list: list[dict] + :param list_resource_function: function that takes agent ID and returns + resources present on that agent. + :type list_resource_function: Callable + :param resource_name: filter only resources with given name (e.g.: + "networks", "routers", ...) + :type resource_name: str + :return: List of neutron resources. + :rtype: list[dict] + """ + agent_id_list = [agent["id"] for agent in agent_list] + resource_list = [] + for agent_id in agent_id_list: + resource_list.extend(list_resource_function(agent_id)[resource_name]) + return resource_list + + +def clean_resource_list(resource_list, allowed_keys=None): + """Strip resources of all fields except those in 'allowed_keys.' + + resource_list is a list where each resources is represented as a dict with + many attributes. This function strips all but those defined in + 'allowed_keys' + :param resource_list: List of resources to strip + :param allowed_keys: keys allowed in the resulting dictionary for each + resource + :return: List of stripped resources + :rtype: list[dict] + """ + if allowed_keys is None: + allowed_keys = ["id", "status"] + + clean_data_list = [] + for resource in resource_list: + clean_data = {key: value for key, value in resource.items() + if key in allowed_keys} + clean_data_list.append(clean_data) + return clean_data_list + + +def format_status_output(data, key_attribute="id"): + """Reformat data from the neutron api into human-readable yaml. + + Input data are expected to be list of dictionaries (as returned by neutron + api). This list is transformed into dict where "id" of a resource is key + and rest of the resource attributes are value (in form of dictionary). The + resulting structure is dumped as yaml text. + + :param data: List of dictionaires representing neutron resources + :param key_attribute: attribute that will be used as a key in the + result. (default=id) + :return: yaml string representing input data. + """ + output = {} + for entry in data: + header = entry.pop(key_attribute) + output[header] = {} + for attribute, value in entry.items(): + output[header][attribute] = value + + return yaml.dump(output) + + +def get_routers(args): + """Implementation of 'show-routers' action.""" + neutron = get_neutron() + agent_list = get_network_agents_on_host(socket.gethostname(), neutron, + "L3 agent") + router_list = get_resource_list_on_agents(agent_list, + neutron.list_routers_on_l3_agent, + "routers") + + clean_data = clean_resource_list(router_list, + allowed_keys=["id", + "status", + "ha", + "name"]) + + function_set({"router-list": format_status_output(clean_data)}) + + +def get_dhcp_networks(args): + """Implementation of 'show-dhcp-networks' action.""" + neutron = get_neutron() + agent_list = get_network_agents_on_host(socket.gethostname(), neutron, + "DHCP agent") + list_func = neutron.list_networks_on_dhcp_agent + dhcp_network_list = get_resource_list_on_agents(agent_list, + list_func, + "networks") + + clean_data = clean_resource_list(dhcp_network_list, + allowed_keys=["id", "status", "name"]) + + function_set({"dhcp-networks": format_status_output(clean_data)}) + + +def get_lbaasv2_lb(args): + """Implementation of 'show-loadbalancers' action.""" + neutron = get_neutron() + agent_list = get_network_agents_on_host(socket.gethostname(), + neutron, + "Loadbalancerv2 agent") + list_func = neutron.list_loadbalancers_on_lbaas_agent + lb_list = get_resource_list_on_agents(agent_list, + list_func, + "loadbalancers") + + clean_data = clean_resource_list(lb_list, allowed_keys=["id", + "status", + "name"]) + + function_set({"load-balancers": format_status_output(clean_data)}) + + # A dictionary of all the defined actions to callables (which take # parsed arguments). -ACTIONS = {"pause": pause, "resume": resume, "restart-services": restart, +ACTIONS = {"pause": pause, + "resume": resume, + "restart-services": restart, "show-deferred-events": show_deferred_events, - "run-deferred-hooks": run_deferred_hooks} + "run-deferred-hooks": run_deferred_hooks, + "show-routers": get_routers, + "show-dhcp-networks": get_dhcp_networks, + "show-loadbalancers": get_lbaasv2_lb, + } def main(args): diff --git a/actions/show-dhcp-networks b/actions/show-dhcp-networks new file mode 120000 index 00000000..405a394e --- /dev/null +++ b/actions/show-dhcp-networks @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/actions/show-loadbalancers b/actions/show-loadbalancers new file mode 120000 index 00000000..405a394e --- /dev/null +++ b/actions/show-loadbalancers @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/actions/show-routers b/actions/show-routers new file mode 120000 index 00000000..405a394e --- /dev/null +++ b/actions/show-routers @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/hooks/neutron_hooks.py b/hooks/neutron_hooks.py index 5a83c2ca..7e22cfe1 100755 --- a/hooks/neutron_hooks.py +++ b/hooks/neutron_hooks.py @@ -115,6 +115,7 @@ def install(): status_set('maintenance', 'Installing apt packages') apt_update(fatal=True) apt_install('python-six', fatal=True) # Force upgrade + apt_install('python3-neutronclient') if valid_plugin(): apt_install(filter_installed_packages(get_early_packages()), fatal=True) diff --git a/tests/tests.yaml b/tests/tests.yaml index efc805df..de8546e0 100644 --- a/tests/tests.yaml +++ b/tests/tests.yaml @@ -68,6 +68,7 @@ configure_options: tests: - zaza.openstack.charm_tests.neutron.tests.NeutronGatewayDeferredRestartTest - zaza.openstack.charm_tests.neutron.tests.NeutronGatewayTest + - zaza.openstack.charm_tests.neutron.tests.NeutronGatewayShowActionsTest - zaza.openstack.charm_tests.neutron.tests.SecurityTest - zaza.openstack.charm_tests.neutron.tests.NeutronNetworkingTest - zaza.openstack.charm_tests.neutron.tests.NeutronOvsVsctlTest @@ -75,6 +76,7 @@ tests: - migrate-ovn: - zaza.openstack.charm_tests.neutron.tests.NeutronGatewayDeferredRestartTest - zaza.openstack.charm_tests.neutron.tests.NeutronGatewayTest + - zaza.openstack.charm_tests.neutron.tests.NeutronGatewayShowActionsTest - zaza.openstack.charm_tests.neutron.tests.SecurityTest - zaza.openstack.charm_tests.neutron.tests.NeutronOvsVsctlTest - zaza.openstack.charm_tests.neutron.tests.NeutronBridgePortMappingTest diff --git a/unit_tests/test_actions.py b/unit_tests/test_actions.py index 06c81fc4..5b2201a1 100644 --- a/unit_tests/test_actions.py +++ b/unit_tests/test_actions.py @@ -46,6 +46,72 @@ class ResumeTestCase(CharmTestCase): self.resume_unit_helper.assert_called_once_with('test-config') +class GetStatusTestCase(CharmTestCase): + + def setUp(self): + super(GetStatusTestCase, self).setUp(actions, []) + + def test_clean_resource_list(self): + data = [{"id": 1, "x": "data", "z": "data"}, + {"id": 2, "y": "data", "z": "data"}] + + clean_data = actions.clean_resource_list(data) + for resource in clean_data: + self.assertTrue("id" in resource) + self.assertTrue("x" not in resource) + self.assertTrue("y" not in resource) + self.assertTrue("z" not in resource) + + # test allowed keys + clean_data = actions.clean_resource_list(data, + allowed_keys=["id", "z"]) + for resource in clean_data: + self.assertTrue("id" in resource) + self.assertTrue("x" not in resource) + self.assertTrue("y" not in resource) + self.assertTrue("z" in resource) + + def test_get_resource_list_on_agents(self): + list_function = MagicMock() + agent_list = [{"id": 1}, {"id": 2}] + list_results = [{"results": ["a", "b"]}, {"results": ["c"], "x": [""]}] + + expected_resource_length = 0 + for r in list_results: + expected_resource_length += len(r.get("results", [])) + + list_function.side_effect = list_results + resource_list = actions.get_resource_list_on_agents(agent_list, + list_function, + "results") + assert list_function.call_count > 0 + self.assertEqual(len(resource_list), expected_resource_length) + + @mock.patch("actions.function_set") + @mock.patch("actions.get_resource_list_on_agents") + @mock.patch("actions.get_neutron") + def test_action_get_status(self, mock_get_neutron, + mock_get_resource_list_on_agents, + mock_function_set): + data = [{"id": 1, "x": "data", "z": "data"}, + {"id": 2, "y": "data", "z": "data"}] + mock_get_resource_list_on_agents.return_value = data + + clean_data = actions.clean_resource_list(data) + yaml_clean_data = actions.format_status_output(clean_data) + + actions.get_routers(None) + mock_function_set.assert_called_with({"router-list": yaml_clean_data}) + + actions.get_dhcp_networks(None) + mock_function_set.assert_called_with({"dhcp-networks": + yaml_clean_data}) + + actions.get_lbaasv2_lb(None) + mock_function_set.assert_called_with({"load-balancers": + yaml_clean_data}) + + class MainTestCase(CharmTestCase): def setUp(self):