Skip to content

Commit 279c334

Browse files
Add portgroup name validation middleware for undersync compatibility
Strategy: Minimal patch to Ironic's app.py that imports middleware. The patch adds 6 lines before `return app` in setup_app() - if upstream changes break this, the build fails immediately rather than silently. All validation logic (`{node_name}-port-channel{number} where number is between 100-998`) lives in ironic-understack package, making it easy to update without touching the patch.
1 parent a44fd86 commit 279c334

6 files changed

Lines changed: 392 additions & 2 deletions

containers/ironic/Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
3333

3434
COPY containers/ironic/patches /tmp/patches/
3535
RUN cd /var/lib/openstack/lib/python3.12/site-packages && \
36-
patch -p1 < /tmp/patches/0001-Solve-IPMI-call-issue-results-in-UTF-8-format-error-.patch
36+
patch -p1 < /tmp/patches/0001-Add-portgroup-name-validation-middleware-2025.2.patch
3737
RUN cd /var/lib/openstack/lib/python3.12/site-packages && \
38-
patch -p1 < /tmp/patches/0002-Handle-missing-storage-controller-mode-attribute.patch
38+
patch -p1 < /tmp/patches/0002-Solve-IPMI-call-issue-results-in-UTF-8-format-error-.patch
39+
RUN cd /var/lib/openstack/lib/python3.12/site-packages && \
40+
patch -p1 < /tmp/patches/0003-Handle-missing-storage-controller-mode-attribute.patch
3941

4042
# download and unpack novnc
4143
RUN \
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
2+
From: Syed <syed8048@users.noreply.github.com>
3+
Date: Thu, 6 Feb 2026 00:00:00 +0000
4+
Subject: [PATCH] Add portgroup name validation middleware for undersync
5+
6+
Add portgroup name validation middleware directly in setup_app.
7+
Validates names match format: {node_name}-port-channel{number}
8+
---
9+
ironic/api/app.py | 6 ++++++
10+
1 file changed, 6 insertions(+)
11+
12+
diff --git a/ironic/api/app.py b/ironic/api/app.py
13+
--- a/ironic/api/app.py
14+
+++ b/ironic/api/app.py
15+
@@ -135,6 +135,12 @@ def setup_app(pecan_config=None, extra_hooks=None):
16+
# Add request logging middleware
17+
app = request_log.RequestLogMiddleware(app)
18+
19+
+ # Add understack portgroup name validation middleware
20+
+ from ironic_understack.portgroup_name_middleware import (
21+
+ PortgroupNameValidationMiddleware,
22+
+ )
23+
+ app = PortgroupNameValidationMiddleware(app)
24+
+
25+
return app
26+
27+
28+
--
29+
2.25.1

containers/ironic/patches/0001-Solve-IPMI-call-issue-results-in-UTF-8-format-error-.patch renamed to containers/ironic/patches/0002-Solve-IPMI-call-issue-results-in-UTF-8-format-error-.patch

File renamed without changes.

containers/ironic/patches/0002-Handle-missing-storage-controller-mode-attribute.patch renamed to containers/ironic/patches/0003-Handle-missing-storage-controller-mode-attribute.patch

