Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions python/neutron-understack/neutron_understack/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__version__ = "0.1"

# Neutron's extension loader looks for ``<service_plugin_root>.extensions`` as
# an attribute on the imported top-level package.
from neutron_understack import extensions # noqa: F401
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should that be 0 or ATTR_NOT_SPECIFIED ? I am concerned if having it as 0 will not prevent non-admin users from creating routers

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit hazy on the logic for collections, but I think we want non-admin users to create routers, we just don't want them to be able to choose a VNI.

A value of zero is explicitly interpreted as "please auto-assign" in the assignment hook, so the value that actually gets persisted should be non-zero.

Technically it may be more correct to use the sentinel value but I thought this was going to the DB layer, I'm not sure what the best answer is.

@skrobul skrobul Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The python/neutron-understack/neutron_understack/conf/policies/evpn.py has:

   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,
...

which will translate to "create_router:evpn_vni": "rule:admin_only" inside a default policy (similarly to stock one and if the user provides any value of the attribute in the request, it will match the rule, effectively requiring admin privileges, so we may need to adjust base.ADMIN to sth else.

"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 = {}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import itertools

from neutron_understack.conf.policies import evpn


def list_rules():
return itertools.chain(
evpn.list_rules(),
)
34 changes: 34 additions & 0 deletions python/neutron-understack/neutron_understack/conf/policies/evpn.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions python/neutron-understack/neutron_understack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,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(
Expand Down Expand Up @@ -99,6 +100,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 [
Expand All @@ -125,6 +139,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)

Expand All @@ -139,6 +159,10 @@ 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):
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)
Expand Down
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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",
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
8f5b7d0f4b2b
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
5b7f7d0f4b2a
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions python/neutron-understack/neutron_understack/db/understack_vni.py
Original file line number Diff line number Diff line change
@@ -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",)
Empty file.
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading