![asarfaty](/assets/img/avatar_default.png)
1.Upgrade pylint to 2.4.4, add exclusions to the tests, and fix some lint errors in the code 2. Fix user creation with GRANT in MySQL 8.0(Ubuntu Focal) In Ubuntu Bionic (18.04) mysql 5.7 version used to create the user implicitly when using using the GRANT. Ubuntu Focal (20.04) has mysql 8.0 and with mysql 8.0 there is no implicit user creation with GRANT. We need to create the user first before using GRANT command. See also commit I97b0dcbb88c6ef7c22e3c55970211bed792bbd0d 3. Remove fwaas from the zuul.yaml 4. Remove DB migration test which is failing ue to FWaaS migration with py38 5. Fix cover tests python version in .tox 6. fix requirememnts Change-Id: I22654a5d5ccaad3185ae3365a90afba1ce870695
519 lines
23 KiB
Python
519 lines
23 KiB
Python
# Copyright 2017 VMware, Inc.
|
|
# 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 oslo_log import log as logging
|
|
from sqlalchemy.orm import exc
|
|
|
|
from neutron.db import address_scope_db
|
|
from neutron.db import db_base_plugin_v2
|
|
from neutron.db import l3_attrs_db
|
|
from neutron.db import l3_db
|
|
from neutron.db import models_v2
|
|
from neutron_lib.api.definitions import address_scope as ext_address_scope
|
|
from neutron_lib.api.definitions import availability_zone as az_def
|
|
from neutron_lib.api.definitions import network as net_def
|
|
from neutron_lib.api.definitions import port as port_def
|
|
from neutron_lib.api.definitions import subnet as subnet_def
|
|
from neutron_lib.api import validators
|
|
from neutron_lib.api.validators import availability_zone as az_validator
|
|
from neutron_lib.callbacks import events
|
|
from neutron_lib.callbacks import registry
|
|
from neutron_lib.callbacks import resources
|
|
from neutron_lib import constants
|
|
from neutron_lib import context as n_context
|
|
from neutron_lib.db import api as db_api
|
|
from neutron_lib.db import resource_extend
|
|
from neutron_lib import exceptions as n_exc
|
|
from neutron_lib.plugins import directory
|
|
from neutron_lib.utils import net as nl_net_utils
|
|
|
|
from vmware_nsx._i18n import _
|
|
from vmware_nsx.common import exceptions as nsx_exc
|
|
from vmware_nsx.extensions import secgroup_rule_local_ip_prefix as sg_prefix
|
|
from vmware_nsx.services.qos.common import utils as qos_com_utils
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
@resource_extend.has_resource_extenders
|
|
class NsxPluginBase(db_base_plugin_v2.NeutronDbPluginV2,
|
|
address_scope_db.AddressScopeDbMixin):
|
|
"""Common methods for NSX-V, NSX-V3 and NSX-P plugins"""
|
|
|
|
@staticmethod
|
|
def plugin_type():
|
|
return "Unknown"
|
|
|
|
@staticmethod
|
|
@resource_extend.extends([net_def.COLLECTION_NAME])
|
|
def _ext_extend_network_dict(result, netdb):
|
|
ctx = n_context.get_admin_context()
|
|
# get the core plugin as this is a static method with no 'self'
|
|
plugin = directory.get_plugin()
|
|
with db_api.CONTEXT_WRITER.using(ctx):
|
|
plugin._extension_manager.extend_network_dict(
|
|
ctx.session, netdb, result)
|
|
|
|
@staticmethod
|
|
@resource_extend.extends([port_def.COLLECTION_NAME])
|
|
def _ext_extend_port_dict(result, portdb):
|
|
ctx = n_context.get_admin_context()
|
|
# get the core plugin as this is a static method with no 'self'
|
|
plugin = directory.get_plugin()
|
|
with db_api.CONTEXT_WRITER.using(ctx):
|
|
plugin._extension_manager.extend_port_dict(
|
|
ctx.session, portdb, result)
|
|
|
|
@staticmethod
|
|
@resource_extend.extends([subnet_def.COLLECTION_NAME])
|
|
def _ext_extend_subnet_dict(result, subnetdb):
|
|
ctx = n_context.get_admin_context()
|
|
# get the core plugin as this is a static method with no 'self'
|
|
plugin = directory.get_plugin()
|
|
with db_api.CONTEXT_WRITER.using(ctx):
|
|
plugin._extension_manager.extend_subnet_dict(
|
|
ctx.session, subnetdb, result)
|
|
|
|
def get_network_az_by_net_id(self, context, network_id):
|
|
try:
|
|
network = self.get_network(context, network_id)
|
|
except Exception:
|
|
return self.get_default_az()
|
|
|
|
return self.get_network_az(network)
|
|
|
|
def _get_router_interface_ports_by_network(
|
|
self, context, router_id, network_id):
|
|
port_filters = {'device_id': [router_id],
|
|
'device_owner': [l3_db.DEVICE_OWNER_ROUTER_INTF],
|
|
'network_id': [network_id]}
|
|
return self.get_ports(context, filters=port_filters)
|
|
|
|
def _get_network_interface_ports(self, context, net_id):
|
|
port_filters = {'device_owner': [l3_db.DEVICE_OWNER_ROUTER_INTF],
|
|
'network_id': [net_id]}
|
|
return self.get_ports(context, filters=port_filters)
|
|
|
|
def _get_network_router_ids(self, context, net_id):
|
|
intf_ports = self._get_network_interface_ports(context, net_id)
|
|
return [port['device_id'] for port in intf_ports if port['device_id']]
|
|
|
|
def get_router_for_floatingip(self, context, internal_port,
|
|
internal_subnet, external_network_id):
|
|
router_id = super(NsxPluginBase, self).get_router_for_floatingip(
|
|
context, internal_port, internal_subnet, external_network_id)
|
|
if router_id:
|
|
router = self._get_router(context.elevated(), router_id)
|
|
if not router.enable_snat:
|
|
msg = _("Unable to assign a floating IP to a router that "
|
|
"has SNAT disabled")
|
|
raise n_exc.InvalidInput(error_message=msg)
|
|
return router_id
|
|
|
|
def _get_network_address_scope(self, context, net_id):
|
|
network = self.get_network(context, net_id)
|
|
return network.get(ext_address_scope.IPV4_ADDRESS_SCOPE)
|
|
|
|
def _get_subnet_address_scope(self, context, subnet_id, subnet=None):
|
|
if not subnet:
|
|
subnet = self.get_subnet(context, subnet_id)
|
|
if not subnet['subnetpool_id']:
|
|
return
|
|
subnetpool = self.get_subnetpool(context, subnet['subnetpool_id'])
|
|
return subnetpool.get('address_scope_id', '')
|
|
|
|
def _get_subnetpool_address_scope(self, context, subnetpool_id):
|
|
if not subnetpool_id:
|
|
return
|
|
subnetpool = self.get_subnetpool(context, subnetpool_id)
|
|
return subnetpool.get('address_scope_id', '')
|
|
|
|
def _validate_address_scope_for_router_interface(self, context, router_id,
|
|
gw_network_id, subnet_id,
|
|
subnet=None):
|
|
"""Validate that the GW address scope is the same as the interface"""
|
|
gw_address_scope = self._get_network_address_scope(context,
|
|
gw_network_id)
|
|
if not gw_address_scope:
|
|
return
|
|
subnet_address_scope = self._get_subnet_address_scope(
|
|
context, subnet_id, subnet=subnet)
|
|
if (not subnet_address_scope or
|
|
subnet_address_scope != gw_address_scope):
|
|
raise nsx_exc.NsxRouterInterfaceDoesNotMatchAddressScope(
|
|
router_id=router_id, address_scope_id=gw_address_scope)
|
|
|
|
def _get_router_interfaces(self, context, router_id):
|
|
port_filters = {'device_id': [router_id],
|
|
'device_owner': [l3_db.DEVICE_OWNER_ROUTER_INTF]}
|
|
return self.get_ports(context, filters=port_filters)
|
|
|
|
def _find_router_subnets_cidrs(self, context, router_id, subnets=None):
|
|
"""Retrieve cidrs of subnets attached to the specified router."""
|
|
if not subnets:
|
|
subnets = self._load_router_subnet_cidrs_from_db(context,
|
|
router_id)
|
|
return [subnet['cidr'] for subnet in subnets]
|
|
|
|
def _find_router_subnets_cidrs_per_addr_scope(self, context, router_id,
|
|
subnets=None):
|
|
"""Generate a list of cidrs per address pool.
|
|
|
|
Go over all the router interface subnets.
|
|
return a list of lists of subnets cidrs belonging to same
|
|
address pool.
|
|
"""
|
|
if not subnets:
|
|
subnets = self._load_router_subnet_cidrs_from_db(context,
|
|
router_id)
|
|
cidrs_map = {}
|
|
for subnet in subnets:
|
|
ads = self._get_subnetpool_address_scope(
|
|
context, subnet['subnetpool_id']) or ''
|
|
if ads not in cidrs_map:
|
|
cidrs_map[ads] = []
|
|
cidrs_map[ads].append(subnet['cidr'])
|
|
return list(cidrs_map.values())
|
|
|
|
def _get_port_by_device_id(self, context, device_id, device_owner):
|
|
"""Retrieve ports associated with a specific device id.
|
|
|
|
Used for retrieving all neutron ports attached to a given router.
|
|
"""
|
|
port_qry = context.session.query(models_v2.Port)
|
|
return port_qry.filter_by(
|
|
device_id=device_id,
|
|
device_owner=device_owner,).all()
|
|
|
|
def _update_filters_with_sec_group(self, context, filters=None):
|
|
if filters is not None:
|
|
security_groups = filters.pop("security_groups", None)
|
|
if security_groups:
|
|
bindings = (
|
|
super(NsxPluginBase, self)
|
|
._get_port_security_group_bindings(context,
|
|
filters={'security_group_id': security_groups}))
|
|
if 'id' in filters:
|
|
filters['id'] = [entry['port_id'] for
|
|
entry in bindings
|
|
if entry['port_id'] in filters['id']]
|
|
else:
|
|
filters['id'] = [entry['port_id'] for entry in bindings]
|
|
|
|
def _load_router_subnet_cidrs_from_db(self, context, router_id):
|
|
"""Retrieve subnets attached to the specified router."""
|
|
ports = self._get_port_by_device_id(context, router_id,
|
|
l3_db.DEVICE_OWNER_ROUTER_INTF)
|
|
# No need to check for overlapping CIDRs
|
|
subnet_ids = []
|
|
for port in ports:
|
|
for ip in port.get('fixed_ips', []):
|
|
subnet_ids.append(ip.subnet_id)
|
|
|
|
subnet_qry = context.session.query(models_v2.Subnet)
|
|
db_subnets = subnet_qry.filter(
|
|
models_v2.Subnet.id.in_(subnet_ids)).all()
|
|
subnets = [{'id': subnet.id,
|
|
'cidr': subnet.cidr,
|
|
'subnetpool_id': subnet.subnetpool_id,
|
|
'ip_version': subnet.ip_version,
|
|
'network_id': subnet.network_id,
|
|
'gateway_ip': subnet.gateway_ip,
|
|
'enable_dhcp': subnet.enable_dhcp,
|
|
'ipv6_address_mode': subnet.ipv6_address_mode}
|
|
for subnet in db_subnets]
|
|
return subnets
|
|
|
|
def _find_router_gw_subnets(self, context, router):
|
|
"""Retrieve external subnets attached to router GW"""
|
|
if not router['external_gateway_info']:
|
|
return []
|
|
|
|
subnets = []
|
|
for fip in router['external_gateway_info']['external_fixed_ips']:
|
|
subnet = self.get_subnet(context, fip['subnet_id'])
|
|
subnets.append(subnet)
|
|
|
|
return subnets
|
|
|
|
def recalculate_snat_rules_for_router(self, context, router, subnets):
|
|
"""Method to recalculate router snat rules for specific subnets.
|
|
Invoked when subnetpool address scope changes.
|
|
Implemented in child plugin classes
|
|
"""
|
|
pass
|
|
|
|
def recalculate_fw_rules_for_router(self, context, router, subnets):
|
|
"""Method to recalculate router FW rules for specific subnets.
|
|
Invoked when subnetpool address scope changes.
|
|
Implemented in child plugin classes
|
|
"""
|
|
pass
|
|
|
|
def _filter_subnets_by_subnetpool(self, subnets, subnetpool_id):
|
|
return [subnet for subnet in subnets
|
|
if subnet['subnetpool_id'] == subnetpool_id]
|
|
|
|
def on_subnetpool_address_scope_updated(self, resource, event,
|
|
trigger, payload=None):
|
|
context = payload.context
|
|
|
|
routers = self.get_routers(context)
|
|
subnetpool_id = payload.resource_id
|
|
elevated_context = context.elevated()
|
|
LOG.info("Inspecting routers for potential configuration changes "
|
|
"due to address scope change on subnetpool %s", subnetpool_id)
|
|
for rtr in routers:
|
|
subnets = self._load_router_subnet_cidrs_from_db(elevated_context,
|
|
rtr['id'])
|
|
gw_subnets = self._find_router_gw_subnets(elevated_context,
|
|
rtr)
|
|
|
|
affected_subnets = self._filter_subnets_by_subnetpool(
|
|
subnets, subnetpool_id)
|
|
affected_gw_subnets = self._filter_subnets_by_subnetpool(
|
|
gw_subnets, subnetpool_id)
|
|
|
|
if not affected_subnets and not affected_gw_subnets:
|
|
# No subnets were affected by address scope change
|
|
continue
|
|
|
|
if (affected_subnets == subnets and
|
|
affected_gw_subnets == gw_subnets):
|
|
# All subnets remain under the same address scope
|
|
# (all router subnets were allocated from subnetpool_id)
|
|
continue
|
|
|
|
# Update east-west FW rules
|
|
self.recalculate_fw_rules_for_router(context, rtr,
|
|
affected_subnets)
|
|
|
|
if not rtr['external_gateway_info']:
|
|
continue
|
|
|
|
if not rtr['external_gateway_info']['enable_snat']:
|
|
LOG.warning("Due to address scope change on subnetpool "
|
|
"%(subnetpool)s, uniqueness on interface "
|
|
"addresses on no-snat router %(router) is no "
|
|
"longer guaranteed, which may result in faulty "
|
|
"operation.", {'subnetpool': subnetpool_id,
|
|
'router': rtr['id']})
|
|
continue
|
|
|
|
if affected_gw_subnets:
|
|
# GW address scope have changed - we need to revisit snat
|
|
# rules for all router interfaces
|
|
affected_subnets = subnets
|
|
|
|
self.recalculate_snat_rules_for_router(context, rtr,
|
|
affected_subnets)
|
|
|
|
def _validate_max_ips_per_port(self, fixed_ip_list, device_owner):
|
|
"""Validate the number of fixed ips on a port
|
|
|
|
Do not allow multiple ip addresses on a port since the nsx backend
|
|
cannot add multiple static dhcp bindings with the same port
|
|
"""
|
|
if (device_owner and
|
|
nl_net_utils.is_port_trusted({'device_owner': device_owner})):
|
|
return
|
|
|
|
if validators.is_attr_set(fixed_ip_list) and len(fixed_ip_list) > 1:
|
|
msg = _('Exceeded maximum amount of fixed ips per port')
|
|
raise n_exc.InvalidInput(error_message=msg)
|
|
|
|
def _extract_external_gw(self, context, router, is_extract=True):
|
|
r = router['router']
|
|
gw_info = constants.ATTR_NOT_SPECIFIED
|
|
# First extract the gateway info in case of updating
|
|
# gateway before edge is deployed.
|
|
if 'external_gateway_info' in r:
|
|
gw_info = r.get('external_gateway_info', {})
|
|
if is_extract:
|
|
del r['external_gateway_info']
|
|
network_id = (gw_info.get('network_id') if gw_info
|
|
else None)
|
|
if network_id:
|
|
ext_net = self._get_network(context, network_id)
|
|
if not ext_net.external:
|
|
msg = (_("Network '%s' is not a valid external network") %
|
|
network_id)
|
|
raise n_exc.BadRequest(resource='router', msg=msg)
|
|
|
|
subnets = self._get_subnets_by_network(context.elevated(),
|
|
network_id)
|
|
if not subnets:
|
|
msg = _("Cannot update gateway on Network '%s' "
|
|
"with no subnet") % network_id
|
|
raise n_exc.BadRequest(resource='router', msg=msg)
|
|
return gw_info
|
|
|
|
def get_subnets_by_network(self, context, network_id):
|
|
return [self._make_subnet_dict(subnet_obj) for subnet_obj in
|
|
self._get_subnets_by_network(context.elevated(), network_id)]
|
|
|
|
def _validate_routes(self, context, router_id, routes):
|
|
super(NsxPluginBase, self)._validate_routes(
|
|
context, router_id, routes)
|
|
# do not allow adding a default route. NSX-v/v3 don't support it
|
|
for route in routes:
|
|
if (route.get('destination', '').startswith('0.0.0.0/') or
|
|
route.get('destination', '').startswith('::/')):
|
|
msg = _("Cannot set a default route using static routes")
|
|
raise n_exc.BadRequest(resource='router', msg=msg)
|
|
|
|
@staticmethod
|
|
@resource_extend.extends([net_def.COLLECTION_NAME])
|
|
def _extend_availability_zone_hints(net_res, net_db):
|
|
net_res[az_def.AZ_HINTS] = az_validator.convert_az_string_to_list(
|
|
net_db[az_def.AZ_HINTS])
|
|
|
|
def _validate_external_subnet(self, context, network_id):
|
|
if self._network_is_external(context, network_id):
|
|
err_msg = _("Can not enable DHCP on external network")
|
|
raise n_exc.InvalidInput(error_message=err_msg)
|
|
|
|
def _validate_host_routes_input(self, subnet_input,
|
|
orig_enable_dhcp=None,
|
|
orig_host_routes=None):
|
|
s = subnet_input['subnet']
|
|
request_host_routes = (validators.is_attr_set(s.get('host_routes')) and
|
|
s['host_routes'])
|
|
clear_host_routes = (validators.is_attr_set(s.get('host_routes')) and
|
|
not s['host_routes'])
|
|
request_enable_dhcp = s.get('enable_dhcp')
|
|
if request_enable_dhcp is False:
|
|
if (request_host_routes or
|
|
not clear_host_routes and orig_host_routes):
|
|
err_msg = _("Can't disable DHCP while using host routes")
|
|
raise n_exc.InvalidInput(error_message=err_msg)
|
|
|
|
if request_host_routes:
|
|
if not request_enable_dhcp and orig_enable_dhcp is False:
|
|
err_msg = _("Host routes can only be supported when DHCP "
|
|
"is enabled")
|
|
raise n_exc.InvalidInput(error_message=err_msg)
|
|
|
|
def _validate_qos_policy_id(self, context, qos_policy_id):
|
|
if qos_policy_id:
|
|
qos_com_utils.validate_policy_accessable(context, qos_policy_id)
|
|
|
|
def _process_extra_attr_router_create(self, context, router_db, r):
|
|
for extra_attr in l3_attrs_db.get_attr_info().keys():
|
|
if (extra_attr in r and
|
|
validators.is_attr_set(r.get(extra_attr))):
|
|
self.set_extra_attr_value(context, router_db,
|
|
extra_attr, r[extra_attr])
|
|
|
|
def _ensure_default_security_group(self, context, tenant_id):
|
|
try:
|
|
return super(NsxPluginBase, self)._ensure_default_security_group(
|
|
context, tenant_id)
|
|
except exc.FlushError:
|
|
# This means that another worker already created this default SG
|
|
LOG.info("_ensure_default_security_group fail for project %s. "
|
|
"Default security group already created", tenant_id)
|
|
return self._get_default_sg_id(context, tenant_id)
|
|
|
|
def _assert_on_assoc_floatingip_to_special_ports(self, fip, internal_port):
|
|
"""Do not allow attaching fip to dedicated ports"""
|
|
port_id = fip.get('port_id')
|
|
dev_owner = internal_port.get('device_owner', '')
|
|
if (port_id and dev_owner and
|
|
(dev_owner in constants.ROUTER_INTERFACE_OWNERS or
|
|
dev_owner == constants.DEVICE_OWNER_DHCP)):
|
|
msg = _('Associating floatingip to %s port is '
|
|
'restricted') % dev_owner
|
|
raise n_exc.BadRequest(resource='floatingip', msg=msg)
|
|
|
|
def _fix_sg_rule_dict_ips(self, sg_rule):
|
|
# 0.0.0.0/# and ::/ are not valid entries for local and remote so we
|
|
# need to change this to None
|
|
if (sg_rule.get('remote_ip_prefix') and
|
|
(sg_rule['remote_ip_prefix'].startswith('0.0.0.0/') or
|
|
sg_rule['remote_ip_prefix'].startswith('::/'))):
|
|
sg_rule['remote_ip_prefix'] = None
|
|
if (sg_rule.get(sg_prefix.LOCAL_IP_PREFIX) and
|
|
validators.is_attr_set(sg_rule[sg_prefix.LOCAL_IP_PREFIX]) and
|
|
(sg_rule[sg_prefix.LOCAL_IP_PREFIX].startswith('0.0.0.0/') or
|
|
sg_rule[sg_prefix.LOCAL_IP_PREFIX].startswith('::/'))):
|
|
sg_rule[sg_prefix.LOCAL_IP_PREFIX] = None
|
|
|
|
def _log_get_ports(self, ports, filters):
|
|
if len(ports) > 1:
|
|
LOG.debug("Getting %(num)s ports with %(filters)s",
|
|
{'num': len(ports),
|
|
'filters': ('filters ' + str(filters) if filters
|
|
else 'no filters')})
|
|
|
|
def get_housekeeper(self, context, name, fields=None):
|
|
# run the job in readonly mode and get the results
|
|
self.housekeeper.run(context, name, readonly=True)
|
|
return self.housekeeper.get(name) if self.housekeeper else None
|
|
|
|
def get_housekeepers(self, context, filters=None, fields=None, sorts=None,
|
|
limit=None, marker=None, page_reverse=False):
|
|
return self.housekeeper.list() if self.housekeeper else []
|
|
|
|
def update_housekeeper(self, context, name, housekeeper):
|
|
# run the job in non-readonly mode and get the results
|
|
if not self.housekeeper.readwrite_allowed(name):
|
|
err_msg = (_("Can not run housekeeper job %s in readwrite "
|
|
"mode") % name)
|
|
raise n_exc.InvalidInput(error_message=err_msg)
|
|
self.housekeeper.run(context, name, readonly=False)
|
|
return self.housekeeper.get(name)
|
|
|
|
def get_housekeepers_count(self, context, filters=None):
|
|
return len(self.housekeeper.list()) if self.housekeeper else 0
|
|
|
|
|
|
# Register the callback
|
|
def _validate_network_has_subnet(resource, event, trigger, payload=None):
|
|
network_id = payload.metadata.get('network_id')
|
|
subnets = payload.metadata.get('subnets')
|
|
if not subnets:
|
|
msg = _('No subnet defined on network %s') % network_id
|
|
raise n_exc.InvalidInput(error_message=msg)
|
|
|
|
|
|
def _delete_sg_group_related_rules(resource, event, trigger, **kwargs):
|
|
"""Upon SG deletion, call the explicit delete method for rules with that
|
|
SG as the remote one.
|
|
Otherwise those will be deleted with on_delete cascade, leaving the NSX
|
|
backend unaware.
|
|
"""
|
|
sg_id = kwargs["security_group"]["id"]
|
|
context = kwargs["context"]
|
|
core_plugin = directory.get_plugin()
|
|
filters = {'remote_group_id': [sg_id]}
|
|
rules = core_plugin.get_security_group_rules(context, filters=filters)
|
|
for rule in rules:
|
|
if rule['security_group_id'] == sg_id:
|
|
continue
|
|
LOG.info("Deleting SG rule %s because of remote group %s deletion",
|
|
rule['id'], sg_id)
|
|
core_plugin.delete_security_group_rule(context, rule["id"])
|
|
|
|
|
|
def subscribe():
|
|
registry.subscribe(_validate_network_has_subnet,
|
|
resources.ROUTER_GATEWAY, events.BEFORE_CREATE)
|
|
registry.subscribe(_delete_sg_group_related_rules,
|
|
resources.SECURITY_GROUP, events.PRECOMMIT_DELETE)
|
|
|
|
|
|
subscribe()
|