diff --git a/esiclient/tests/unit/test_utils.py b/esiclient/tests/unit/test_utils.py index 5c349ec..d6cd4f3 100644 --- a/esiclient/tests/unit/test_utils.py +++ b/esiclient/tests/unit/test_utils.py @@ -554,3 +554,45 @@ def test_create_trunk(self): 'segmentation_id': 333} ] ) + + +class TestDeleteTrunk(TestCase): + + def setUp(self): + super(TestDeleteTrunk, self).setUp() + + self.trunk = test_utils.create_mock_object({ + "id": "trunk_uuid", + "name": "trunk", + "port_id": "port_uuid_1", + "sub_ports": [ + { + "port_id": 'port_uuid_2', + "segmentation_id": '222', + "segmentation_type": 'vlan' + }, + { + "port_id": 'port_uuid_3', + "segmentation_id": '333', + "segmentation_type": 'vlan' + } + ] + }) + + self.neutron_client = mock.Mock() + self.neutron_client.delete_trunk.\ + return_value = None + self.neutron_client.delete_port.\ + return_value = None + + def test_delete_trunk(self): + utils.delete_trunk(self.neutron_client, self.trunk) + + self.neutron_client.delete_trunk.\ + assert_called_once_with("trunk_uuid") + self.neutron_client.delete_port.\ + assert_has_calls([ + mock.call("port_uuid_2"), + mock.call("port_uuid_3"), + mock.call("port_uuid_1"), + ]) diff --git a/esiclient/tests/unit/v1/orchestrator/test_cluster.py b/esiclient/tests/unit/v1/orchestrator/test_cluster.py index 9e3119a..dce8e06 100644 --- a/esiclient/tests/unit/v1/orchestrator/test_cluster.py +++ b/esiclient/tests/unit/v1/orchestrator/test_cluster.py @@ -399,3 +399,128 @@ def test_take_action_invalid_provisioning_type(self, mock_load): fields=["uuid", "name", "resource_class"], provision_state='available' ) + + +class TestUndeploy(base.TestCommand): + + def setUp(self): + super(TestUndeploy, self).setUp() + self.cmd = cluster.Undeploy(self.app, None) + + self.port1 = utils.create_mock_object({ + "id": "port_uuid_1", + "name": "esi-node2-network2", + "network_id": "network_uuid_1", + }) + self.port2 = utils.create_mock_object({ + "id": "port_uuid_2", + "name": "esi-node3-network2", + "network_id": "network_uuid_2", + }) + self.network1 = utils.create_mock_object({ + "id": "network_uuid_1", + "name": "network1", + }) + self.network2 = utils.create_mock_object({ + "id": "network_uuid_2", + "name": "network2", + }) + self.trunk = utils.create_mock_object({ + "id": "trunk_uuid_1", + "name": "trunk", + }) + + def mock_find_network(name): + if name == "network1": + return self.network1 + if name == "network2": + return self.network2 + return None + self.app.client_manager.network.find_network.\ + side_effect = mock_find_network + + def mock_find_port(name): + if name == "esi-node2-network2": + return self.port1 + if name == "esi-node3-network2": + return self.port2 + return None + self.app.client_manager.network.find_port.\ + side_effect = mock_find_port + self.app.client_manager.network.delete_port.\ + return_value = None + + self.app.client_manager.network.find_trunk.return_value = \ + self.trunk + + self.app.client_manager.baremetal.node.set_provision_state.\ + return_value = None + + @mock.patch( + 'esiclient.utils.delete_trunk', + autospec=True) + @mock.patch('time.sleep', autospec=True) + @mock.patch('json.load', autospec=True) + def test_take_action(self, mock_load, mock_sleep, mock_dt): + mock_load.return_value = { + "node_configs": [ + { + "nodes": { + "node_uuids": ["node1"] + }, + "network": { + "network_uuid": "network1", + "tagged_network_uuids": ["network2"], + "fip_network_uuid": "external_network" + }, + "provisioning": { + "provisioning_type": "image", + "image_uuid": "image_uuid", + "ssh_key": "/path/to/ssh/key" + } + }, + { + "nodes": { + "node_uuids": ["node2", "node3"] + }, + "network": { + "network_uuid": "network2" + }, + "provisioning": { + "provisioning_type": "image_url", + "url": "https://image.url" + } + } + ] + } + + arglist = ['config.json'] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + with patch("builtins.open"): + self.cmd.take_action(parsed_args) + + self.app.client_manager.network.find_network.assert_has_calls([ + call('network1'), + call('network2') + ]) + self.app.client_manager.baremetal.node.set_provision_state.\ + assert_has_calls([ + call('node1', 'deleted'), + call('node2', 'deleted'), + call('node3', 'deleted') + ]) + self.app.client_manager.network.find_trunk.\ + assert_called_once_with('esi-node1-trunk') + mock_dt.assert_called_once_with( + self.app.client_manager.network, self.trunk) + self.app.client_manager.network.find_port.assert_has_calls([ + call('esi-node2-network2'), + call('esi-node3-network2') + ]) + self.app.client_manager.network.delete_port.assert_has_calls([ + call('port_uuid_1'), + call('port_uuid_2') + ]) diff --git a/esiclient/tests/unit/v1/orchestrator/test_openshift.py b/esiclient/tests/unit/v1/orchestrator/test_openshift.py index 60157bb..2d822cf 100644 --- a/esiclient/tests/unit/v1/orchestrator/test_openshift.py +++ b/esiclient/tests/unit/v1/orchestrator/test_openshift.py @@ -526,3 +526,161 @@ def test_take_action_missing_pull_secret(self, mock_load): openshift.OrchestrationException, 'Please export PULL_SECRET', self.cmd.take_action, parsed_args) + + +class TestUndeploy(base.TestCommand): + + def setUp(self): + super(TestUndeploy, self).setUp() + self.cmd = openshift.Undeploy(self.app, None) + + self.provisioning_port1 = utils.create_mock_object({ + "id": "provisioning_port_uuid_1", + "network_id": "network_uuid_2", + }) + self.provisioning_port2 = utils.create_mock_object({ + "id": "provisioning_port_uuid_2", + "network_id": "network_uuid_2", + }) + self.provisioning_port3 = utils.create_mock_object({ + "id": "provisioning_port_uuid_3", + "network_id": "network_uuid_2", + }) + self.private_port1 = utils.create_mock_object({ + "id": "private_port_uuid_1", + "network_id": "network_uuid_1", + }) + self.private_port2 = utils.create_mock_object({ + "id": "private_port_uuid_2", + "network_id": "network_uuid_1", + }) + self.private_port3 = utils.create_mock_object({ + "id": "private_port_uuid_3", + "network_id": "network_uuid_1", + }) + + def mock_find_port(uuid): + if uuid == "esi-node1-provisioning_network": + return self.provisioning_port1 + if uuid == "esi-node2-provisioning_network": + return self.provisioning_port2 + if uuid == "esi-node3-provisioning_network": + return self.provisioning_port3 + if uuid == "esi-node1-private_network": + return self.private_port1 + if uuid == "esi-node2-private_network": + return self.private_port2 + if uuid == "esi-node3-private_network": + return self.private_port3 + return None + self.app.client_manager.network.find_port.\ + side_effect = mock_find_port + + self.api_port = utils.create_mock_object({ + "id": "api_port_uuid_1", + "network_id": "network_uuid_1", + }) + self.apps_port = utils.create_mock_object({ + "id": "apps_port_uuid_1", + "network_id": "network_uuid_1", + }) + + def mock_ports(fixed_ips=None): + if fixed_ips == "ip_address=1.1.1.1": + return [self.api_port] + if fixed_ips == "ip_address=2.2.2.2": + return [self.apps_port] + return [] + self.app.client_manager.network.ports.\ + side_effect = mock_ports + self.app.client_manager.network.delete_port.\ + return_value = None + + self.api_fip = utils.create_mock_object({ + "id": "fip_uuid_1", + "floating_ip_address": "3.3.3.3" + }) + self.apps_fip = utils.create_mock_object({ + "id": "fip_uuid_2", + "floating_ip_address": "4.4.4.4" + }) + + def mock_ips(fixed_ip_address=None): + if fixed_ip_address == "1.1.1.1": + return [self.api_fip] + if fixed_ip_address == "2.2.2.2": + return [self.apps_fip] + return [] + self.app.client_manager.network.ips.\ + side_effect = mock_ips + self.app.client_manager.network.delete_ip.\ + return_value = None + + self.app.client_manager.baremetal.node.set_provision_state.\ + return_value = None + + @mock.patch('time.sleep', autospec=True) + @mock.patch('json.loads', autospec=True) + @mock.patch('json.load', autospec=True) + @mock.patch.dict(os.environ, {"PULL_SECRET": "pull_secret_file", + "API_TOKEN": "api-token"}) + def test_take_action(self, mock_load, mock_loads, mock_sleep): + mock_load.return_value = { + "cluster_name": "test_cluster", + "api_vip": "1.1.1.1", + "ingress_vip": "2.2.2.2", + "openshift_version": "1", + "base_dns_domain": "foo.bar", + "ssh_public_key": "ssh-public-key", + "external_network_name": "external_network", + "provisioning_network_name": "provisioning_network", + "private_network_name": "private_network", + "private_subnet_name": "private_subnet", + "nodes": ["node1", "node2", "node3"] + } + mock_loads.return_value = 'pull_secret_value' + + arglist = ['config.json'] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + with patch("builtins.open"): + self.cmd.take_action(parsed_args) + + self.app.client_manager.network.ports.assert_has_calls([ + call(fixed_ips='ip_address=1.1.1.1'), + call(fixed_ips='ip_address=2.2.2.2') + ]) + self.app.client_manager.network.ips.assert_has_calls([ + call(fixed_ip_address='1.1.1.1'), + call(fixed_ip_address='2.2.2.2') + ]) + self.app.client_manager.network.delete_ip.assert_has_calls([ + call('fip_uuid_1'), + call('fip_uuid_2') + ]) + self.app.client_manager.network.delete_port.assert_has_calls([ + call('api_port_uuid_1'), + call('apps_port_uuid_1'), + call('provisioning_port_uuid_1'), + call('private_port_uuid_1'), + call('provisioning_port_uuid_2'), + call('private_port_uuid_2'), + call('provisioning_port_uuid_3'), + call('private_port_uuid_3') + ]) + self.app.client_manager.baremetal.node.set_provision_state.\ + assert_has_calls([ + call('node1', 'deleted'), + call('node2', 'deleted'), + call('node3', 'deleted') + ]) + self.app.client_manager.network.find_port.assert_has_calls([ + call('esi-node1-provisioning_network'), + call('esi-node1-private_network'), + call('esi-node2-provisioning_network'), + call('esi-node2-private_network'), + call('esi-node3-provisioning_network'), + call('esi-node3-private_network') + ]) diff --git a/esiclient/tests/unit/v1/test_trunk.py b/esiclient/tests/unit/v1/test_trunk.py index dec7bbf..db7565b 100644 --- a/esiclient/tests/unit/v1/test_trunk.py +++ b/esiclient/tests/unit/v1/test_trunk.py @@ -260,7 +260,9 @@ def mock_find_trunk(trunk_name): self.app.client_manager.network.delete_port.\ return_value = None - def test_take_action(self): + @mock.patch('esiclient.utils.delete_trunk', + autospec=True) + def test_take_action(self, mock_delete_trunk): arglist = ['trunk'] verifylist = [] @@ -270,16 +272,12 @@ def test_take_action(self): self.app.client_manager.network.find_trunk.\ assert_called_once_with("trunk") - self.app.client_manager.network.delete_trunk.\ - assert_called_once_with("trunk_uuid") - self.app.client_manager.network.delete_port.\ - assert_has_calls([ - mock.call("port_uuid_2"), - mock.call("port_uuid_3"), - mock.call("port_uuid_1"), - ]) + mock_delete_trunk.assert_called_once_with( + self.app.client_manager.network, self.trunk) - def test_take_action_no_trunk(self): + @mock.patch('esiclient.utils.delete_trunk', + autospec=True) + def test_take_action_no_trunk(self, mock_delete_trunk): arglist = ['trunk2'] verifylist = [] @@ -289,6 +287,7 @@ def test_take_action_no_trunk(self): exceptions.CommandError, 'ERROR: no trunk named trunk2', self.cmd.take_action, parsed_args) + mock_delete_trunk.assert_not_called class TestAddNetwork(base.TestCommand): diff --git a/esiclient/utils.py b/esiclient/utils.py index 646eccb..1950c3d 100644 --- a/esiclient/utils.py +++ b/esiclient/utils.py @@ -240,3 +240,13 @@ def create_trunk(neutron_client, trunk_name, network, tagged_networks=[]): sub_ports=sub_ports) return trunk, trunk_port + + +def delete_trunk(neutron_client, trunk): + port_ids_to_delete = [sub_port['port_id'] + for sub_port in trunk.sub_ports] + port_ids_to_delete.append(trunk.port_id) + + neutron_client.delete_trunk(trunk.id) + for port_id in port_ids_to_delete: + neutron_client.delete_port(port_id) diff --git a/esiclient/v1/orchestrator/cluster.py b/esiclient/v1/orchestrator/cluster.py index ef00ecf..1bacf7f 100644 --- a/esiclient/v1/orchestrator/cluster.py +++ b/esiclient/v1/orchestrator/cluster.py @@ -13,6 +13,7 @@ import concurrent.futures import json import logging +import time from osc_lib.command import command from osc_lib.i18n import _ @@ -222,3 +223,78 @@ def take_action(self, parsed_args): return ["Node", "Port", "Network", "Fixed IP", "Floating Network", "Floating IP"], data + + +class Undeploy(command.Command): + """Undeploy a cluster from ESI nodes""" + + log = logging.getLogger(__name__ + ".Undeploy") + + def get_parser(self, prog_name): + parser = super(Undeploy, self).get_parser(prog_name) + + parser.add_argument( + "cluster_config_file", + metavar="", + help=_("File describing the cluster configuration")) + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + cluster_config_file = parsed_args.cluster_config_file + with open(cluster_config_file) as f: + cluster_config = json.load(f) + + print("STARTING UNDEPLOY") + + ironic_client = self.app.client_manager.baremetal + neutron_client = self.app.client_manager.network + node_configs = cluster_config['node_configs'] + + uuid_node_configs = [node_config for node_config in node_configs + if 'node_uuids' in node_config['nodes']] + count = 0 + for node_config in uuid_node_configs: + count += 1 + print("* undeploying %s of %s node configs" % ( + count, len(uuid_node_configs))) + node_uuids = node_config['nodes']['node_uuids'] + network_config = node_config['network'] + network_uuid = network_config.get('network_uuid', None) + network = neutron_client.find_network(network_uuid) + for node in node_uuids: + print(" * %s" % node) + ironic_client.node.set_provision_state(node, 'deleted') + print(" * waiting for nodes to start undeploy before" + " deleting ports") + time.sleep(15) + if 'tagged_network_uuids' in network_config: + trunk_name = "esi-%s-trunk" % node + trunk = neutron_client.find_trunk(trunk_name) + if trunk: + print(" * %s" % trunk_name) + utils.delete_trunk(neutron_client, trunk) + else: + port_name = "esi-%s-%s" % (node, network.name) + port = neutron_client.find_port(port_name) + if port: + print(" * %s" % port_name) + neutron_client.delete_port(port.id) + + non_uuid_node_configs = [node_config for node_config in node_configs + if 'node_uuids' not in node_config['nodes']] + if len(non_uuid_node_configs) > 0: + print("* %s node configs were skipped, as they do not" + " specify specific nodes" % len(non_uuid_node_configs)) + print(" * these nodes and ports will have to be removed" + " manually") + print(" openstack baremetal node undeploy ") + print(" openstack port delete ") + + print("UNDEPLOY COMPLETE") + print("-----------------") + print("* node cleaning will take a while to complete") + print("* run `openstack baremetal node list` to see if" + " they are in the `available` state") diff --git a/esiclient/v1/orchestrator/openshift.py b/esiclient/v1/orchestrator/openshift.py index cb2f1c4..da9ba0b 100644 --- a/esiclient/v1/orchestrator/openshift.py +++ b/esiclient/v1/orchestrator/openshift.py @@ -371,3 +371,92 @@ def take_action(self, parsed_args): return ["Endpoint", "IP"], [ ["API", api_fip.floating_ip_address], ["apps", apps_fip.floating_ip_address]] + + +class Undeploy(command.Command): + """Undeploy an OpenShift cluster from ESI nodes""" + + log = logging.getLogger(__name__ + ".Undeploy") + REQUIRED_FIELDS = ['nodes', 'private_network_name', 'api_vip', + 'ingress_vip'] + + def get_parser(self, prog_name): + parser = super(Undeploy, self).get_parser(prog_name) + + parser.add_argument( + "cluster_config_file", + metavar="", + help=_("File describing the cluster configuration")) + + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + with open(parsed_args.cluster_config_file) as f: + cluster_config = json.load(f) + + missing_fields = list( + set(self.REQUIRED_FIELDS).difference(cluster_config.keys())) + if missing_fields: + raise OrchestrationException( + "Please specify these missing values in your config file: %s" % + missing_fields) + + nodes = cluster_config.get('nodes') + provisioning_network_name = cluster_config.get( + 'provisioning_network_name', 'provisioning') + private_network_name = cluster_config.get('private_network_name') + api_vip = cluster_config.get('api_vip') + ingress_vip = cluster_config.get('ingress_vip') + + ironic_client = self.app.client_manager.baremetal + neutron_client = self.app.client_manager.network + + print("STARTING UNDEPLOY") + + # delete apps and API floating and fixed IPs + print("* removing API and ingress ports and fips") + for ip in [api_vip, ingress_vip]: + print(" * %s" % ip) + ip_search_string = "ip_address=%s" % ip + ports = list(neutron_client.ports(fixed_ips=ip_search_string)) + if len(ports) > 0: + port = ports[0] + fips = list(neutron_client.ips(fixed_ip_address=ip)) + if len(fips) > 0: + fip = fips[0] + print(" * %s" % fip.floating_ip_address) + neutron_client.delete_ip(fip.id) + neutron_client.delete_port(port.id) + + # undeploy nodes + print("* undeploying nodes") + for node in nodes: + print(" * %s" % node) + ironic_client.node.set_provision_state(node, 'deleted') + print("* waiting for nodes to start undeploy before deleting ports") + time.sleep(15) + + # delete provisioning and private network ports + print("* deleting Neutron ports") + for node in nodes: + provisioning_port_name = utils.get_port_name( + provisioning_network_name, prefix=node) + provisioning_port = neutron_client.find_port( + provisioning_port_name) + if provisioning_port: + print(" * %s" % provisioning_port_name) + neutron_client.delete_port(provisioning_port.id) + private_port_name = utils.get_port_name( + private_network_name, prefix=node) + private_port = neutron_client.find_port(private_port_name) + if private_port: + print(" * %s" % private_port_name) + neutron_client.delete_port(private_port.id) + + print("UNDEPLOY COMPLETE") + print("-----------------") + print("* node cleaning will take a while to complete") + print("* run `openstack baremetal node list` to see if" + " they are in the `available` state") diff --git a/esiclient/v1/trunk.py b/esiclient/v1/trunk.py index da6fec3..3910641 100644 --- a/esiclient/v1/trunk.py +++ b/esiclient/v1/trunk.py @@ -249,10 +249,4 @@ def take_action(self, parsed_args): raise exceptions.CommandError( "ERROR: no trunk named {0}".format(parsed_args.name)) - port_ids_to_delete = [sub_port['port_id'] - for sub_port in trunk.sub_ports] - port_ids_to_delete.append(trunk.port_id) - - neutron_client.delete_trunk(trunk.id) - for port_id in port_ids_to_delete: - neutron_client.delete_port(port_id) + utils.delete_trunk(neutron_client, trunk) diff --git a/setup.cfg b/setup.cfg index 082f0a5..bb19bbe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,8 +36,10 @@ openstack.esiclient.v1 = esi_node_network_detach = esiclient.v1.node_network:Detach esi_node_network_list = esiclient.v1.node_network:List esi_node_volume_attach = esiclient.v1.node_volume:Attach - esi_orchestrate_cluster = esiclient.v1.orchestrator.cluster:Orchestrate - esi_orchestrate_openshift = esiclient.v1.orchestrator.openshift:Orchestrate + esi_cluster_orchestrate = esiclient.v1.orchestrator.cluster:Orchestrate + esi_cluster_undeploy = esiclient.v1.orchestrator.cluster:Undeploy + esi_openshift_orchestrate = esiclient.v1.orchestrator.openshift:Orchestrate + esi_openshift_undeploy = esiclient.v1.orchestrator.openshift:Undeploy esi_switch_vlan_list = esiclient.v1.switch:ListVLAN esi_switch_port_list = esiclient.v1.switch:ListSwitchPort esi_switch_list = esiclient.v1.switch:List