Support cleanup of tenant resources with a single API call
The addition of the 'neutron purge' command allows cloud admins to conveniently delete multiple neutron resources associated with a given tenant. The command will delete all supported resources provided that they can be deleted (not in use, etc) and feedback the amount of each resource deleted to the user. A completion percentage is also given to keep the user informed of progress. Currently supports deletion of: Networks Subnets (implicitly) Routers Ports (including router interfaces) Floating IPs Security Groups This feature can be easily extended to support more resource types in the future. DocImpact: Update API documentation to describe neutron-purge usage Change-Id: I5a366d3537191045eb53f9cccd8cd0f7ce54a63b Closes-Bug: 1511574 Partially-Implements: blueprint tenant-delete
This commit is contained in:
147
neutronclient/neutron/v2_0/purge.py
Normal file
147
neutronclient/neutron/v2_0/purge.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Copyright 2016 Cisco Systems
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# 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 sys
|
||||||
|
|
||||||
|
from neutronclient._i18n import _
|
||||||
|
from neutronclient.neutron import v2_0 as neutronV20
|
||||||
|
|
||||||
|
|
||||||
|
class Purge(neutronV20.NeutronCommand):
|
||||||
|
|
||||||
|
def _pluralize(self, string):
|
||||||
|
return string + 's'
|
||||||
|
|
||||||
|
def _get_resources(self, neutron_client, resource_types, tenant_id):
|
||||||
|
resources = []
|
||||||
|
for resource_type in resource_types:
|
||||||
|
resources.append([])
|
||||||
|
resource_type_plural = self._pluralize(resource_type)
|
||||||
|
opts = {'fields': ['id', 'tenant_id']}
|
||||||
|
if resource_type_plural == 'ports':
|
||||||
|
opts['fields'].append('device_id')
|
||||||
|
opts['fields'].append('device_owner')
|
||||||
|
function = getattr(neutron_client, 'list_%s' %
|
||||||
|
resource_type_plural)
|
||||||
|
if callable(function):
|
||||||
|
returned_resources = function(**opts).get(resource_type_plural,
|
||||||
|
[])
|
||||||
|
for resource in returned_resources:
|
||||||
|
if resource['tenant_id'] == tenant_id:
|
||||||
|
index = resource_types.index(resource_type)
|
||||||
|
resources[index].append(resource)
|
||||||
|
self.total_resources += 1
|
||||||
|
return resources
|
||||||
|
|
||||||
|
def _delete_resource(self, neutron_client, resource_type, resource):
|
||||||
|
resource_id = resource['id']
|
||||||
|
if resource_type == 'port':
|
||||||
|
if resource.get('device_owner', '') == 'network:router_interface':
|
||||||
|
body = {'port_id': resource_id}
|
||||||
|
neutron_client.remove_interface_router(resource['device_id'],
|
||||||
|
body)
|
||||||
|
return
|
||||||
|
function = getattr(neutron_client, 'delete_%s' % resource_type)
|
||||||
|
if callable(function):
|
||||||
|
function(resource_id)
|
||||||
|
|
||||||
|
def _purge_resources(self, neutron_client, resource_types,
|
||||||
|
tenant_resources):
|
||||||
|
deleted = {}
|
||||||
|
failed = {}
|
||||||
|
failures = False
|
||||||
|
for resources in tenant_resources:
|
||||||
|
index = tenant_resources.index(resources)
|
||||||
|
resource_type = resource_types[index]
|
||||||
|
failed[resource_type] = 0
|
||||||
|
deleted[resource_type] = 0
|
||||||
|
for resource in resources:
|
||||||
|
try:
|
||||||
|
self._delete_resource(neutron_client, resource_type,
|
||||||
|
resource)
|
||||||
|
deleted[resource_type] += 1
|
||||||
|
self.deleted_resources += 1
|
||||||
|
except Exception:
|
||||||
|
failures = True
|
||||||
|
failed[resource_type] += 1
|
||||||
|
self.total_resources -= 1
|
||||||
|
percent_complete = 100
|
||||||
|
if self.total_resources > 0:
|
||||||
|
percent_complete = (self.deleted_resources /
|
||||||
|
float(self.total_resources)) * 100
|
||||||
|
sys.stdout.write("\rPurging resources: %d%% complete." %
|
||||||
|
percent_complete)
|
||||||
|
sys.stdout.flush()
|
||||||
|
return (deleted, failed, failures)
|
||||||
|
|
||||||
|
def _build_message(self, deleted, failed, failures):
|
||||||
|
msg = ''
|
||||||
|
deleted_msg = []
|
||||||
|
for resource, value in deleted.items():
|
||||||
|
if value:
|
||||||
|
if not msg:
|
||||||
|
msg = 'Deleted'
|
||||||
|
if not value == 1:
|
||||||
|
resource = self._pluralize(resource)
|
||||||
|
deleted_msg.append(" %d %s" % (value, resource))
|
||||||
|
if deleted_msg:
|
||||||
|
msg += ','.join(deleted_msg)
|
||||||
|
|
||||||
|
failed_msg = []
|
||||||
|
if failures:
|
||||||
|
if msg:
|
||||||
|
msg += '. '
|
||||||
|
msg += 'The following resources could not be deleted:'
|
||||||
|
for resource, value in failed.items():
|
||||||
|
if value:
|
||||||
|
if not value == 1:
|
||||||
|
resource = self._pluralize(resource)
|
||||||
|
failed_msg.append(" %d %s" % (value, resource))
|
||||||
|
msg += ','.join(failed_msg)
|
||||||
|
|
||||||
|
if msg:
|
||||||
|
msg += '.'
|
||||||
|
else:
|
||||||
|
msg = _('Tenant has no supported resources.')
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def get_parser(self, prog_name):
|
||||||
|
parser = super(Purge, self).get_parser(prog_name)
|
||||||
|
parser.add_argument(
|
||||||
|
'tenant', metavar='TENANT',
|
||||||
|
help=_('ID of Tenant owning the resources to be deleted.'))
|
||||||
|
return parser
|
||||||
|
|
||||||
|
def run(self, parsed_args):
|
||||||
|
neutron_client = self.get_client()
|
||||||
|
|
||||||
|
self.any_failures = False
|
||||||
|
|
||||||
|
# A list of the types of resources supported in the order in which
|
||||||
|
# they should be deleted.
|
||||||
|
resource_types = ['floatingip', 'port', 'router',
|
||||||
|
'network', 'security_group']
|
||||||
|
|
||||||
|
deleted = {}
|
||||||
|
failed = {}
|
||||||
|
self.total_resources = 0
|
||||||
|
self.deleted_resources = 0
|
||||||
|
resources = self._get_resources(neutron_client, resource_types,
|
||||||
|
parsed_args.tenant)
|
||||||
|
deleted, failed, failures = self._purge_resources(neutron_client,
|
||||||
|
resource_types,
|
||||||
|
resources)
|
||||||
|
print('\n%s' % self._build_message(deleted, failed, failures))
|
@@ -66,6 +66,7 @@ from neutronclient.neutron.v2_0 import network
|
|||||||
from neutronclient.neutron.v2_0.nsx import networkgateway
|
from neutronclient.neutron.v2_0.nsx import networkgateway
|
||||||
from neutronclient.neutron.v2_0.nsx import qos_queue
|
from neutronclient.neutron.v2_0.nsx import qos_queue
|
||||||
from neutronclient.neutron.v2_0 import port
|
from neutronclient.neutron.v2_0 import port
|
||||||
|
from neutronclient.neutron.v2_0 import purge
|
||||||
from neutronclient.neutron.v2_0.qos import bandwidth_limit_rule
|
from neutronclient.neutron.v2_0.qos import bandwidth_limit_rule
|
||||||
from neutronclient.neutron.v2_0.qos import policy as qos_policy
|
from neutronclient.neutron.v2_0.qos import policy as qos_policy
|
||||||
from neutronclient.neutron.v2_0.qos import rule as qos_rule
|
from neutronclient.neutron.v2_0.qos import rule as qos_rule
|
||||||
@@ -171,6 +172,7 @@ COMMAND_V2 = {
|
|||||||
'port-create': port.CreatePort,
|
'port-create': port.CreatePort,
|
||||||
'port-delete': port.DeletePort,
|
'port-delete': port.DeletePort,
|
||||||
'port-update': port.UpdatePort,
|
'port-update': port.UpdatePort,
|
||||||
|
'purge': purge.Purge,
|
||||||
'quota-list': quota.ListQuota,
|
'quota-list': quota.ListQuota,
|
||||||
'quota-show': quota.ShowQuota,
|
'quota-show': quota.ShowQuota,
|
||||||
'quota-delete': quota.DeleteQuota,
|
'quota-delete': quota.DeleteQuota,
|
||||||
|
172
neutronclient/tests/functional/core/test_purge.py
Normal file
172
neutronclient/tests/functional/core/test_purge.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Copyright 2016 Cisco Systems
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# 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 neutronclient.tests.functional import base
|
||||||
|
|
||||||
|
from tempest_lib import exceptions
|
||||||
|
|
||||||
|
|
||||||
|
class PurgeNeutronClientCLITest(base.ClientTestBase):
|
||||||
|
|
||||||
|
def _safe_cleanup(self, delete_command):
|
||||||
|
try:
|
||||||
|
self.neutron(delete_command)
|
||||||
|
except exceptions.CommandFailed:
|
||||||
|
# This resource was already purged successfully
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _create_subnet(self, name, tenant_id, cidr):
|
||||||
|
params = ('%(name)s --name %(name)s --tenant-id %(tenant)s '
|
||||||
|
'%(cidr)s' % {'name': name,
|
||||||
|
'tenant': tenant_id,
|
||||||
|
'cidr': cidr})
|
||||||
|
subnet = self.parser.listing(self.neutron('subnet-create',
|
||||||
|
params=params))
|
||||||
|
for row in subnet:
|
||||||
|
if row['Field'] == 'id':
|
||||||
|
return row['Value']
|
||||||
|
|
||||||
|
def _create_router(self, name, tenant_id):
|
||||||
|
params = ('%(name)s --tenant_id %(tenant)s' % {'name': name,
|
||||||
|
'tenant': tenant_id})
|
||||||
|
router = self.parser.listing(self.neutron('router-create',
|
||||||
|
params=params))
|
||||||
|
for row in router:
|
||||||
|
if row['Field'] == 'id':
|
||||||
|
return row['Value']
|
||||||
|
|
||||||
|
def _create_floatingip(self, network, tenant_id):
|
||||||
|
params = ('%(network)s --tenant-id %(tenant)s' %
|
||||||
|
{'network': network, 'tenant': tenant_id})
|
||||||
|
floatingip = self.parser.listing(self.neutron('floatingip-create',
|
||||||
|
params=params))
|
||||||
|
for row in floatingip:
|
||||||
|
if row['Field'] == 'id':
|
||||||
|
return row['Value']
|
||||||
|
|
||||||
|
def _create_resources(self, name, tenant_id, shared_tenant_id=None):
|
||||||
|
# If no shared_tenant_id is provided, create the resources for the
|
||||||
|
# current tenant to test that they will be deleted when not in use.
|
||||||
|
if not shared_tenant_id:
|
||||||
|
shared_tenant_id = tenant_id
|
||||||
|
|
||||||
|
self.neutron('net-create',
|
||||||
|
params=('%(name)s --router:external True '
|
||||||
|
'--tenant-id %(tenant)s' % {'name': name,
|
||||||
|
'tenant': tenant_id}))
|
||||||
|
self.addCleanup(self._safe_cleanup, 'net-delete %s' % name)
|
||||||
|
|
||||||
|
self.neutron('net-create',
|
||||||
|
params=('%(name)s-shared --shared '
|
||||||
|
'--tenant-id %(tenant)s' %
|
||||||
|
{'name': name, 'tenant': shared_tenant_id}))
|
||||||
|
self.addCleanup(self._safe_cleanup,
|
||||||
|
'net-delete %s-shared' % name)
|
||||||
|
|
||||||
|
subnet = self._create_subnet(name, tenant_id, '192.168.71.0/24')
|
||||||
|
self.addCleanup(self._safe_cleanup, 'subnet-delete %s' % name)
|
||||||
|
|
||||||
|
subnet = self._create_subnet('%s-shared' % name, tenant_id,
|
||||||
|
'192.168.81.0/24')
|
||||||
|
self.addCleanup(self._safe_cleanup, 'subnet-delete %s-shared' % name)
|
||||||
|
|
||||||
|
router = self._create_router(name, tenant_id)
|
||||||
|
self.addCleanup(self._safe_cleanup, 'router-delete %s' % name)
|
||||||
|
|
||||||
|
self.neutron('router-interface-add',
|
||||||
|
params=('%(router)s %(subnet)s '
|
||||||
|
'--tenant-id %(tenant)s' % {'router': router,
|
||||||
|
'subnet': subnet,
|
||||||
|
'tenant': tenant_id}))
|
||||||
|
|
||||||
|
self.neutron('port-create',
|
||||||
|
params=('%(name)s --name %(name)s '
|
||||||
|
'--tenant-id %(tenant)s' % {'name': name,
|
||||||
|
'tenant': tenant_id}))
|
||||||
|
self.addCleanup(self._safe_cleanup, 'port-delete %s' % name)
|
||||||
|
|
||||||
|
self.neutron('port-create',
|
||||||
|
params=('%(name)s-shared --name %(name)s-shared '
|
||||||
|
'--tenant-id %(tenant)s' % {'name': name,
|
||||||
|
'tenant': tenant_id}))
|
||||||
|
self.addCleanup(self._safe_cleanup, 'port-delete %s-shared' % name)
|
||||||
|
|
||||||
|
self.neutron('security-group-create',
|
||||||
|
params=('%(name)s --tenant-id %(tenant)s' %
|
||||||
|
{'name': name, 'tenant': tenant_id}))
|
||||||
|
self.addCleanup(self._safe_cleanup, 'security-group-delete %s' % name)
|
||||||
|
|
||||||
|
floatingip = self._create_floatingip(name, tenant_id)
|
||||||
|
self.addCleanup(self._safe_cleanup, ('floatingip-delete '
|
||||||
|
'%s' % floatingip))
|
||||||
|
return floatingip
|
||||||
|
|
||||||
|
def _verify_deletion(self, resources, resource_type):
|
||||||
|
purged = True
|
||||||
|
no_purge_purged = True
|
||||||
|
for row in resources:
|
||||||
|
if resource_type == 'port' and row.get('id', None):
|
||||||
|
port = self.parser.listing(self.neutron('port-show',
|
||||||
|
params=row['id']))
|
||||||
|
port_dict = {}
|
||||||
|
for row in port:
|
||||||
|
port_dict[row['Field']] = row['Value']
|
||||||
|
if port_dict['device_owner'] == 'network:router_interface':
|
||||||
|
if port_dict['tenant_id'] == 'purge-tenant':
|
||||||
|
purged = False
|
||||||
|
elif port_dict['tenant_id'] == 'no-purge-tenant':
|
||||||
|
no_purge_purged = False
|
||||||
|
if not purged or not no_purge_purged:
|
||||||
|
self.addCleanup(self.neutron,
|
||||||
|
('router-interface-delete %(router)s '
|
||||||
|
'port=%(port)s' %
|
||||||
|
{'router': port_dict['device_id'],
|
||||||
|
'port': port_dict['id']}))
|
||||||
|
if (row.get('name') == 'purge-me' or
|
||||||
|
row.get('id') == self.purge_floatingip):
|
||||||
|
purged = False
|
||||||
|
elif ('no-purge' in row.get('name', '') or
|
||||||
|
row.get('id') == self.no_purge_floatingip):
|
||||||
|
no_purge_purged = False
|
||||||
|
|
||||||
|
if not purged:
|
||||||
|
self.fail('%s not deleted by neutron purge' % resource_type)
|
||||||
|
|
||||||
|
if no_purge_purged:
|
||||||
|
self.fail('%s owned by another tenant incorrectly deleted '
|
||||||
|
'by neutron purge' % resource_type)
|
||||||
|
|
||||||
|
def test_purge(self):
|
||||||
|
self.purge_floatingip = self._create_resources('purge-me',
|
||||||
|
'purge-tenant')
|
||||||
|
self.no_purge_floatingip = self._create_resources('no-purge',
|
||||||
|
'no-purge-tenant',
|
||||||
|
'purge-tenant')
|
||||||
|
|
||||||
|
purge_output = self.neutron('purge', params='purge-tenant').strip()
|
||||||
|
if not purge_output:
|
||||||
|
self.fail('Purge command did not return feedback')
|
||||||
|
|
||||||
|
networks = self.parser.listing(self.neutron('net-list'))
|
||||||
|
subnets = self.parser.listing(self.neutron('subnet-list'))
|
||||||
|
routers = self.parser.listing(self.neutron('router-list'))
|
||||||
|
ports = self.parser.listing(self.neutron('port-list'))
|
||||||
|
floatingips = self.parser.listing(self.neutron('floatingip-list'))
|
||||||
|
|
||||||
|
self._verify_deletion(networks, 'network')
|
||||||
|
self._verify_deletion(subnets, 'subnet')
|
||||||
|
self._verify_deletion(ports, 'port')
|
||||||
|
self._verify_deletion(routers, 'router')
|
||||||
|
self._verify_deletion(floatingips, 'floatingip')
|
100
neutronclient/tests/unit/test_cli20_purge.py
Normal file
100
neutronclient/tests/unit/test_cli20_purge.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Copyright 2016 Cisco Systems
|
||||||
|
# All Rights Reserved
|
||||||
|
#
|
||||||
|
# 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 sys
|
||||||
|
|
||||||
|
from neutronclient.neutron.v2_0 import purge
|
||||||
|
from neutronclient.tests.unit import test_cli20
|
||||||
|
|
||||||
|
|
||||||
|
class CLITestV20Purge(test_cli20.CLITestV20Base):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(CLITestV20Purge, self).setUp()
|
||||||
|
self.resource_types = ['floatingip', 'port', 'router',
|
||||||
|
'network', 'security_group']
|
||||||
|
|
||||||
|
def _generate_resources_dict(self, value=0):
|
||||||
|
resources_dict = {}
|
||||||
|
resources_dict['true'] = value
|
||||||
|
for resource_type in self.resource_types:
|
||||||
|
resources_dict[resource_type] = value
|
||||||
|
return resources_dict
|
||||||
|
|
||||||
|
def _verify_suffix(self, resources, message):
|
||||||
|
for resource, value in resources.items():
|
||||||
|
if value > 0:
|
||||||
|
suffix = list('%(value)d %(resource)s' %
|
||||||
|
{'value': value, 'resource': resource})
|
||||||
|
if value != 1:
|
||||||
|
suffix.append('s')
|
||||||
|
suffix = ''.join(suffix)
|
||||||
|
self.assertIn(suffix, message)
|
||||||
|
else:
|
||||||
|
self.assertNotIn(resource, message)
|
||||||
|
|
||||||
|
def _verify_message(self, message, deleted, failed):
|
||||||
|
message = message.split('.')
|
||||||
|
success_prefix = "Deleted "
|
||||||
|
failure_prefix = "The following resources could not be deleted: "
|
||||||
|
if not deleted['true']:
|
||||||
|
for msg in message:
|
||||||
|
self.assertNotIn(success_prefix, msg)
|
||||||
|
message = message[0]
|
||||||
|
if not failed['true']:
|
||||||
|
expected = 'Tenant has no supported resources'
|
||||||
|
self.assertEqual(expected, message)
|
||||||
|
else:
|
||||||
|
self.assertIn(failure_prefix, message)
|
||||||
|
self._verify_suffix(failed, message)
|
||||||
|
else:
|
||||||
|
resources_deleted = message[0]
|
||||||
|
self.assertIn(success_prefix, resources_deleted)
|
||||||
|
self._verify_suffix(deleted, resources_deleted)
|
||||||
|
if failed['true']:
|
||||||
|
resources_failed = message[1]
|
||||||
|
self.assertIn(failure_prefix, resources_failed)
|
||||||
|
self._verify_suffix(failed, resources_failed)
|
||||||
|
else:
|
||||||
|
for msg in message:
|
||||||
|
self.assertNotIn(failure_prefix, msg)
|
||||||
|
|
||||||
|
def _verify_result(self, my_purge, deleted, failed):
|
||||||
|
message = my_purge._build_message(deleted, failed, failed['true'])
|
||||||
|
self._verify_message(message, deleted, failed)
|
||||||
|
|
||||||
|
def test_build_message(self):
|
||||||
|
my_purge = purge.Purge(test_cli20.MyApp(sys.stdout), None)
|
||||||
|
|
||||||
|
# Verify message when tenant has no supported resources
|
||||||
|
deleted = self._generate_resources_dict()
|
||||||
|
failed = self._generate_resources_dict()
|
||||||
|
self._verify_result(my_purge, deleted, failed)
|
||||||
|
|
||||||
|
# Verify message when tenant has supported resources,
|
||||||
|
# and they are all deleteable
|
||||||
|
deleted = self._generate_resources_dict(1)
|
||||||
|
self._verify_result(my_purge, deleted, failed)
|
||||||
|
|
||||||
|
# Verify message when tenant has supported resources,
|
||||||
|
# and some are not deleteable
|
||||||
|
failed = self._generate_resources_dict(1)
|
||||||
|
self._verify_result(my_purge, deleted, failed)
|
||||||
|
|
||||||
|
# Verify message when tenant has supported resources,
|
||||||
|
# and all are not deleteable
|
||||||
|
deleted = self._generate_resources_dict()
|
||||||
|
self._verify_result(my_purge, deleted, failed)
|
16
releasenotes/notes/add-neutron-purge-a89e3d1179dce4b1.yaml
Normal file
16
releasenotes/notes/add-neutron-purge-a89e3d1179dce4b1.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
New command 'neutron purge <tenant_id>' will delete all
|
||||||
|
supported resources owned by the given tenant, provided
|
||||||
|
that the user has sufficient authorization and the
|
||||||
|
resources in question are not shared, in use, or
|
||||||
|
otherwise undeletable.
|
||||||
|
|
||||||
|
Supported resources are:
|
||||||
|
* Networks
|
||||||
|
* Subnets
|
||||||
|
* Routers
|
||||||
|
* Ports
|
||||||
|
* Floating IPs
|
||||||
|
* Security Groups
|
Reference in New Issue
Block a user