File renamed without changes.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
r"""Middleware to validate portgroup names for undersync compatibility.
2+
3+
This middleware intercepts portgroup create/update requests and validates
4+
that names follow the required format: {node_name}-port-channel{number}
5+
6+
This is required for undersync to extract port-channel numbers via regex:
7+
name.to_s[/port-channel(\d+)$/, 1]
8+
"""
9+
10+
import json
11+
import re
12+
13+
import webob
14+
import webob.dec
15+
import webob.exc
16+
from oslo_log import log as logging
17+
18+
LOG = logging.getLogger(__name__)
19+
20+
# Pattern: {anything}-port-channel{number} - captures the number
21+
PORTGROUP_NAME_PATTERN = re.compile(r"^.+-port-channel(\d+)$")
22+
23+
# Valid port-channel number range (inclusive)
24+
PORT_CHANNEL_MIN = 100
25+
PORT_CHANNEL_MAX = 998
26+
27+
28+
def validate_portgroup_name(name):
29+
"""Validate portgroup name follows the required format.
30+
31+
:param name: The portgroup name to validate.
32+
:returns: tuple (is_valid, error_message)
33+
"""
34+
if not name:
35+
return False, "Portgroup name is required and cannot be empty."
36+
37+
match = PORTGROUP_NAME_PATTERN.match(name)
38+
if not match:
39+
return False, (
40+
f"Portgroup name '{name}' must match format "
41+
"'{node_name}-port-channel{{number}}' "
42+
"(e.g., 'server01-port-channel100')"
43+
)
44+
45+
port_channel_num = int(match.group(1))
46+
if not (PORT_CHANNEL_MIN <= port_channel_num <= PORT_CHANNEL_MAX):
47+
return False, (
48+
f"Portgroup name '{name}' has invalid port-channel number "
49+
f"{port_channel_num}. Must be between {PORT_CHANNEL_MIN} "
50+
f"and {PORT_CHANNEL_MAX} (inclusive)."
51+
)
52+
53+
return True, None
54+
55+
56+
class PortgroupNameValidationMiddleware:
57+
"""WSGI middleware that validates portgroup names.
58+
59+
Intercepts POST /v1/portgroups and PATCH /v1/portgroups/{id} requests
60+
to validate that portgroup names match the required format.
61+
"""
62+
63+
def __init__(self, app):
64+
self.app = app
65+
66+
@webob.dec.wsgify
67+
def __call__(self, req):
68+
# Only check portgroup endpoints
69+
if not self._is_portgroup_request(req):
70+
return req.get_response(self.app)
71+
72+
# Check POST (create) requests
73+
if req.method == "POST" and req.path_info.rstrip("/") == "/v1/portgroups":
74+
return self._validate_create(req)
75+
76+
# Check PATCH (update) requests
77+
if req.method == "PATCH" and self._is_portgroup_patch(req.path_info):
78+
return self._validate_patch(req)
79+
80+
return req.get_response(self.app)
81+
82+
def _is_portgroup_request(self, req):
83+
"""Check if this is a portgroup-related request."""
84+
return "/portgroups" in req.path_info
85+
86+
def _is_portgroup_patch(self, path):
87+
"""Check if this is a PATCH to a specific portgroup."""
88+
# Matches /v1/portgroups/{uuid_or_name}
89+
pattern = r"^/v1/portgroups/[^/]+$"
90+
return bool(re.match(pattern, path.rstrip("/")))
91+
92+
def _validate_create(self, req):
93+
"""Validate portgroup name on create."""
94+
try:
95+
body = json.loads(req.body)
96+
except (json.JSONDecodeError, ValueError):
97+
# Let Ironic handle malformed JSON
98+
return req.get_response(self.app)
99+
100+
name = body.get("name")
101+
is_valid, error_msg = validate_portgroup_name(name)
102+
if not is_valid:
103+
LOG.warning("Rejecting portgroup creation with invalid name: %s", name)
104+
return self._error_response(req, error_msg)
105+
106+
return req.get_response(self.app)
107+
108+
def _validate_patch(self, req):
109+
"""Validate portgroup name on update."""
110+
try:
111+
patch = json.loads(req.body)
112+
except (json.JSONDecodeError, ValueError):
113+
# Let Ironic handle malformed JSON
114+
return req.get_response(self.app)
115+
116+
# Look for name changes in the patch
117+
for op in patch:
118+
if op.get("path") == "/name" and op.get("op") in ("add", "replace"):
119+
name = op.get("value")
120+
is_valid, error_msg = validate_portgroup_name(name)
121+
if not is_valid:
122+
LOG.warning(
123+
"Rejecting portgroup update with invalid name: %s", name
124+
)
125+
return self._error_response(req, error_msg)
126+
127+
return req.get_response(self.app)
128+
129+
def _error_response(self, req, message):
130+
"""Return a 400 Bad Request response."""
131+
error_body = {
132+
"error_message": json.dumps(
133+
{"faultstring": message, "faultcode": "Client", "debuginfo": None}
134+
)
135+
}
136+
return webob.Response(
137+
status=400,
138+
content_type="application/json",
139+
body=json.dumps(error_body).encode("utf-8"),
140+
)
141+
142+
143+
def factory(global_conf, **local_conf):
144+
"""Paste deploy factory function."""
145+
146+
def filter(app):
147+
return PortgroupNameValidationMiddleware(app)
148+
149+
return filter

0 commit comments

Comments
 (0)