diff --git a/.typos.toml b/.typos.toml index 6f497f9a3..517cfdbb5 100644 --- a/.typos.toml +++ b/.typos.toml @@ -36,3 +36,5 @@ retreive = "retreive" metadat = "metadat" # operators/rook/values-cluster.yaml mentions "ceph-blockpool-ecoded" ecoded = "ecoded" +# lazy option is not a typo +selectin = "selectin" diff --git a/python/neutron-understack/neutron_understack/__init__.py b/python/neutron-understack/neutron_understack/__init__.py index a4e2017f0..d1af898c8 100644 --- a/python/neutron-understack/neutron_understack/__init__.py +++ b/python/neutron-understack/neutron_understack/__init__.py @@ -1 +1,5 @@ __version__ = "0.1" + +# Neutron's extension loader looks for ``.extensions`` as +# an attribute on the imported top-level package. +from neutron_understack import extensions # noqa: F401 diff --git a/python/neutron-understack/neutron_understack/api/__init__.py b/python/neutron-understack/neutron_understack/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/api/definitions/__init__.py b/python/neutron-understack/neutron_understack/api/definitions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/api/definitions/understack_vni.py b/python/neutron-understack/neutron_understack/api/definitions/understack_vni.py new file mode 100644 index 000000000..9a54db773 --- /dev/null +++ b/python/neutron-understack/neutron_understack/api/definitions/understack_vni.py @@ -0,0 +1,37 @@ +from neutron_lib import constants +from neutron_lib.api import converters +from neutron_lib.api.definitions import l3 + +ALIAS = "understack_vni" +IS_SHIM_EXTENSION = False +IS_STANDARD_ATTR_EXTENSION = False +NAME = "Understack Router VNI" +API_PREFIX = "" +DESCRIPTION = "Router extension for Understack hardware VRF VNI allocation" +UPDATED_TIMESTAMP = "2026-07-01T00:00:00-00:00" +RESOURCE_NAME = l3.ROUTER +COLLECTION_NAME = l3.ROUTERS + +EVPN_VNI = "evpn_vni" + +RESOURCE_ATTRIBUTE_MAP = { + COLLECTION_NAME: { + EVPN_VNI: { + "allow_post": True, + "allow_put": False, + "convert_to": converters.convert_to_int_if_not_none, + "default": 0, + "is_visible": True, + "is_filter": True, + "is_sort_key": True, + "enforce_policy": True, + "validate": {"type:range_or_none": [0, constants.MAX_VXLAN_VNI]}, + }, + }, +} + +SUB_RESOURCE_ATTRIBUTE_MAP = {} +ACTION_MAP = {} +REQUIRED_EXTENSIONS = [l3.ALIAS] +OPTIONAL_EXTENSIONS = [] +ACTION_STATUS = {} diff --git a/python/neutron-understack/neutron_understack/conf/__init__.py b/python/neutron-understack/neutron_understack/conf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/conf/policies/__init__.py b/python/neutron-understack/neutron_understack/conf/policies/__init__.py new file mode 100644 index 000000000..26c3c907a --- /dev/null +++ b/python/neutron-understack/neutron_understack/conf/policies/__init__.py @@ -0,0 +1,9 @@ +import itertools + +from neutron_understack.conf.policies import evpn + + +def list_rules(): + return itertools.chain( + evpn.list_rules(), + ) diff --git a/python/neutron-understack/neutron_understack/conf/policies/evpn.py b/python/neutron-understack/neutron_understack/conf/policies/evpn.py new file mode 100644 index 000000000..916f07caf --- /dev/null +++ b/python/neutron-understack/neutron_understack/conf/policies/evpn.py @@ -0,0 +1,34 @@ +from neutron.conf.policies import base +from oslo_policy import policy + +COLLECTION_PATH = "/routers" +RESOURCE_PATH = "/routers/{id}" + +ACTION_POST = [ + {"method": "POST", "path": COLLECTION_PATH}, +] +ACTION_GET = [ + {"method": "GET", "path": COLLECTION_PATH}, + {"method": "GET", "path": RESOURCE_PATH}, +] + +rules = [ + policy.DocumentedRuleDefault( + name="create_router:evpn_vni", + check_str=base.ADMIN, + scope_types=["project"], + description="Specify ``evpn_vni`` attribute when creating a router", + operations=ACTION_POST, + ), + policy.DocumentedRuleDefault( + name="get_router:evpn_vni", + check_str=base.ADMIN_OR_PROJECT_READER, + scope_types=["project"], + description="Get ``evpn_vni`` attribute of a router", + operations=ACTION_GET, + ), +] + + +def list_rules(): + return rules diff --git a/python/neutron-understack/neutron_understack/config.py b/python/neutron-understack/neutron_understack/config.py index cbcbd440f..8ee4324b1 100644 --- a/python/neutron-understack/neutron_understack/config.py +++ b/python/neutron-understack/neutron_understack/config.py @@ -1,3 +1,5 @@ +import contextlib + from keystoneauth1 import loading as ks_loading from keystoneauth1 import session as ks_session from oslo_config import cfg @@ -5,6 +7,7 @@ _OPT_GRP_ML2_UNDERSTACK = "ml2_understack" _OPT_GRP_IRONIC = "ironic" _OPT_GRP_L3_SVC_CISCO_ASA = "l3_service_cisco_asa" +_OPT_GRP_UNDERSTACK_VNI = "understack_vni" _mech_understack_opts = [ cfg.StrOpt( @@ -99,6 +102,19 @@ ), ] +_understack_vni_opts = [ + cfg.ListOpt( + "vni_ranges", + default=["1:16777215"], + item_type=cfg.types.String(), + help=( + "Comma-separated list of VNI ranges available for automatic " + "Understack VRF router VNI allocation. Each entry is either a " + "single VNI or an inclusive start:end range." + ), + ), +] + def list_understack_opts(): return [ @@ -125,6 +141,12 @@ def list_cisco_asa_opts(): ] +def list_understack_vni_opts(): + return [ + (_OPT_GRP_UNDERSTACK_VNI, _understack_vni_opts), + ] + + def register_ml2_understack_opts(config): config.register_opts(_mech_understack_opts, _OPT_GRP_ML2_UNDERSTACK) @@ -139,6 +161,11 @@ def register_l3_svc_cisco_asa_opts(config): config.register_opts(_l3_svc_cisco_asa_opts, _OPT_GRP_L3_SVC_CISCO_ASA) +def register_understack_vni_opts(config): + with contextlib.suppress(cfg.DuplicateOptError): + config.register_opts(_understack_vni_opts, _OPT_GRP_UNDERSTACK_VNI) + + def get_session(group: str) -> ks_session.Session: auth = ks_loading.load_auth_from_conf_options(cfg.CONF, group) session = ks_loading.load_session_from_conf_options(cfg.CONF, group, auth=auth) diff --git a/python/neutron-understack/neutron_understack/db/__init__.py b/python/neutron-understack/neutron_understack/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/__init__.py b/python/neutron-understack/neutron_understack/db/migration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/__init__.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/env.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/env.py new file mode 100644 index 000000000..b6e607914 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/env.py @@ -0,0 +1,67 @@ +from logging.config import fileConfig + +import sqlalchemy as sa +from alembic import context +from neutron_lib.db import model_base +from oslo_config import cfg +from oslo_db.sqlalchemy import session +from sqlalchemy import event + +VERSION_TABLE = "neutron_understack_alembic_version" +MYSQL_ENGINE = None +config = context.config +neutron_config = config.neutron_config +fileConfig(config.config_file_name) +target_metadata = model_base.BASEV2.metadata + + +def set_mysql_engine(): + try: + mysql_engine = neutron_config.command.mysql_engine + except cfg.NoSuchOptError: + mysql_engine = None + + global MYSQL_ENGINE # noqa: PLW0603 + MYSQL_ENGINE = mysql_engine or model_base.BASEV2.__table_args__["mysql_engine"] + + +def run_migrations_offline(): + set_mysql_engine() + kwargs = {"version_table": VERSION_TABLE} + if neutron_config.database.connection: + kwargs["url"] = neutron_config.database.connection + else: + kwargs["dialect_name"] = neutron_config.database.engine + context.configure(**kwargs) + + with context.begin_transaction(): + context.run_migrations() + + +@event.listens_for(sa.Table, "after_parent_attach") +def set_storage_engine(target, parent): + if MYSQL_ENGINE: + target.kwargs["mysql_engine"] = MYSQL_ENGINE + + +def run_migrations_online(): + set_mysql_engine() + engine = session.create_engine(neutron_config.database.connection) + connection = engine.connect() + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table=VERSION_TABLE, + ) + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/script.py.mako b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/script.py.mako new file mode 100644 index 000000000..6a67b1a5c --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/script.py.mako @@ -0,0 +1,38 @@ +# Copyright ${create_date.year} Understack Developers +# +# 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. +# + +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +% if branch_labels: +branch_labels = ${repr(branch_labels)} +% endif + + +def upgrade(): + ${upgrades if upgrades else "pass"} diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/__init__.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/contract/8f5b7d0f4b2b_start_contract_branch.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/contract/8f5b7d0f4b2b_start_contract_branch.py new file mode 100644 index 000000000..28370aba0 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/contract/8f5b7d0f4b2b_start_contract_branch.py @@ -0,0 +1,17 @@ +"""Start neutron-understack contract branch. + +Revision ID: 8f5b7d0f4b2b +Revises: start_neutron_understack +Create Date: 2026-07-01 00:00:00.000000 + +""" + +from neutron.db.migration import cli + +revision = "8f5b7d0f4b2b" +down_revision = "start_neutron_understack" +branch_labels = (cli.CONTRACT_BRANCH,) + + +def upgrade(): + pass diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/contract/__init__.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/contract/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/expand/5b7f7d0f4b2a_add_understack_vni_allocations.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/expand/5b7f7d0f4b2a_add_understack_vni_allocations.py new file mode 100644 index 000000000..51c0e0948 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/expand/5b7f7d0f4b2a_add_understack_vni_allocations.py @@ -0,0 +1,36 @@ +"""Add Understack router VNI allocation table. + +Revision ID: 5b7f7d0f4b2a +Revises: start_neutron_understack +Create Date: 2026-07-01 00:00:00.000000 + +""" + +import sqlalchemy as sa +from neutron.db import migration +from neutron.db.migration import cli + +revision = "5b7f7d0f4b2a" +down_revision = "start_neutron_understack" +branch_labels = (cli.EXPAND_BRANCH,) + + +def upgrade(): + migration.create_table_if_not_exists( + "understack_router_vni_allocations", + sa.Column("vni", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("router_id", sa.String(length=36), nullable=True), + sa.Column( + "allocated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("released_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["router_id"], ["routers.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("vni"), + sa.UniqueConstraint( + "router_id", + name="uniq_understack_router_vni_allocations0router_id", + ), + ) diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/expand/__init__.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/2026.1/expand/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/CONTRACT_HEAD b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/CONTRACT_HEAD new file mode 100644 index 000000000..5296d0cd1 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/CONTRACT_HEAD @@ -0,0 +1 @@ +8f5b7d0f4b2b diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/EXPAND_HEAD b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/EXPAND_HEAD new file mode 100644 index 000000000..72eac5f17 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/EXPAND_HEAD @@ -0,0 +1 @@ +5b7f7d0f4b2a diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/__init__.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/start_neutron_understack.py b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/start_neutron_understack.py new file mode 100644 index 000000000..f5bd27c95 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/migration/alembic_migrations/versions/start_neutron_understack.py @@ -0,0 +1,14 @@ +"""Start neutron-understack migration chain. + +Revision ID: start_neutron_understack +Revises: None +Create Date: 2026-07-01 00:00:00.000000 + +""" + +revision = "start_neutron_understack" +down_revision = None + + +def upgrade(): + pass diff --git a/python/neutron-understack/neutron_understack/db/understack_vni.py b/python/neutron-understack/neutron_understack/db/understack_vni.py new file mode 100644 index 000000000..c68ab8ab2 --- /dev/null +++ b/python/neutron-understack/neutron_understack/db/understack_vni.py @@ -0,0 +1,43 @@ +import sqlalchemy as sa +from neutron_lib.db import model_base +from sqlalchemy import orm + + +class UnderstackRouterVNIAllocation(model_base.BASEV2): + __tablename__ = "understack_router_vni_allocations" + + vni = sa.Column(sa.Integer(), primary_key=True, autoincrement=False) + router_id = sa.Column( + sa.String(36), + sa.ForeignKey("routers.id", ondelete="SET NULL"), + nullable=True, + ) + allocated_at = sa.Column( + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ) + released_at = sa.Column(sa.DateTime(), nullable=True) + + __table_args__ = ( + sa.UniqueConstraint( + "router_id", + name="uniq_understack_router_vni_allocations0router_id", + ), + model_base.BASEV2.__table_args__, + ) + + router = orm.relationship( + "Router", + lazy="noload", + load_on_pending=True, + viewonly=True, + backref=orm.backref( + "understack_vni_allocation", + lazy="selectin", + uselist=False, + viewonly=True, + ), + ) + + revises_on_change = ("router",) diff --git a/python/neutron-understack/neutron_understack/extensions/__init__.py b/python/neutron-understack/neutron_understack/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/neutron-understack/neutron_understack/extensions/understack_vni.py b/python/neutron-understack/neutron_understack/extensions/understack_vni.py new file mode 100644 index 000000000..9e181c126 --- /dev/null +++ b/python/neutron-understack/neutron_understack/extensions/understack_vni.py @@ -0,0 +1,23 @@ +from importlib import util as importlib_util + +from neutron_lib.api import extensions + +try: + from neutron_lib.api.definitions import evpn as evpn_apidef +except ImportError: + evpn_apidef = None + +from neutron_understack.api.definitions import understack_vni as apidef + + +def _api_definition(): + if ( + evpn_apidef is not None + and importlib_util.find_spec("neutron.extensions.evpn") is None + ): + return evpn_apidef + return apidef + + +class Understack_vni(extensions.APIExtensionDescriptor): + api_definition = _api_definition() diff --git a/python/neutron-understack/neutron_understack/l3_router/understack_vni_db.py b/python/neutron-understack/neutron_understack/l3_router/understack_vni_db.py new file mode 100644 index 000000000..329ccd364 --- /dev/null +++ b/python/neutron-understack/neutron_understack/l3_router/understack_vni_db.py @@ -0,0 +1,238 @@ +import sqlalchemy as sa +from neutron_lib import constants as n_const +from neutron_lib import exceptions as n_exc +from oslo_config import cfg +from oslo_utils import timeutils + +from neutron_understack.db import understack_vni as vni_models + +MIN_VNI = getattr(n_const, "MIN_VXLAN_VNI", 1) +MAX_VNI = n_const.MAX_VXLAN_VNI +DEFAULT_VNI_RANGES = [f"{MIN_VNI}:{MAX_VNI}"] + + +class UnderstackVNIInvalidRange(n_exc.NeutronException): + message = "Invalid understack_vni.vni_ranges entry %(entry)s: %(reason)s." + + +class UnderstackVNINotInRange(n_exc.BadRequest): + message = ( + "VNI %(vni)s is outside the configured Understack VNI ranges " "%(ranges)s." + ) + + +class UnderstackVNIInUse(n_exc.Conflict): + message = "Understack VNI %(vni)s is already allocated to router %(router_id)s." + + +class UnderstackVNIRouterHasVNI(n_exc.Conflict): + message = ( + "Router %(router_id)s already has Understack VNI %(existing_vni)s; " + "requested %(requested_vni)s." + ) + + +class UnderstackVNINoAvailable(n_exc.Conflict): + message = "No Understack VNI is available in configured ranges %(ranges)s." + + +def is_auto_vni(vni): + return vni is n_const.ATTR_NOT_SPECIFIED or vni in (None, 0) + + +def format_vni_ranges(ranges): + return ",".join(f"{start}:{end}" for start, end in ranges) + + +def parse_vni_ranges(vni_ranges): + parsed = [] + for entry in vni_ranges or []: + entry = str(entry).strip() # noqa: PLW2901 + if not entry: + raise UnderstackVNIInvalidRange(entry=entry, reason="empty range") + + if ":" in entry: + start_text, end_text = entry.split(":", 1) + else: + start_text = end_text = entry + + try: + start = int(start_text) + end = int(end_text) + except ValueError as exc: + raise UnderstackVNIInvalidRange( + entry=entry, reason="range bounds must be integers" + ) from exc + + if start > end: + raise UnderstackVNIInvalidRange( + entry=entry, reason="range start must be less than or equal to end" + ) + if start < MIN_VNI or end > MAX_VNI: + raise UnderstackVNIInvalidRange( + entry=entry, + reason=f"range must be within {MIN_VNI}:{MAX_VNI}", + ) + parsed.append((start, end)) + + if not parsed: + raise UnderstackVNIInvalidRange( + entry=",".join(vni_ranges or []), reason="at least one range is required" + ) + + return _merge_ranges(parsed) + + +def _merge_ranges(ranges): + merged = [] + for start, end in sorted(ranges): + if not merged or start > merged[-1][1] + 1: + merged.append((start, end)) + else: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + return merged + + +class UnderstackVniDbHelper: + def __init__(self, vni_ranges=None): + self._vni_ranges = vni_ranges + + @property + def ranges(self): + configured_ranges = ( + self._vni_ranges + if self._vni_ranges is not None + else cfg.CONF.understack_vni.vni_ranges + ) + return parse_vni_ranges(configured_ranges) + + def allocate_vni_for_router(self, context, router_id, requested_vni): + ranges = self.ranges + existing = self._get_router_allocation(context, router_id) + if existing: + if is_auto_vni(requested_vni) or requested_vni == existing.vni: + return existing.vni + raise UnderstackVNIRouterHasVNI( + router_id=router_id, + existing_vni=existing.vni, + requested_vni=requested_vni, + ) + + if is_auto_vni(requested_vni): + return self._allocate_auto_vni(context, router_id, ranges) + + requested_vni = int(requested_vni) + self._validate_vni_in_ranges(requested_vni, ranges) + return self._allocate_specific_vni(context, router_id, requested_vni) + + def release_vni_for_router(self, context, router_id): + allocation = self._get_router_allocation(context, router_id) + if not allocation: + return + allocation.router_id = None + allocation.released_at = timeutils.utcnow() + context.session.flush() + + def get_vni_for_router(self, context, router_id): + allocation = self._get_router_allocation(context, router_id) + if allocation: + return allocation.vni + return None + + def _allocate_auto_vni(self, context, router_id, ranges): + never_used_vni = self._find_never_used_vni(context, ranges) + if never_used_vni is not None: + allocation = vni_models.UnderstackRouterVNIAllocation( + vni=never_used_vni, + router_id=router_id, + ) + context.session.add(allocation) + context.session.flush() + return never_used_vni + + released = self._find_released_allocation(context, ranges) + if released: + released.router_id = router_id + released.released_at = None + context.session.flush() + return released.vni + + raise UnderstackVNINoAvailable(ranges=format_vni_ranges(ranges)) + + def _allocate_specific_vni(self, context, router_id, vni): + allocation = ( + context.session.query(vni_models.UnderstackRouterVNIAllocation) + .filter_by(vni=vni) + .with_for_update() + .first() + ) + if allocation: + if allocation.router_id: + raise UnderstackVNIInUse(vni=vni, router_id=allocation.router_id) + allocation.router_id = router_id + allocation.released_at = None + else: + allocation = vni_models.UnderstackRouterVNIAllocation( + vni=vni, + router_id=router_id, + ) + context.session.add(allocation) + + context.session.flush() + return vni + + def _get_router_allocation(self, context, router_id): + return ( + context.session.query(vni_models.UnderstackRouterVNIAllocation) + .filter_by(router_id=router_id) + .with_for_update() + .first() + ) + + def _find_never_used_vni(self, context, ranges): + for start, end in ranges: + candidate = start + rows = ( + context.session.query(vni_models.UnderstackRouterVNIAllocation.vni) + .filter(vni_models.UnderstackRouterVNIAllocation.vni >= start) + .filter(vni_models.UnderstackRouterVNIAllocation.vni <= end) + .order_by(vni_models.UnderstackRouterVNIAllocation.vni) + ) + for (vni,) in rows: + if vni > candidate: + return candidate + if vni == candidate: + candidate += 1 + if candidate > end: + break + if candidate <= end: + return candidate + return None + + def _find_released_allocation(self, context, ranges): + return ( + context.session.query(vni_models.UnderstackRouterVNIAllocation) + .filter(vni_models.UnderstackRouterVNIAllocation.router_id.is_(None)) + .filter(self._range_filter(ranges)) + .order_by( + vni_models.UnderstackRouterVNIAllocation.released_at, + vni_models.UnderstackRouterVNIAllocation.vni, + ) + .with_for_update() + .first() + ) + + def _range_filter(self, ranges): + return sa.or_( + *[ + vni_models.UnderstackRouterVNIAllocation.vni.between(start, end) + for start, end in ranges + ] + ) + + def _validate_vni_in_ranges(self, vni, ranges): + if not any(start <= vni <= end for start, end in ranges): + raise UnderstackVNINotInRange( + vni=vni, + ranges=format_vni_ranges(ranges), + ) diff --git a/python/neutron-understack/neutron_understack/l3_router/vrf.py b/python/neutron-understack/neutron_understack/l3_router/vrf.py index 54c0a9f1b..ff0ba6194 100644 --- a/python/neutron-understack/neutron_understack/l3_router/vrf.py +++ b/python/neutron-understack/neutron_understack/l3_router/vrf.py @@ -1,5 +1,121 @@ from neutron.services.ovn_l3.service_providers.user_defined import UserDefined +from neutron_lib import constants as n_const +from neutron_lib import exceptions as n_exc +from neutron_lib.callbacks import events +from neutron_lib.callbacks import priority_group +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from neutron_lib.db import resource_extend + +try: + from neutron_lib.api.definitions import evpn as evpn_apidef +except ImportError: + evpn_apidef = None +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory +from neutron_lib.services import base as service_base +from oslo_config import cfg +from oslo_log import log as logging + +from neutron_understack import config +from neutron_understack.api.definitions import understack_vni as apidef +from neutron_understack.l3_router import understack_vni_db + +LOG = logging.getLogger(__name__) class Vrf(UserDefined): pass + + +def _vrf_provider_driver(): + return f"{Vrf.__module__}.{Vrf.__name__}" + + +def _is_vrf_router(context, router): + flavor_id = router.get("flavor_id") + if flavor_id is None or flavor_id is n_const.ATTR_NOT_SPECIFIED: + return False + + flavor_plugin = directory.get_plugin(plugin_constants.FLAVORS) + flavor = flavor_plugin.get_flavor(context, flavor_id) + provider = flavor_plugin.get_flavor_next_provider(context, flavor["id"])[0] + return str(provider["driver"]) == _vrf_provider_driver() + + +def _supported_extension_aliases(): + if evpn_apidef is not None: + return [evpn_apidef.ALIAS] + return [apidef.ALIAS] + + +@resource_extend.has_resource_extenders +@registry.has_registry_receivers +class UnderstackVniPlugin(service_base.ServicePluginBase): + supported_extension_aliases = _supported_extension_aliases() + + __native_pagination_support = True + __native_sorting_support = True + + def __init__(self): + super().__init__() + config.register_understack_vni_opts(cfg.CONF) + self._vni_db = understack_vni_db.UnderstackVniDbHelper() + LOG.info("Starting Understack VNI service plugin") + + @classmethod + def get_plugin_type(cls): + return "UNDERSTACK_VNI" + + def get_plugin_description(self): + return "Understack router VNI allocation plugin" + + @staticmethod + @resource_extend.extends([apidef.COLLECTION_NAME]) + def _extend_router_dict(router_res, router_db): + allocation = None + if hasattr(router_db, "get"): + allocation = router_db.get("understack_vni_allocation") + if allocation is None: + allocation = getattr(router_db, "understack_vni_allocation", None) + + router_res[apidef.EVPN_VNI] = ( + allocation.vni if allocation and allocation.router_id else None + ) + return router_res + + @registry.receives( + resources.ROUTER, + [events.PRECOMMIT_CREATE], + priority_group.PRIORITY_ROUTER_EXTENDED_ATTRIBUTE, + ) + def _process_router_create(self, resource, event, trigger, payload): + router = payload.latest_state + requested_vni = router.get( + apidef.EVPN_VNI, + n_const.ATTR_NOT_SPECIFIED, + ) + + if not _is_vrf_router(payload.context, router): + if not understack_vni_db.is_auto_vni(requested_vni): + raise n_exc.BadRequest( + resource=apidef.RESOURCE_NAME, + msg="evpn_vni can only be set on VRF routers", + ) + return + + vni = self._vni_db.allocate_vni_for_router( + payload.context, + payload.resource_id, + requested_vni, + ) + router[apidef.EVPN_VNI] = vni + LOG.info( + "Allocated Understack VNI %s for VRF router %s", + vni, + payload.resource_id, + ) + + @registry.receives(resources.ROUTER, [events.PRECOMMIT_DELETE]) + def _process_router_delete(self, resource, event, trigger, payload): + self._vni_db.release_vni_for_router(payload.context, payload.resource_id) diff --git a/python/neutron-understack/neutron_understack/tests/conftest.py b/python/neutron-understack/neutron_understack/tests/conftest.py index c96aca047..cfb5b4256 100644 --- a/python/neutron-understack/neutron_understack/tests/conftest.py +++ b/python/neutron-understack/neutron_understack/tests/conftest.py @@ -345,6 +345,7 @@ def oslo_config(): conf_fixture.setUp() # register the ml2_understack options understack_config.register_ml2_understack_opts(conf_fixture.conf) + understack_config.register_understack_vni_opts(conf_fixture.conf) yield conf_fixture conf_fixture.cleanUp() diff --git a/python/neutron-understack/neutron_understack/tests/test_understack_vni.py b/python/neutron-understack/neutron_understack/tests/test_understack_vni.py new file mode 100644 index 000000000..e7c773952 --- /dev/null +++ b/python/neutron-understack/neutron_understack/tests/test_understack_vni.py @@ -0,0 +1,147 @@ +from types import SimpleNamespace + +import pytest +import sqlalchemy as sa +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from neutron_understack.api.definitions import understack_vni as apidef +from neutron_understack.l3_router import understack_vni_db +from neutron_understack.l3_router import vrf + + +class FakeFlavorPlugin: + def __init__(self, driver): + self.driver = driver + + def get_flavor(self, _context, flavor_id): + return {"id": flavor_id} + + def get_flavor_next_provider(self, _context, _flavor_id): + return [{"driver": self.driver}] + + +@pytest.fixture +def db_context(): + engine = create_engine("sqlite:///:memory:") + session = sessionmaker(bind=engine)() + session.execute( + sa.text( + """ + CREATE TABLE understack_router_vni_allocations ( + vni INTEGER NOT NULL PRIMARY KEY, + router_id VARCHAR(36) NULL UNIQUE, + allocated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + released_at DATETIME NULL + ) + """ + ) + ) + session.commit() + yield SimpleNamespace(session=session) + session.close() + engine.dispose() + + +def test_parse_vni_ranges_merges_overlapping_ranges(): + assert understack_vni_db.parse_vni_ranges(["200:202", "100", "101:103"]) == [ + (100, 103), + (200, 202), + ] + + +def test_parse_vni_ranges_rejects_invalid_range(): + with pytest.raises(understack_vni_db.UnderstackVNIInvalidRange): + understack_vni_db.parse_vni_ranges(["200:100"]) + + +def test_auto_allocation_uses_never_used_vnis_before_released_vnis(db_context): + helper = understack_vni_db.UnderstackVniDbHelper(vni_ranges=["100:102"]) + + assert helper.allocate_vni_for_router(db_context, "router-1", None) == 100 + assert helper.allocate_vni_for_router(db_context, "router-2", 0) == 101 + + helper.release_vni_for_router(db_context, "router-1") + + assert helper.allocate_vni_for_router(db_context, "router-3", 0) == 102 + assert helper.allocate_vni_for_router(db_context, "router-4", 0) == 100 + + +def test_specific_allocation_can_reuse_released_vni(db_context): + helper = understack_vni_db.UnderstackVniDbHelper(vni_ranges=["100:101"]) + + assert helper.allocate_vni_for_router(db_context, "router-1", 100) == 100 + helper.release_vni_for_router(db_context, "router-1") + + assert helper.allocate_vni_for_router(db_context, "router-2", 100) == 100 + + +def test_specific_allocation_rejects_active_vni(db_context): + helper = understack_vni_db.UnderstackVniDbHelper(vni_ranges=["100:101"]) + + helper.allocate_vni_for_router(db_context, "router-1", 100) + + with pytest.raises(understack_vni_db.UnderstackVNIInUse): + helper.allocate_vni_for_router(db_context, "router-2", 100) + + +def test_auto_allocation_reports_exhaustion(db_context): + helper = understack_vni_db.UnderstackVniDbHelper(vni_ranges=["100"]) + + helper.allocate_vni_for_router(db_context, "router-1", 0) + + with pytest.raises(understack_vni_db.UnderstackVNINoAvailable): + helper.allocate_vni_for_router(db_context, "router-2", 0) + + +def test_vrf_router_create_allocates_vni(mocker): + mocker.patch.object( + vrf.directory, + "get_plugin", + return_value=FakeFlavorPlugin(vrf._vrf_provider_driver()), + ) + plugin = vrf.UnderstackVniPlugin.__new__(vrf.UnderstackVniPlugin) + plugin._vni_db = mocker.Mock() + plugin._vni_db.allocate_vni_for_router.return_value = 500 + payload = SimpleNamespace( + context="context", + resource_id="router-1", + latest_state={ + "id": "router-1", + "flavor_id": "flavor-1", + apidef.EVPN_VNI: 0, + }, + ) + + plugin._process_router_create(None, None, None, payload) + + plugin._vni_db.allocate_vni_for_router.assert_called_once_with( + "context", + "router-1", + 0, + ) + assert payload.latest_state[apidef.EVPN_VNI] == 500 + + +def test_non_vrf_router_create_rejects_explicit_vni(mocker): + mocker.patch.object( + vrf.directory, + "get_plugin", + return_value=FakeFlavorPlugin("neutron_understack.l3_router.svi.Svi"), + ) + plugin = vrf.UnderstackVniPlugin.__new__(vrf.UnderstackVniPlugin) + plugin._vni_db = mocker.Mock() + payload = SimpleNamespace( + context="context", + resource_id="router-1", + latest_state={ + "id": "router-1", + "flavor_id": "flavor-1", + apidef.EVPN_VNI: 500, + }, + ) + + with pytest.raises(vrf.n_exc.BadRequest): + plugin._process_router_create(None, None, None, payload) + + plugin._vni_db.allocate_vni_for_router.assert_not_called() diff --git a/python/neutron-understack/pyproject.toml b/python/neutron-understack/pyproject.toml index 9fa945cc6..953bf2772 100644 --- a/python/neutron-understack/pyproject.toml +++ b/python/neutron-understack/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "requests>=2,<3", - "neutron-lib>=3,<4", + "neutron-lib>=3,<5", "neutron>=27,<29", "oslo.config>=9.7.1", "oslo.log>=7.1.0", @@ -37,6 +37,7 @@ dependencies = [ ironic = "neutron_understack.config:list_ironic_opts" understack = "neutron_understack.config:list_understack_opts" cisco-asa = "neutron_understack.config:list_cisco_asa_opts" +understack-vni = "neutron_understack.config:list_understack_vni_opts" [project.entry-points."neutron.ml2.mechanism_drivers"] understack = "neutron_understack.neutron_understack_mech:UnderstackDriver" @@ -44,6 +45,16 @@ undersync = "neutron_understack.undersync_mech:UndersyncDriver" [project.entry-points."neutron.service_plugins"] l3_service_cisco_asa = "neutron_understack.l3_service_cisco_asa:CiscoAsa" +understack_vni = "neutron_understack.l3_router.vrf:UnderstackVniPlugin" + +[project.entry-points."oslo.policy.policies"] +neutron-understack = "neutron_understack.conf.policies:list_rules" + +[project.entry-points."neutron.policies"] +neutron-understack = "neutron_understack.conf.policies:list_rules" + +[project.entry-points."neutron.db.alembic_migrations"] +neutron-understack = "neutron_understack.db.migration:alembic_migrations" [project.urls] Source = "https://github.com/rackerlabs/understack"