6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
307 lines
9.6 KiB
Python
307 lines
9.6 KiB
Python
"""Rewrite incoming requests onto /_os/<service>/… based on gateway port/header.
|
|
|
|
All OpenStack service routers are mounted under /_os/<service> so that
|
|
overlapping paths (/v3 for Keystone vs Cinder, /v1 for Heat vs Swift, …)
|
|
do not collide inside a single FastAPI process.
|
|
|
|
When the browser UI is served from the Keystone port (5000), relative fetches
|
|
like ``/v2.1/servers`` still arrive with ``X-OpenStack-Service: keystone``.
|
|
In that case we re-resolve the target service from the URL path (or from
|
|
``X-OpenStack-Route-Service`` set by the console).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
from app.openstack.surface import SERVICES
|
|
|
|
_PORT_TO_SERVICE = {spec.port: spec.name for spec in SERVICES}
|
|
|
|
_SKIP_PREFIXES = (
|
|
"/_os/",
|
|
"/api2",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi",
|
|
"/health",
|
|
"/metrics",
|
|
"/static",
|
|
"/ui",
|
|
"/favicon",
|
|
"/assets",
|
|
"/console",
|
|
)
|
|
|
|
_AMBIGUOUS_SERVICES = frozenset({"", "keystone", "horizon", "simulator", "https"})
|
|
|
|
_UUID_RE = re.compile(
|
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
)
|
|
|
|
_KEYSTONE_V3_ROOTS = frozenset(
|
|
{
|
|
"auth",
|
|
"users",
|
|
"groups",
|
|
"projects",
|
|
"domains",
|
|
"roles",
|
|
"regions",
|
|
"services",
|
|
"endpoints",
|
|
"credentials",
|
|
"policies",
|
|
"role_assignments",
|
|
"OS-INHERIT",
|
|
"OS-FEDERATION",
|
|
"OS-TRUST",
|
|
"OS-EP-FILTER",
|
|
"OS-OAUTH1",
|
|
"OS-SIMPLE-CERT",
|
|
"OS-EC2",
|
|
"application_credentials",
|
|
"system",
|
|
"limits",
|
|
"registered_limits",
|
|
"project_tags",
|
|
}
|
|
)
|
|
|
|
_CINDER_V3_ROOTS = frozenset(
|
|
{
|
|
"volumes",
|
|
"snapshots",
|
|
"backups",
|
|
"types",
|
|
"qos-specs",
|
|
"groups",
|
|
"group_snapshots",
|
|
"consistencygroups",
|
|
"attachments",
|
|
"volume-transfers",
|
|
"os-services",
|
|
"os-quota-sets",
|
|
"clusters",
|
|
"messages",
|
|
"resource_filters",
|
|
"scheduler-stats",
|
|
}
|
|
)
|
|
|
|
_GLANCE_V2_ROOTS = frozenset({"images", "schemas", "metadefs", "tasks", "info"})
|
|
_MANILA_V2_ROOTS = frozenset(
|
|
{
|
|
"shares",
|
|
"snapshots",
|
|
"share-networks",
|
|
"share-servers",
|
|
"share-groups",
|
|
"security-services",
|
|
"types",
|
|
"share-replicas",
|
|
}
|
|
)
|
|
_DESIGNATE_V2_ROOTS = frozenset(
|
|
{"zones", "tlds", "blacklists", "pools", "service_statuses", "tsigkeys", "reverse"}
|
|
)
|
|
|
|
|
|
def resolve_service_from_path(path: str) -> str | None:
|
|
"""Map an absolute OpenStack API path to a service name."""
|
|
|
|
p = (path or "/").split("?", 1)[0]
|
|
if not p.startswith("/"):
|
|
p = f"/{p}"
|
|
|
|
if p.startswith("/v2.1"):
|
|
return "nova"
|
|
if p.startswith("/v2.0"):
|
|
return "neutron"
|
|
|
|
if p.startswith("/resource_providers") or p.startswith("/resource_classes"):
|
|
return "placement"
|
|
if p.startswith("/allocation_candidates") or p.startswith("/allocations"):
|
|
return "placement"
|
|
if p.startswith("/traits") or p.startswith("/usages"):
|
|
return "placement"
|
|
|
|
if p.startswith("/v2/lbaas") or p.startswith("/v2/octavia"):
|
|
return "octavia"
|
|
|
|
if p.startswith("/v2/"):
|
|
root = p.split("/", 3)[2] if p.count("/") >= 2 else ""
|
|
if root in _GLANCE_V2_ROOTS:
|
|
return "glance"
|
|
if root in _MANILA_V2_ROOTS:
|
|
return "manila"
|
|
if root in _DESIGNATE_V2_ROOTS:
|
|
return "designate"
|
|
# Default glance for bare /v2/
|
|
return "glance"
|
|
|
|
if p.startswith("/v3"):
|
|
parts = [seg for seg in p.split("/") if seg]
|
|
if len(parts) == 1:
|
|
return "keystone"
|
|
root = parts[1]
|
|
if root in _KEYSTONE_V3_ROOTS or root.startswith("OS-"):
|
|
return "keystone"
|
|
if root in _CINDER_V3_ROOTS:
|
|
return "cinder"
|
|
# /v3/{project_id}/volumes|…
|
|
if _UUID_RE.match(root) and len(parts) >= 3 and parts[2] in _CINDER_V3_ROOTS | {"limits"}:
|
|
return "cinder"
|
|
return "keystone"
|
|
|
|
if p.startswith("/info") or p.startswith("/v1/AUTH_"):
|
|
return "swift"
|
|
if p == "/stacks" or p.startswith("/stacks"):
|
|
return "heat-cfn"
|
|
|
|
if p.startswith("/v1/"):
|
|
parts = [seg for seg in p.split("/") if seg]
|
|
root = parts[1] if len(parts) > 1 else ""
|
|
if root in {
|
|
"nodes",
|
|
"drivers",
|
|
"chassis",
|
|
"portgroups",
|
|
"conductors",
|
|
"allocations",
|
|
"deploy_templates",
|
|
}:
|
|
return "ironic"
|
|
if root in {"ports", "volume"} and (
|
|
len(parts) > 2 or root == "volume" or "portgroups" in p
|
|
):
|
|
# /v1/ports is ironic; avoid stealing neutron
|
|
return "ironic"
|
|
if root in {"secrets", "containers", "orders", "secret-stores"}:
|
|
return "barbican"
|
|
if root in {"clusters", "clustertemplates", "certificates", "mservices"}:
|
|
return "magnum"
|
|
if root in {"containers", "services", "hosts", "capsules"} and "magnum" not in root:
|
|
if root == "containers":
|
|
return "zun"
|
|
if root in {"instances", "datastores", "configurations", "backups"}:
|
|
return "trove"
|
|
if root in {"jobs", "clients", "actions", "sessions"}:
|
|
return "freezer"
|
|
if (
|
|
"stacks" in parts
|
|
or "software_configs" in parts
|
|
or "software_deployments" in parts
|
|
or "resource_types" in parts
|
|
):
|
|
return "heat"
|
|
if root.startswith("AUTH_") or (len(parts) >= 2 and parts[1].startswith("AUTH_")):
|
|
return "swift"
|
|
# Heat style /v1/{tenant}/stacks
|
|
if len(parts) >= 3 and parts[2] == "stacks":
|
|
return "heat"
|
|
if root in {"workflows", "actions", "executions", "workbooks", "cron_triggers"}:
|
|
return "mistral"
|
|
if root in {"alarms", "alarm"}:
|
|
return "aodh"
|
|
if root in {"leases", "hosts", "floatingips"}:
|
|
return "blazar"
|
|
if root in {"segments", "notifications", "hosts"}:
|
|
return "masakari"
|
|
if root in {"vnfs", "vnffgs", "vim", "nsds"}:
|
|
return "tacker"
|
|
if root in {"tasks", "tokens", "status"}:
|
|
return "adjutant"
|
|
if root in {"rating", "collect", "storage", "info"}:
|
|
return "cloudkitty"
|
|
|
|
if p.startswith("/v2/alarms") or p.startswith("/v2/query"):
|
|
return "aodh"
|
|
if p.startswith("/v2/workflows") or p.startswith("/v2/executions"):
|
|
return "mistral"
|
|
if p.startswith("/v1.0/"):
|
|
return "trove"
|
|
if p.startswith("/leases"):
|
|
return "blazar"
|
|
|
|
return None
|
|
|
|
|
|
def resolve_service(headers: dict[str, str], path: str | None = None) -> str | None:
|
|
path_service = resolve_service_from_path(path or "") if path else None
|
|
|
|
# Identity auth must never be stolen by a stale UI route-service header.
|
|
if path and (path.startswith("/v3/auth") or path == "/v3" or path == "/v3/"):
|
|
return "keystone"
|
|
|
|
route = (headers.get("x-openstack-route-service") or "").lower().strip()
|
|
if route and route not in _AMBIGUOUS_SERVICES:
|
|
return route
|
|
|
|
header = (headers.get("x-openstack-service") or "").lower().strip()
|
|
port_raw = headers.get("x-forwarded-port") or ""
|
|
try:
|
|
port_service = _PORT_TO_SERVICE.get(int(port_raw))
|
|
except ValueError:
|
|
port_service = None
|
|
|
|
# Dedicated service ports win when path is empty or matches.
|
|
if header and header not in _AMBIGUOUS_SERVICES:
|
|
if path_service and path_service != header and header == "keystone":
|
|
return path_service
|
|
return header
|
|
|
|
if port_service and port_service not in _AMBIGUOUS_SERVICES:
|
|
if path_service and path_service != port_service and port_service == "keystone":
|
|
return path_service
|
|
return port_service
|
|
|
|
return path_service or header or port_service
|
|
|
|
|
|
class ServiceDispatchMiddleware:
|
|
"""Pure ASGI middleware — rewrites scope['path'] before the app sees it."""
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
self.app = app
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] == "http":
|
|
path = scope.get("path") or "/"
|
|
if path == "/" or any(path.startswith(p) for p in _SKIP_PREFIXES):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
headers = {
|
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
|
for k, v in scope.get("headers") or []
|
|
}
|
|
service = resolve_service(headers, path)
|
|
if service:
|
|
scope = dict(scope)
|
|
scope["path"] = f"/_os/{service}{path}"
|
|
scope["root_path"] = scope.get("root_path") or ""
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
class ServiceStateMiddleware(BaseHTTPMiddleware):
|
|
"""Expose resolved service name on request.state for handlers/microversions."""
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
path = request.url.path or "/"
|
|
if path.startswith("/_os/"):
|
|
parts = path.split("/", 3)
|
|
service = parts[2] if len(parts) > 2 else None
|
|
else:
|
|
service = resolve_service(
|
|
{k.lower(): v for k, v in request.headers.items()},
|
|
path,
|
|
)
|
|
if service:
|
|
request.state.openstack_service = service
|
|
return await call_next(request)
|