Initial commit: stateful OpenStack API laboratory simulator.

Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm
packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""OpenStack API surfaces (Keystone, Nova, Neutron, Glance, Cinder)."""
+186
View File
@@ -0,0 +1,186 @@
"""Keystone token issue / validation helpers."""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from asyncpg import Connection
from app.openstack.catalog import build_catalog_from_db
from app.openstack.errors import OpenStackError
from app.security.auth import verify_secret
@dataclass(frozen=True, slots=True)
class TokenContext:
token_id: str
user_id: UUID
user_name: str
project_id: UUID | None
project_name: str | None
roles: tuple[str, ...]
expires_at: datetime
is_admin: bool
async def issue_token(
conn: Connection,
*,
user_name: str,
password: str,
project_name: str | None,
domain_name: str = "Default",
host: str = "localhost",
scheme: str = "http",
ttl_seconds: int = 3600,
) -> tuple[str, dict[str, Any]]:
domain = await conn.fetchrow(
"SELECT id, name FROM os_domains WHERE name = $1 AND enabled", domain_name
)
if domain is None:
raise OpenStackError("Unauthorized", "Invalid user credentials", status_code=401)
user = await conn.fetchrow(
"""SELECT id, name, password_hash, enabled
FROM os_users WHERE domain_id = $1 AND name = $2""",
domain["id"],
user_name,
)
if user is None or not user["enabled"] or not verify_secret(password, user["password_hash"]):
raise OpenStackError(
"Unauthorized", "The request you have made requires authentication.", status_code=401
)
project = None
if project_name:
project = await conn.fetchrow(
"""SELECT id, name, enabled FROM os_projects
WHERE domain_id = $1 AND name = $2""",
domain["id"],
project_name,
)
if project is None or not project["enabled"]:
raise OpenStackError("Unauthorized", "Project not found or disabled", status_code=401)
assignment = await conn.fetchval(
"""SELECT 1 FROM os_role_assignments
WHERE user_id = $1 AND project_id = $2 LIMIT 1""",
user["id"],
project["id"],
)
if assignment is None:
raise OpenStackError("Forbidden", "User is not authorized for project", status_code=403)
roles_rows = []
if project is not None:
roles_rows = await conn.fetch(
"""SELECT r.name FROM os_role_assignments a
JOIN os_roles r ON r.id = a.role_id
WHERE a.user_id = $1 AND a.project_id = $2""",
user["id"],
project["id"],
)
role_names = [str(row["name"]) for row in roles_rows]
token_id = secrets.token_hex(16)
now = datetime.now(UTC)
expires = now + timedelta(seconds=ttl_seconds)
await conn.execute(
"""INSERT INTO os_tokens(id, user_id, project_id, expires_at, issued_at, revoked)
VALUES($1, $2, $3, $4, $5, false)""",
token_id,
user["id"],
project["id"] if project else None,
expires,
now,
)
catalog = await build_catalog_from_db(conn, host, scheme=scheme) if project is not None else []
body = {
"token": {
# Lab convenience: token id also in body so browser UIs need not rely on
# Access-Control-Expose-Headers for X-Subject-Token.
"id": token_id,
"methods": ["password"],
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"issued_at": now.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"user": {
"id": str(user["id"]),
"name": user["name"],
"domain": {"id": str(domain["id"]), "name": domain["name"]},
},
"audit_ids": [secrets.token_urlsafe(8)],
"roles": [{"id": name, "name": name} for name in role_names],
}
}
if project is not None:
body["token"]["project"] = {
"id": str(project["id"]),
"name": project["name"],
"domain": {"id": str(domain["id"]), "name": domain["name"]},
}
body["token"]["catalog"] = catalog
return token_id, body
async def validate_token(conn: Connection, token_id: str) -> TokenContext:
if not token_id:
raise OpenStackError(
"Unauthorized",
"The request you have made requires authentication.",
status_code=401,
)
row = await conn.fetchrow(
"""SELECT t.id, t.user_id, t.project_id, t.expires_at, t.revoked,
u.name AS user_name, p.name AS project_name
FROM os_tokens t
JOIN os_users u ON u.id = t.user_id
LEFT JOIN os_projects p ON p.id = t.project_id
WHERE t.id = $1""",
token_id,
)
if row is None or row["revoked"]:
raise OpenStackError("Unauthorized", "Invalid token", status_code=401)
expires = row["expires_at"]
if expires.tzinfo is None:
expires = expires.replace(tzinfo=UTC)
if expires <= datetime.now(UTC):
raise OpenStackError("Unauthorized", "Token has expired", status_code=401)
roles: list[str] = []
if row["project_id"] is not None:
roles = [
str(r["name"])
for r in await conn.fetch(
"""SELECT r.name FROM os_role_assignments a
JOIN os_roles r ON r.id = a.role_id
WHERE a.user_id = $1 AND a.project_id = $2""",
row["user_id"],
row["project_id"],
)
]
is_admin = "admin" in roles
return TokenContext(
token_id=str(row["id"]),
user_id=row["user_id"],
user_name=str(row["user_name"]),
project_id=row["project_id"],
project_name=str(row["project_name"]) if row["project_name"] else None,
roles=tuple(roles),
expires_at=expires,
is_admin=is_admin,
)
def extract_token(headers: dict[str, str]) -> str | None:
# Case-insensitive lookup
lower = {k.lower(): v for k, v in headers.items()}
if "x-auth-token" in lower:
return lower["x-auth-token"]
auth = lower.get("authorization", "")
if auth.lower().startswith("bearer "):
return auth.split(" ", 1)[1].strip()
return None
+49
View File
@@ -0,0 +1,49 @@
"""Service catalog builders — loaded from PostgreSQL discovery seed."""
from __future__ import annotations
import json
from typing import Any
from asyncpg import Connection
from app.openstack.db_docs import require_doc
from app.openstack.errors import OpenStackError
from app.openstack.surface import catalog_entries
def public_base(host: str, port: int, *, scheme: str = "http") -> str:
host = host.split("%")[0]
return f"{scheme}://{host}:{port}"
def build_catalog(host: str, *, scheme: str = "http") -> list[dict[str, Any]]:
"""Sync helper for offline tests/tools (no DB). Runtime catalog uses DB only."""
return catalog_entries(host, scheme=scheme)
async def build_catalog_from_db(
conn: Connection,
host: str,
*,
scheme: str = "http",
) -> list[dict[str, Any]]:
"""Render Keystone catalog from the seeded DB template."""
doc = await require_doc(
conn,
service="keystone",
resource_type="service_catalog_template",
name="default",
)
catalog = doc.get("catalog") or doc.get("services") or []
rendered = json.dumps(catalog).replace("__HOST__", host).replace("__SCHEME__", scheme)
data = json.loads(rendered)
if not isinstance(data, list):
raise OpenStackError(
"NotFound",
"keystone/service_catalog_template/default has invalid catalog shape",
status_code=404,
)
return data
+204
View File
@@ -0,0 +1,204 @@
"""Load and hot-swap OpenStack series contract packs from contracts/openstack/."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.openstack.opspec import OperationSpec, SeriesManifest, ServicePack
_CONTRACTS_ROOT = Path(__file__).resolve().parents[2] / "contracts" / "openstack"
_SERIES_MAJOR = {
"yoga": 6,
"antelope": 7,
"caracal": 8,
"dalmatian": 9,
}
_MAJOR_SERIES = {v: k for k, v in _SERIES_MAJOR.items()}
def contracts_root() -> Path:
return _CONTRACTS_ROOT
def series_for_major(major: int) -> str:
return _MAJOR_SERIES.get(major, "dalmatian")
def major_for_series(series: str) -> int:
return _SERIES_MAJOR.get(series.lower(), 9)
def list_series() -> list[dict[str, Any]]:
root = contracts_root()
if not root.exists():
return []
result: list[dict[str, Any]] = []
for path in sorted(root.iterdir()):
man = path / "manifest.json"
if not man.is_file():
continue
data = json.loads(man.read_text())
result.append(
{
"series": data.get("series", path.name),
"major": data.get("major", major_for_series(path.name)),
"operation_count": data.get("operation_count", 0),
"service_count": data.get("service_count", 0),
"checksum": data.get("checksum", ""),
"generated_at": data.get("generated_at", ""),
}
)
return result
def _op_from_dict(service: str, raw: dict[str, Any]) -> OperationSpec:
return OperationSpec(
operation_id=str(raw["operation_id"]),
method=raw["method"], # type: ignore[arg-type]
path=str(raw["path"]),
service=service,
resource_type=str(raw.get("resource_type") or "object"),
collection_key=raw.get("collection_key"),
item_key=raw.get("item_key"),
kind=raw.get("kind") or "collection", # type: ignore[arg-type]
status_code=int(raw.get("status_code") or 200),
create_status=int(raw.get("create_status") or raw.get("status_code") or 201),
microversion_min=raw.get("microversion_min"),
microversion_max=raw.get("microversion_max"),
requires_auth=bool(raw.get("requires_auth", True)),
requires_project=bool(raw.get("requires_project", True)),
action_name=raw.get("action_name"),
response_fixture=raw.get("response_fixture"),
notes=str(raw.get("notes") or ""),
)
def load_series_pack(series: str) -> dict[str, ServicePack]:
series = series.lower()
series_dir = contracts_root() / series
man_path = series_dir / "manifest.json"
if not man_path.is_file():
raise FileNotFoundError(f"OpenStack contract pack not found: {series_dir}")
packs: dict[str, ServicePack] = {}
for svc_dir in sorted(series_dir.iterdir()):
api = svc_dir / "api.json"
if not api.is_file():
continue
data = json.loads(api.read_text())
name = str(data["service"])
ops = [_op_from_dict(name, raw) for raw in data.get("operations") or []]
packs[name] = ServicePack(
name=name,
typ=str(data.get("type") or name),
port=int(data["port"]),
version_path=str(data.get("version_path") or "/"),
default_microversion=data.get("default_microversion"),
max_microversion=data.get("max_microversion"),
operations=ops,
)
return packs
def load_manifest(series: str) -> SeriesManifest:
data = json.loads((contracts_root() / series.lower() / "manifest.json").read_text())
services = list(data.get("services") or [])
return SeriesManifest(
series=str(data["series"]),
major=int(data["major"]),
services=services,
checksum=str(data.get("checksum") or ""),
generated_at=str(data.get("generated_at") or ""),
operation_count=int(data.get("operation_count") or 0),
service_count=int(data.get("service_count") or len(services)),
)
@dataclass
class ContractRuntime:
"""Process-wide active OpenStack contract pack + per-service microversion overrides."""
series: str = "dalmatian"
packs: dict[str, ServicePack] = field(default_factory=dict)
microversion_overrides: dict[str, str] = field(default_factory=dict)
_lock: threading.RLock = field(default_factory=threading.RLock)
def reload(self, series: str | None = None) -> dict[str, Any]:
with self._lock:
target = (series or self.series).lower()
self.packs = load_series_pack(target)
self.series = target
man = load_manifest(target)
return {
"series": man.series,
"major": man.major,
"operation_count": man.operation_count,
"service_count": man.service_count,
"checksum": man.checksum,
"services": sorted(self.packs.keys()),
}
def summary(self) -> dict[str, Any]:
with self._lock:
man = None
try:
man = load_manifest(self.series)
except FileNotFoundError:
pass
return {
"series": self.series,
"major": major_for_series(self.series),
"operation_count": sum(p.operation_count() for p in self.packs.values()),
"service_count": len(self.packs),
"checksum": man.checksum if man else "",
"microversion_overrides": dict(self.microversion_overrides),
"services": [
{
"name": p.name,
"type": p.typ,
"port": p.port,
"operation_count": p.operation_count(),
"default_microversion": p.default_microversion,
"max_microversion": p.max_microversion,
"active_microversion": self.microversion_overrides.get(
p.name, p.default_microversion
),
}
for p in sorted(self.packs.values(), key=lambda x: x.name)
],
}
def set_microversion(self, service: str, version: str | None) -> None:
with self._lock:
if version is None:
self.microversion_overrides.pop(service, None)
else:
self.microversion_overrides[service] = version
def active_microversion(self, service: str) -> str | None:
with self._lock:
if service in self.microversion_overrides:
return self.microversion_overrides[service]
pack = self.packs.get(service)
return pack.default_microversion if pack else None
_RUNTIME = ContractRuntime()
def get_runtime() -> ContractRuntime:
return _RUNTIME
def ensure_loaded(series: str = "dalmatian") -> ContractRuntime:
rt = get_runtime()
if not rt.packs:
try:
rt.reload(series)
except FileNotFoundError:
pass
return rt
+105
View File
@@ -0,0 +1,105 @@
"""Read JSON documents stored in ``os_api_objects`` (discovery, schemas, catalog)."""
from __future__ import annotations
import json
from typing import Any
from asyncpg import Connection
from app.openstack.errors import OpenStackError
from app.openstack.ids import oid
async def fetch_doc(
conn: Connection,
*,
service: str,
resource_type: str,
name: str = "default",
) -> dict[str, Any] | None:
row = await conn.fetchrow(
"""SELECT data FROM os_api_objects
WHERE service=$1 AND resource_type=$2 AND name=$3
ORDER BY updated_at DESC
LIMIT 1""",
service,
resource_type,
name,
)
if row is None:
return None
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
return dict(data or {})
async def require_doc(
conn: Connection,
*,
service: str,
resource_type: str,
name: str = "default",
) -> dict[str, Any]:
doc = await fetch_doc(conn, service=service, resource_type=resource_type, name=name)
if doc is None:
raise OpenStackError(
"NotFound",
f"{service}/{resource_type}/{name} not seeded in database",
status_code=404,
)
return doc
async def list_docs(
conn: Connection,
*,
service: str,
resource_type: str,
) -> list[dict[str, Any]]:
rows = await conn.fetch(
"""SELECT id, name, status, data FROM os_api_objects
WHERE service=$1 AND resource_type=$2
ORDER BY created_at NULLS LAST, name""",
service,
resource_type,
)
items: list[dict[str, Any]] = []
for row in rows:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = dict(data or {})
data.setdefault("id", str(row["id"]))
data.setdefault("name", row["name"])
data.setdefault("status", row["status"])
items.append(data)
return items
async def upsert_doc(
conn: Connection,
*,
service: str,
resource_type: str,
name: str,
data: dict[str, Any],
project_id: Any | None = None,
status: str = "ACTIVE",
) -> None:
item_id = oid(f"doc:{service}:{resource_type}:{name}")
payload = {"id": str(item_id), "name": name, **data}
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
ON CONFLICT (id) DO UPDATE SET
data=EXCLUDED.data, status=EXCLUDED.status, updated_at=now()""",
item_id,
service,
resource_type,
project_id,
name,
status,
json.dumps(payload),
)
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
"""FastAPI dependencies for OpenStack routes."""
from __future__ import annotations
from typing import Annotated
from asyncpg import Connection, Pool
from fastapi import Depends, Request
from app.db.pool import AsyncpgDatabase
from app.dependencies import get_database
from app.openstack.auth import TokenContext, extract_token, validate_token
from app.openstack.errors import OpenStackError
async def get_pool(request: Request) -> Pool:
database = get_database(request)
if not isinstance(database, AsyncpgDatabase):
raise OpenStackError("ServiceUnavailable", "Database unavailable", status_code=503)
return database.pool
async def get_conn(pool: Annotated[Pool, Depends(get_pool)]) -> Connection:
async with pool.acquire() as connection:
yield connection
async def require_token(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
) -> TokenContext:
token_id = extract_token({k: v for k, v in request.headers.items()})
if token_id is None:
raise OpenStackError(
"Unauthorized",
"The request you have made requires authentication.",
status_code=401,
)
return await validate_token(conn, token_id)
async def require_project_token(
ctx: Annotated[TokenContext, Depends(require_token)],
) -> TokenContext:
if ctx.project_id is None:
raise OpenStackError(
"Forbidden",
"A project-scoped token is required for this action.",
status_code=403,
)
return ctx
def request_public_host(request: Request, default: str = "localhost") -> str:
forwarded = request.headers.get("x-forwarded-host") or request.headers.get("host")
if not forwarded:
return default
host = forwarded.split(",")[0].strip()
# Strip port from Host header so catalog can attach service ports.
if host.startswith("["):
# [ipv6]:port
if "]" in host:
return host[1 : host.index("]")]
return host.strip("[]")
return host.rsplit(":", 1)[0]
def request_scheme(request: Request) -> str:
return request.headers.get("x-forwarded-proto") or request.url.scheme or "http"
+306
View File
@@ -0,0 +1,306 @@
"""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)
+298
View File
@@ -0,0 +1,298 @@
"""Generic OpenStack collection/item CRUD backed by os_api_objects."""
from __future__ import annotations
import json
import re
from typing import Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token, require_token
from app.openstack.errors import OpenStackError
from app.openstack.surface import SERVICES, ServiceSpec
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
def _singular(collection_key: str) -> str:
if collection_key.endswith("ies"):
return collection_key[:-3] + "y"
if collection_key.endswith("ses"):
return collection_key[:-2]
if collection_key.endswith("s") and not collection_key.endswith("ss"):
return collection_key[:-1]
return collection_key
def _wrap_list(collection_key: str, items: list[dict[str, Any]]) -> dict[str, Any]:
return {collection_key: items}
def _wrap_item(collection_key: str, item: dict[str, Any]) -> dict[str, Any]:
return {_singular(collection_key): item}
def _row_to_item(row: Any) -> dict[str, Any]:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
item = dict(data or {})
item.setdefault("id", str(row["id"]))
item.setdefault("name", row["name"])
item.setdefault("status", row["status"])
if row["project_id"] is not None:
item.setdefault("project_id", str(row["project_id"]))
item.setdefault("tenant_id", str(row["project_id"]))
item.setdefault("created_at", row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
item.setdefault("updated_at", row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
return item
def _has_path_params(path: str) -> bool:
return bool(_PATH_PARAM.search(path))
def build_generic_router(spec: ServiceSpec) -> APIRouter:
router = APIRouter(tags=[spec.name.title()])
version_path = (spec.version_path or "").rstrip("/")
if version_path and version_path != "/":
service_name = spec.name
async def version_discovery(
conn: Connection = Depends(get_conn),
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service=service_name, resource_type="discovery_version", name="default"
)
router.add_api_route(
version_path,
version_discovery,
methods=["GET"],
name=f"gen-{spec.name}-version",
)
router.add_api_route(
version_path + "/",
version_discovery,
methods=["GET"],
name=f"gen-{spec.name}-version-slash",
)
for resource_type, collection_path, collection_key in spec.resources:
if collection_path in {"", "/"} or _has_path_params(collection_path):
# Nested templates / bare roots need specialized routers.
continue
_register_collection(
router,
spec=spec,
resource_type=resource_type,
collection_path=collection_path,
collection_key=collection_key,
)
return router
def _register_collection(
router: APIRouter,
*,
spec: ServiceSpec,
resource_type: str,
collection_path: str,
collection_key: str,
) -> None:
item_path = f"{collection_path.rstrip('/')}/{{item_id}}"
async def list_items(
conn: Connection = Depends(get_conn),
ctx: TokenContext = Depends(require_token),
) -> dict[str, Any]:
if ctx.project_id is None:
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service = $1 AND resource_type = $2
ORDER BY created_at""",
spec.name,
resource_type,
)
else:
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service = $1 AND resource_type = $2
AND (project_id = $3 OR project_id IS NULL)
ORDER BY created_at""",
spec.name,
resource_type,
ctx.project_id,
)
return _wrap_list(collection_key, [_row_to_item(r) for r in rows])
async def create_item(
request: Request,
conn: Connection = Depends(get_conn),
ctx: TokenContext = Depends(require_project_token),
) -> JSONResponse:
payload = await request.json()
body = payload.get(_singular(collection_key)) or payload.get(collection_key) or payload
if not isinstance(body, dict):
body = {"value": body}
item_id = uuid4()
name = str(body.get("name") or body.get("stack_name") or resource_type)
status = str(body.get("status") or body.get("stack_status") or "ACTIVE")
data = {**body, "id": str(item_id), "name": name, "status": status}
row = await conn.fetchrow(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1, $2, $3, $4, $5, $6, $7::jsonb)
RETURNING *""",
item_id,
spec.name,
resource_type,
ctx.project_id,
name,
status,
json.dumps(data),
)
return JSONResponse(status_code=201, content=_wrap_item(collection_key, _row_to_item(row)))
async def show_item(
item_id: str,
conn: Connection = Depends(get_conn),
ctx: TokenContext = Depends(require_token),
) -> dict[str, Any]:
row = await conn.fetchrow(
"""SELECT * FROM os_api_objects
WHERE service = $1 AND resource_type = $2 AND id::text = $3""",
spec.name,
resource_type,
item_id,
)
if row is None:
# also allow name lookup
row = await conn.fetchrow(
"""SELECT * FROM os_api_objects
WHERE service = $1 AND resource_type = $2 AND name = $3
LIMIT 1""",
spec.name,
resource_type,
item_id,
)
if row is None:
raise OpenStackError(
"NotFound", f"{resource_type} {item_id} not found", status_code=404
)
if (
ctx.project_id is not None
and row["project_id"] is not None
and row["project_id"] != ctx.project_id
and not ctx.is_admin
):
raise OpenStackError(
"NotFound", f"{resource_type} {item_id} not found", status_code=404
)
return _wrap_item(collection_key, _row_to_item(row))
async def update_item(
item_id: str,
request: Request,
conn: Connection = Depends(get_conn),
ctx: TokenContext = Depends(require_project_token),
) -> dict[str, Any]:
payload = await request.json()
body = payload.get(_singular(collection_key), payload)
if not isinstance(body, dict):
raise OpenStackError("BadRequest", "JSON object required", status_code=400)
row = await conn.fetchrow(
"""SELECT * FROM os_api_objects
WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""",
spec.name,
resource_type,
item_id,
ctx.project_id,
)
if row is None:
raise OpenStackError(
"NotFound", f"{resource_type} {item_id} not found", status_code=404
)
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = {**(data or {}), **body, "id": str(row["id"])}
name = str(data.get("name") or row["name"])
status = str(data.get("status") or row["status"])
updated = await conn.fetchrow(
"""UPDATE os_api_objects
SET name = $1, status = $2, data = $3::jsonb, updated_at = now()
WHERE id = $4
RETURNING *""",
name,
status,
json.dumps(data),
row["id"],
)
return _wrap_item(collection_key, _row_to_item(updated))
async def delete_item(
item_id: str,
conn: Connection = Depends(get_conn),
ctx: TokenContext = Depends(require_project_token),
) -> Response:
result = await conn.execute(
"""DELETE FROM os_api_objects
WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""",
spec.name,
resource_type,
item_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError(
"NotFound", f"{resource_type} {item_id} not found", status_code=404
)
return Response(status_code=204)
# Bind with defaults to capture loop variables.
router.add_api_route(
collection_path, list_items, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-list"
)
router.add_api_route(
collection_path,
create_item,
methods=["POST"],
name=f"gen-{spec.name}-{resource_type}-create",
)
router.add_api_route(
item_path, show_item, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-show"
)
router.add_api_route(
item_path,
update_item,
methods=["PUT", "PATCH"],
name=f"gen-{spec.name}-{resource_type}-update",
)
router.add_api_route(
item_path, delete_item, methods=["DELETE"], name=f"gen-{spec.name}-{resource_type}-delete"
)
def mount_generic_services(app: Any, *, skip: set[str] | None = None) -> int:
"""Mount generic routers under /_os/<service> for every service not in skip."""
skipped = skip or set()
count = 0
for spec in SERVICES:
if spec.name in skipped:
continue
app.include_router(build_generic_router(spec), prefix=f"/_os/{spec.name}")
count += 1 + sum(
1
for _, path, _ in spec.resources
if path not in {"", "/"} and not _has_path_params(path)
)
return count
+52
View File
@@ -0,0 +1,52 @@
"""OpenStack-style error responses."""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import JSONResponse
class OpenStackError(Exception):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 400,
title: str | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.title = title or code
async def openstack_error_handler(_request: Request, exc: OpenStackError) -> JSONResponse:
# Neutron/Glance often use {"NeutronError": ...} etc.; use a common envelope
# that openstacksdk accepts for generic HTTP errors, plus itemized faults.
body: dict[str, object]
if exc.status_code in {401, 403}:
body = {
"error": {
"code": exc.status_code,
"title": exc.title,
"message": exc.message,
}
}
elif "Compute" in exc.code or exc.code.startswith("compute"):
body = {
"itemNotFound" if exc.status_code == 404 else "badRequest": {
"code": exc.status_code,
"message": exc.message,
}
}
else:
body = {
"error": {
"code": exc.status_code,
"title": exc.title,
"message": exc.message,
}
}
return JSONResponse(status_code=exc.status_code, content=body)
+11
View File
@@ -0,0 +1,11 @@
"""Stable UUIDs for seeded OpenStack entities."""
from __future__ import annotations
import uuid
NAMESPACE = uuid.UUID("7e2c0f9a-4b11-4d6e-9c3a-0a0b0c0d0e0f")
def oid(name: str) -> uuid.UUID:
return uuid.uuid5(NAMESPACE, name)
+87
View File
@@ -0,0 +1,87 @@
"""OpenStack API microversion middleware (Nova / Cinder / Manila style)."""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
# Fallback service -> (default, max) when contract pack is not loaded.
MICROVERSIONS: dict[str, tuple[str, str]] = {
"nova": ("2.1", "2.96"),
"cinder": ("3.0", "3.70"),
"manila": ("2.0", "2.82"),
"ironic": ("1.1", "1.90"),
"placement": ("1.0", "1.39"),
}
def _service_from_request(request: Request) -> str | None:
header = request.headers.get("x-openstack-service")
if header:
return header.lower()
port = request.headers.get("x-forwarded-port")
port_map = {
"8774": "nova",
"8776": "cinder",
"8786": "manila",
"6385": "ironic",
"8003": "placement",
}
return port_map.get(str(port))
def _bounds(service: str) -> tuple[str, str] | None:
try:
from app.openstack.contract_loader import get_runtime
runtime = get_runtime()
pack = runtime.packs.get(service)
if pack and pack.default_microversion and pack.max_microversion:
return pack.default_microversion, pack.max_microversion
override = runtime.active_microversion(service)
if pack and override and pack.max_microversion:
return override, pack.max_microversion
except Exception:
pass
return MICROVERSIONS.get(service)
class MicroversionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
service = _service_from_request(request)
requested = request.headers.get("openstack-api-version") or request.headers.get(
"x-openstack-nova-api-version"
)
version = None
if requested:
parts = requested.strip().split()
version = parts[-1] if parts else None
bounds = _bounds(service) if service else None
if service and bounds:
default, maximum = bounds
try:
from app.openstack.contract_loader import get_runtime
custom = get_runtime().microversion_overrides.get(service)
except Exception:
custom = None
chosen = version or custom or default
request.state.microversion = chosen
request.state.microversion_max = maximum
request.state.microversion_service = service
response = await call_next(request)
if service and bounds:
default, maximum = bounds
chosen = getattr(request.state, "microversion", default)
if service == "nova":
response.headers["OpenStack-API-Version"] = f"compute {chosen}"
response.headers["X-OpenStack-Nova-API-Version"] = chosen
elif service == "cinder":
response.headers["OpenStack-API-Version"] = f"volume {chosen}"
elif service == "placement":
response.headers["OpenStack-API-Version"] = f"placement {chosen}"
else:
response.headers["OpenStack-API-Version"] = f"{service} {chosen}"
response.headers.setdefault("Vary", "OpenStack-API-Version")
return response
+78
View File
@@ -0,0 +1,78 @@
"""Mount OpenStack service routers onto the FastAPI application."""
from __future__ import annotations
from fastapi import FastAPI
from app.openstack.contract_loader import ensure_loaded, load_series_pack
from app.openstack.dispatch import ServiceDispatchMiddleware, ServiceStateMiddleware
from app.openstack.engine import mount_generic_services
from app.openstack.errors import OpenStackError, openstack_error_handler
from app.openstack.microversions import MicroversionMiddleware
from app.openstack.registry import HandlerRegistry, register_specialized_handlers
from app.openstack.routes import (
cinder,
glance,
heat,
ironic,
keystone,
neutron,
nova,
octavia,
placement,
root,
swift,
)
from app.openstack.schema_engine import mount_schema_services
# Specialized routers provide stateful handlers; contract packs register every
# path. Legacy generic engine remains as a final fallback for undeclared services.
_SPECIALIZED_ROUTERS: list[tuple[str, object]] = [
("keystone", keystone.router),
("nova", nova.router),
("neutron", neutron.router),
("glance", glance.router),
("cinder", cinder.router),
("placement", placement.router),
("heat", heat.router),
("swift", swift.router),
("ironic", ironic.router),
("octavia", octavia.router),
]
def build_openstack_handlers() -> HandlerRegistry:
"""Collect stateful handlers from specialized routers into a registry."""
registry = HandlerRegistry()
for name, router in _SPECIALIZED_ROUTERS:
register_specialized_handlers(registry, name, router) # type: ignore[arg-type]
return registry
def mount_openstack_routes(app: FastAPI, *, series: str = "dalmatian") -> None:
"""Register all OpenStack Identity + IaaS + schema-complete APIs."""
app.add_exception_handler(OpenStackError, openstack_error_handler)
# Order matters: last added = outermost. Dispatch must be outermost so
# rewritten paths reach routers; microversions see original headers.
app.add_middleware(MicroversionMiddleware)
app.add_middleware(ServiceStateMiddleware)
app.add_middleware(ServiceDispatchMiddleware)
# Port-aware version discovery stays on bare "/".
app.include_router(root.router)
# Contract is the sole path source; specialized routers contribute handlers only.
handlers = build_openstack_handlers()
ensure_loaded(series)
mounted = mount_schema_services(app, series=series, handlers=handlers)
app.state.openstack_schema_ops = mounted
app.state.openstack_handlers = handlers
# Legacy generic CRUD only for services without a schema pack (avoid
# /{item_id} stealing /detail and other static schema paths).
schema_services = set(load_series_pack(series).keys())
mount_generic_services(app, skip=schema_services)
+61
View File
@@ -0,0 +1,61 @@
"""OperationSpec — declarative OpenStack API operation descriptor."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
@dataclass(frozen=True, slots=True)
class OperationSpec:
"""One OpenStack API operation (method + path) within a service pack."""
operation_id: str
method: HttpMethod
path: str
service: str
resource_type: str
collection_key: str | None = None
item_key: str | None = None
kind: Literal["collection", "item", "action", "detail", "custom"] = "collection"
status_code: int = 200
create_status: int = 201
microversion_min: str | None = None
microversion_max: str | None = None
requires_auth: bool = True
requires_project: bool = True
action_name: str | None = None
response_fixture: dict[str, Any] | None = None
notes: str = ""
def path_params(self) -> list[str]:
import re
return re.findall(r"\{([^{}]+)\}", self.path)
@dataclass
class ServicePack:
name: str
typ: str
port: int
version_path: str
default_microversion: str | None
max_microversion: str | None
operations: list[OperationSpec] = field(default_factory=list)
def operation_count(self) -> int:
return len(self.operations)
@dataclass
class SeriesManifest:
series: str
major: int
services: list[dict[str, Any]]
checksum: str = ""
generated_at: str = ""
operation_count: int = 0
service_count: int = 0
+131
View File
@@ -0,0 +1,131 @@
"""Seed ``os_api_objects`` rows for every contract pack resource_type.
Ensures list GETs across all OpenStack series have persistent DB rows
(so list/show handlers serve PostgreSQL rows only).
"""
from __future__ import annotations
import json
from typing import Any
from asyncpg import Connection
from app.openstack.contract_loader import list_series, load_series_pack
from app.openstack.ids import oid
def _default_payload(service: str, resource_type: str, name: str, index: int) -> dict[str, Any]:
"""Reasonable lab JSON per resource family."""
base: dict[str, Any] = {
"name": name,
"status": "ACTIVE",
"enabled": True,
"description": f"lab {service} {resource_type} {index}",
"index": index,
}
# Light resource-specific hints for common clients.
if resource_type in {"share", "volume", "backup", "share_snapshot"}:
base.update({"size": 10, "status": "available"})
elif resource_type in {"zone", "tld"}:
base.update({"email": "hostmaster@lab.example", "ttl": 3600, "type": "PRIMARY"})
elif resource_type in {"alarm"}:
base.update({"type": "threshold", "state": "ok"})
elif resource_type in {"queue"}:
base.update({"_default_message_ttl": 3600})
elif resource_type in {"cluster"}:
base.update({"coe": "kubernetes", "status": "CREATE_COMPLETE", "node_count": 2})
elif resource_type in {"container"} and service == "zun":
base.update({"image": "cirros", "status": "Running"})
elif resource_type in {"container"} and service == "barbican":
base.update({"type": "generic", "status": "ACTIVE"})
elif resource_type in {"secret"}:
base.update({"secret_type": "passphrase", "payload_content_type": "text/plain"})
elif resource_type in {"datastore"}:
base.update({"type": "mysql", "version": "8.0"})
elif resource_type in {"instance"} and service == "trove":
base.update({"datastore": {"type": "mysql", "version": "8.0"}, "status": "ACTIVE"})
elif resource_type in {"workflow", "workbook"}:
base.update({"definition": "version: '2.0'\ndemo:\n tasks: {}"})
elif resource_type in {"dataframes"}:
base.update({"period": "3600"})
elif resource_type in {"quota", "quota_set"}:
base.update({"limit": 100, "in_use": index})
elif resource_type in {"status", "service_status", "health"}:
base.update({"status": "UP", "state": "up", "service": service})
elif resource_type == "ping":
base.update({"ping": "pong", "ok": True})
elif resource_type == "driver" and service == "ironic":
base.update({"hosts": ["simulator"], "type": "classic"})
elif resource_type == "agent" and service == "neutron":
base.update(
{
"agent_type": "L3 agent",
"host": f"network-{index}",
"alive": True,
"admin_state_up": True,
}
)
elif resource_type == "console_output":
base.update({"output": "Booting...\nSimulator console\n"})
elif resource_type == "console":
base.update(
{"type": "novnc", "url": "https://127.0.0.1:6080/vnc_auto.html?token=simulator"}
)
return base
def iter_pack_resource_types(*, series: str | None = None) -> set[tuple[str, str]]:
"""Return ``{(service, resource_type)}`` declared by GET collection/detail/custom ops."""
series_names = [series] if series else [str(item["series"]) for item in list_series()]
found: set[tuple[str, str]] = set()
for name in series_names:
packs = load_series_pack(name)
for pack in packs.values():
for op in pack.operations:
if op.method != "GET":
continue
if op.kind not in {"collection", "detail", "custom"}:
continue
if not op.resource_type or op.resource_type in {"version", "ping"}:
continue
found.add((pack.name, op.resource_type))
return found
async def seed_pack_surface_samples(
conn: Connection,
*,
series: str | None = None,
per_type: int = 3,
project_id: Any | None = None,
) -> dict[str, int]:
"""Insert lab rows for every pack resource_type (idempotent via stable oid)."""
types = iter_pack_resource_types(series=series)
inserted = 0
for service, resource_type in sorted(types):
for index in range(per_type):
name = f"{resource_type}-{index}"
item_id = oid(f"packseed:{service}:{resource_type}:{name}")
payload = _default_payload(service, resource_type, name, index)
payload["id"] = str(item_id)
status = str(payload.get("status") or "ACTIVE")
result = await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
ON CONFLICT (id) DO NOTHING""",
item_id,
service,
resource_type,
project_id,
name,
status,
json.dumps(payload),
)
# asyncpg: "INSERT 0 1" on insert, "INSERT 0 0" on conflict skip
if result.split()[-1] == "1":
inserted += 1
return {"resource_types": len(types), "rows_inserted": inserted, "per_type": per_type}
+46
View File
@@ -0,0 +1,46 @@
"""OpenStack-style limit/marker pagination helpers."""
from __future__ import annotations
from typing import Any, Callable
from starlette.requests import Request
def parse_limit(request: Request, *, default: int = 0, maximum: int = 1000) -> int:
raw = request.query_params.get("limit")
if raw is None or raw == "":
return default
try:
value = int(raw)
except ValueError:
return default
if value <= 0:
return default
return min(value, maximum)
def paginate_rows(
rows: list[Any],
request: Request,
*,
id_attr: Callable[[Any], str],
default_limit: int = 0,
) -> tuple[list[Any], list[dict[str, str]]]:
"""Slice rows by marker/limit. Returns (page, link dicts for next)."""
marker = request.query_params.get("marker")
limit = parse_limit(request, default=default_limit)
start = 0
if marker:
for index, row in enumerate(rows):
if id_attr(row) == marker:
start = index + 1
break
page = rows[start:]
links: list[dict[str, str]] = []
if limit > 0 and len(page) > limit:
page = page[:limit]
last = page[-1]
links.append({"rel": "next", "href": f"?marker={id_attr(last)}&limit={limit}"})
return page, links
+315
View File
@@ -0,0 +1,315 @@
"""Contract-driven OpenStack route registry (Proxmox-style per-path registration)."""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.routing import APIRoute, APIRouter
from app.api.openapi import service_openapi_tag
from app.openstack.opspec import OperationSpec, ServicePack
Handler = Callable[[Request], Awaitable[Response]]
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
_ROUTE_NAME_PREFIX = "os-contract:"
class RouteCollisionError(ValueError):
pass
def normalize_path_template(path: str) -> str:
"""Collapse `{param}` names so `/servers/{id}` matches `/servers/{server_id}`."""
return _PATH_PARAM.sub("{}", path if path.startswith("/") else f"/{path}")
def _param_names(path: str) -> list[str]:
"""Path param names without FastAPI converters (``{object_name:path}`` → ``object_name``)."""
return [name.split(":", 1)[0] for name in _PATH_PARAM.findall(path)]
@dataclass(slots=True)
class HandlerRegistry:
"""Semantic handlers keyed by (service, path, verb)."""
_handlers: dict[tuple[str, str, str], Handler] = field(default_factory=dict)
_normalized: dict[tuple[str, str, str], tuple[str, Handler]] = field(default_factory=dict)
def register(self, service: str, path: str, verb: str, handler: Handler) -> None:
key = (service, path, verb.upper())
if key in self._handlers:
raise RouteCollisionError(f"duplicate semantic handler: {verb} {service} {path}")
self._handlers[key] = handler
norm_key = (service, normalize_path_template(path), verb.upper())
# First registration wins for structural lookup (prefer exact contract names).
self._normalized.setdefault(norm_key, (path, handler))
def get(self, service: str, path: str, verb: str) -> Handler | None:
verb_u = verb.upper()
exact = self._handlers.get((service, path, verb_u))
if exact is not None:
return exact
hit = self._normalized.get((service, normalize_path_template(path), verb_u))
return hit[1] if hit else None
def get_specialized_path(self, service: str, path: str, verb: str) -> str | None:
"""Return the path template the handler was registered under (for param remap)."""
verb_u = verb.upper()
if (service, path, verb_u) in self._handlers:
return path
hit = self._normalized.get((service, normalize_path_template(path), verb_u))
return hit[0] if hit else None
def keys(self) -> frozenset[tuple[str, str, str]]:
return frozenset(self._handlers)
def _fastapi_path(path: str) -> str:
return path if path.startswith("/") else f"/{path}"
def _route_priority(op: OperationSpec) -> tuple[int, int, str]:
"""Static paths before templated ones so /detail is not captured by /{id}."""
path = op.path
braces = path.count("{")
detail_bias = 0 if path.rstrip("/").endswith("/detail") else 1
return (braces, detail_bias, path)
def _remap_path_params(request: Request, contract_path: str, specialized_path: str) -> None:
"""Align request.path_params names with the specialized route template."""
contract_names = _param_names(contract_path)
specialized_names = _param_names(specialized_path)
if not specialized_names:
return
current = dict(request.path_params)
if set(specialized_names) <= set(current):
return
values: list[str] = []
for name in contract_names:
if name in current:
values.append(str(current[name]))
if len(values) != len(specialized_names):
# Fall back to positional values already present.
values = [str(v) for v in current.values()]
if len(values) != len(specialized_names):
return
remapped = dict(zip(specialized_names, values, strict=True))
# Keep any non-path extras (unlikely) under original keys.
for key, value in current.items():
if key not in remapped and key not in contract_names:
remapped[key] = value
request.scope["path_params"] = remapped
def _bridge_route_handler(specialized_path: str, route_handler: Handler) -> Handler:
async def handler(request: Request) -> Response:
contract_path = getattr(request.state, "os_contract_path", specialized_path)
_remap_path_params(request, contract_path, specialized_path)
return await route_handler(request)
return handler
def register_specialized_handlers(
registry: HandlerRegistry,
service: str,
router: APIRouter,
) -> int:
"""Import FastAPI router endpoints into the semantic handler registry."""
count = 0
for route in router.routes:
if not isinstance(route, APIRoute):
continue
methods = route.methods or set()
route_handler = route.get_route_handler()
for method in methods:
if method in {"HEAD", "OPTIONS"}:
continue
path = route.path
key = (service, path, method.upper())
if key in registry._handlers:
continue
registry.register(
service,
path,
method,
_bridge_route_handler(path, route_handler),
)
count += 1
return count
def clear_os_contract_routes(app: FastAPI) -> None:
"""Drop previously registered ``os-contract:`` routes for rebuild / hot-swap."""
app.router.routes = [
route
for route in app.router.routes
if not (
isinstance(getattr(route, "name", None), str)
and str(route.name).startswith(_ROUTE_NAME_PREFIX)
)
]
app.openapi_schema = None
def register_openstack_contract_routes(
app: FastAPI,
packs: dict[str, ServicePack],
handlers: HandlerRegistry,
*,
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
) -> int:
"""Register one FastAPI route per unique (service, method, path) from packs.
Endpoint looks up a semantic handler first; otherwise falls back to ``dispatch_fn``
(schema engine generic CRUD/action behaviour).
"""
registered = 0
for pack in packs.values():
seen: set[tuple[str, str]] = set()
for op in sorted(pack.operations, key=_route_priority):
key = (op.method, op.path)
if key in seen:
continue
seen.add(key)
path = _fastapi_path(op.path)
full_path = f"/_os/{pack.name}{path}"
name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}"
endpoint = _make_contract_endpoint(pack, op, handlers, dispatch_fn)
app.add_api_route(
full_path,
endpoint,
methods=[op.method],
name=name,
include_in_schema=True,
tags=[service_openapi_tag(pack.name)],
)
registered += 1
return registered
def _make_contract_endpoint(
pack: ServicePack,
op: OperationSpec,
handlers: HandlerRegistry,
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
) -> Handler:
async def endpoint(request: Request) -> Response:
request.state.os_contract_path = op.path
request.state.os_contract_op = op
handler = handlers.get(pack.name, op.path, op.method)
if handler is not None:
return await handler(request)
return await dispatch_fn(request, pack, op)
return endpoint
def register_specialized_orphan_routes(
app: FastAPI,
packs: dict[str, ServicePack],
handlers: HandlerRegistry,
) -> int:
"""Register specialized handler paths that are not declared in the contract pack.
Keeps trailing-slash version roots, PUT collection aliases, etc. that exist on
stateful routers but are missing from generated ``api.json`` packs.
"""
declared: set[tuple[str, str, str]] = set()
declared_norm: set[tuple[str, str, str]] = set()
for pack in packs.values():
for op in pack.operations:
declared.add((pack.name, op.method.upper(), op.path))
declared_norm.add((pack.name, op.method.upper(), normalize_path_template(op.path)))
# Paths already mounted by the contract loop.
mounted: set[tuple[str, str, str]] = set()
for route in app.router.routes:
name = getattr(route, "name", None)
if not isinstance(name, str) or not name.startswith(_ROUTE_NAME_PREFIX):
continue
# os-contract:{service}:{METHOD}:{path}
rest = name[len(_ROUTE_NAME_PREFIX) :]
service, _, remainder = rest.partition(":")
method, _, path = remainder.partition(":")
mounted.add((service, method.upper(), path))
registered = 0
for service, path, verb in sorted(handlers.keys()):
verb_u = verb.upper()
if (service, verb_u, path) in declared or (service, verb_u, path) in mounted:
continue
if (service, verb_u, normalize_path_template(path)) in declared_norm:
continue
handler = handlers.get(service, path, verb_u)
if handler is None:
continue
full_path = f"/_os/{service}{_fastapi_path(path)}"
name = f"{_ROUTE_NAME_PREFIX}{service}:{verb_u}:{path}"
endpoint = _make_handler_only_endpoint(path, handler)
app.add_api_route(
full_path,
endpoint,
methods=[verb_u],
name=name,
include_in_schema=True,
tags=[service_openapi_tag(service)],
)
registered += 1
return registered
def _make_handler_only_endpoint(specialized_path: str, handler: Handler) -> Handler:
async def endpoint(request: Request) -> Response:
request.state.os_contract_path = specialized_path
return await handler(request)
return endpoint
def mount_contract_services(
app: FastAPI,
*,
packs: dict[str, ServicePack],
handlers: HandlerRegistry,
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
) -> int:
"""Clear previous contract routes and register from packs. Returns route count."""
# Preserve non-contract routes; insert contract routes before gen-* so static
# schema paths are not stolen by generic /{item_id}.
non_gen: list[Any] = []
gen: list[Any] = []
for route in app.router.routes:
name = getattr(route, "name", "") or ""
if isinstance(name, str) and name.startswith(_ROUTE_NAME_PREFIX):
continue
if isinstance(name, str) and name.startswith("schema-"):
# Legacy schema-* routes from older mounts — drop on rebuild.
continue
if isinstance(name, str) and name.startswith("gen-"):
gen.append(route)
else:
non_gen.append(route)
app.router.routes = non_gen
app.openapi_schema = None
count = register_openstack_contract_routes(app, packs, handlers, dispatch_fn=dispatch_fn)
count += register_specialized_orphan_routes(app, packs, handlers)
app.router.routes.extend(gen)
return count
+1
View File
@@ -0,0 +1 @@
"""HTTP routers for OpenStack services."""
+293
View File
@@ -0,0 +1,293 @@
"""Cinder Block Storage API v3 (lab subset)."""
from __future__ import annotations
from typing import Annotated, Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Cinder"])
def _volume(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": row["name"],
"status": row["status"],
"size": row["size"],
"volume_type": row["volume_type"],
"bootable": "true" if row["bootable"] else "false",
"multiattach": False,
"encrypted": False,
"os-vol-tenant-attr:tenant_id": str(row["project_id"]),
"metadata": {},
"attachments": [],
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
"updated_at": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
"links": [
{"rel": "self", "href": f"/v3/{row['project_id']}/volumes/{row['id']}"},
],
}
@router.get("/v3")
@router.get("/v3/")
async def cinder_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="cinder", resource_type="discovery_version", name="default"
)
@router.get("/v3/{project_id}/volumes")
@router.get("/v3/{project_id}/volumes/detail")
@router.get("/v3/volumes")
@router.get("/v3/volumes/detail")
async def list_volumes(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
_ = project_id # path project_id ignored; token scope wins
detail = request.url.path.rstrip("/").endswith("detail")
rows = list(
await conn.fetch(
"SELECT * FROM os_volumes WHERE project_id = $1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
if detail:
body: dict[str, object] = {"volumes": [_volume(r) for r in page]}
else:
body = {
"volumes": [
{
"id": str(r["id"]),
"name": r["name"],
"links": [{"rel": "self", "href": f"/v3/{ctx.project_id}/volumes/{r['id']}"}],
}
for r in page
]
}
if links:
body["volumes_links"] = links
return body
@router.get("/v3/{project_id}/volumes/{volume_id}")
@router.get("/v3/volumes/{volume_id}")
async def show_volume(
volume_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
# openstacksdk may probe GET /volumes/{name} before create
row = await conn.fetchrow(
"""SELECT * FROM os_volumes
WHERE project_id = $2
AND (id::text = $1 OR name = $1)
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END
LIMIT 1""",
volume_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
return {"volume": _volume(row)}
async def _update_volume(
resource_id: str,
request: Request,
conn: Connection,
ctx: TokenContext,
) -> dict[str, object]:
payload = (await request.json()).get("volume") or {}
row = await conn.fetchrow(
"""UPDATE os_volumes
SET name = COALESCE($1, name), updated_at = now()
WHERE id = $2::uuid AND project_id = $3
RETURNING *""",
payload.get("name"),
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
return {"volume": _volume(row)}
@router.put("/v3/{project_id}/volumes/{volume_id}")
@router.patch("/v3/{project_id}/volumes/{volume_id}")
@router.put("/v3/volumes/{volume_id}")
@router.patch("/v3/volumes/{volume_id}")
async def update_volume(
volume_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
return await _update_volume(volume_id, request, conn, ctx)
@router.put("/v3/{project_id}/volumes/{id}")
@router.patch("/v3/{project_id}/volumes/{id}")
@router.put("/v3/volumes/{id}")
@router.patch("/v3/volumes/{id}")
async def update_volume_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
return await _update_volume(id, request, conn, ctx)
@router.post("/v3/{project_id}/volumes", status_code=202)
@router.post("/v3/volumes", status_code=202)
async def create_volume(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
_ = project_id
payload = (await request.json()).get("volume") or {}
defaults = (
await fetch_doc(conn, service="cinder", resource_type="volume_defaults", name="default")
or {}
)
size = int(
payload.get("size") if payload.get("size") is not None else defaults.get("size") or 1
)
row = await conn.fetchrow(
"""INSERT INTO os_volumes(id, project_id, name, status, size, volume_type, bootable)
VALUES($1, $2, $3, 'available', $4, $5, $6)
RETURNING *""",
uuid4(),
ctx.project_id,
payload.get("name") if payload.get("name") is not None else defaults.get("name") or "",
size,
payload.get("volume_type") or defaults.get("volume_type"),
bool(payload.get("bootable", False)),
)
return {"volume": _volume(row)}
@router.delete("/v3/{project_id}/volumes/{volume_id}", status_code=202)
@router.delete("/v3/volumes/{volume_id}", status_code=202)
async def delete_volume(
volume_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> Response:
_ = project_id
result = await conn.execute(
"DELETE FROM os_volumes WHERE id = $1::uuid AND project_id = $2",
volume_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
return Response(status_code=202)
@router.post("/v3/{project_id}/volumes/{volume_id}/action", status_code=202)
@router.post("/v3/volumes/{volume_id}/action", status_code=202)
async def volume_action(
volume_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> Response:
"""Lab subset of Cinder volume actions (os-extend, etc.)."""
_ = project_id
payload = await request.json()
row = await conn.fetchrow(
"SELECT * FROM os_volumes WHERE id = $1::uuid AND project_id = $2",
volume_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
if "os-extend" in payload:
new_size = int((payload.get("os-extend") or {}).get("new_size") or 0)
if new_size <= int(row["size"]):
raise OpenStackError(
"InvalidInput",
"new_size must be greater than current size",
status_code=400,
)
await conn.execute(
"""UPDATE os_volumes
SET size = $1, updated_at = now()
WHERE id = $2::uuid AND project_id = $3""",
new_size,
volume_id,
ctx.project_id,
)
return Response(status_code=202)
# Persist any other recognized lab action against the volume in PostgreSQL.
import json
from uuid import uuid4
action = next(iter(payload.keys()), "action") if isinstance(payload, dict) else "action"
status_map = {
"os-reserve": "in-use",
"os-unreserve": "available",
"os-attach": "in-use",
"os-detach": "available",
"os-begin_detaching": "in-use",
"os-roll_detaching": "in-use",
"os-force_detach": "available",
"os-reset_status": str(
((payload.get("os-reset_status") or {}) if isinstance(payload, dict) else {}).get(
"status"
)
or row["status"]
),
"os-set_bootable": row["status"],
"os-retype": row["status"],
"os-migrate_volume": row["status"],
"os-start": row["status"],
"os-stop": row["status"],
}
new_status = status_map.get(str(action), row["status"])
await conn.execute(
"UPDATE os_volumes SET status=$1, updated_at=now() WHERE id=$2::uuid AND project_id=$3",
new_status,
volume_id,
ctx.project_id,
)
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'cinder','volume_action',$2,$3,'DONE',$4::jsonb)""",
uuid4(),
ctx.project_id,
f"{volume_id}:{action}",
json.dumps({"volume_id": volume_id, "action": action, "payload": payload}),
)
return Response(status_code=202)
+330
View File
@@ -0,0 +1,330 @@
"""Glance Image API v2 (lab subset)."""
from __future__ import annotations
from typing import Annotated, Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Glance"])
def _image(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": row["name"],
"status": row["status"],
"visibility": row["visibility"],
"size": row["size"],
"disk_format": row["disk_format"],
"container_format": row["container_format"],
"min_disk": 0,
"min_ram": 0,
"protected": False,
"checksum": None,
"owner": str(row["owner_project_id"]) if row["owner_project_id"] else None,
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"updated_at": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"tags": [],
"file": f"/v2/images/{row['id']}/file",
"schema": "/v2/schemas/image",
}
@router.get("/v2")
@router.get("/v2/")
async def glance_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="glance", resource_type="discovery_version", name="default"
)
@router.get("/v2/images")
async def list_images(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
name = request.query_params.get("name")
if name:
rows = list(
await conn.fetch(
"""SELECT * FROM os_images
WHERE (visibility = 'public' OR owner_project_id = $1)
AND name = $2
ORDER BY created_at, id""",
ctx.project_id,
name,
)
)
else:
rows = list(
await conn.fetch(
"""SELECT * FROM os_images
WHERE visibility = 'public'
OR owner_project_id = $1
ORDER BY created_at, id""",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {
"images": [_image(r) for r in page],
"first": "/v2/images",
"schema": "/v2/schemas/images",
}
if links:
body["images_links"] = links
return body
async def _show_image(
resource_id: str,
conn: Connection,
ctx: TokenContext,
) -> dict[str, Any]:
row = await conn.fetchrow(
"""SELECT * FROM os_images
WHERE (id::text = $1 OR name = $1)
AND (visibility = 'public' OR owner_project_id = $2)
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END
LIMIT 1""",
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return _image(row)
@router.get("/v2/images/{image_id}")
async def show_image(
image_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
return await _show_image(image_id, conn, ctx)
@router.get("/v2/images/{id}")
async def show_image_by_id(
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
return await _show_image(id, conn, ctx)
async def _update_image(
resource_id: str,
request: Request,
conn: Connection,
ctx: TokenContext,
) -> dict[str, Any]:
payload = await request.json()
body = payload.get("image") if isinstance(payload.get("image"), dict) else payload
row = await conn.fetchrow(
"""UPDATE os_images
SET name = COALESCE($1, name), updated_at = now()
WHERE id = $2::uuid AND owner_project_id = $3
RETURNING *""",
body.get("name") if isinstance(body, dict) else None,
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return _image(row)
@router.put("/v2/images/{image_id}")
@router.patch("/v2/images/{image_id}")
async def update_image(
image_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, Any]:
return await _update_image(image_id, request, conn, ctx)
@router.put("/v2/images/{id}")
@router.patch("/v2/images/{id}")
async def update_image_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, Any]:
return await _update_image(id, request, conn, ctx)
@router.post("/v2/images", status_code=201)
async def create_image(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
payload = await request.json()
defaults = (
await fetch_doc(conn, service="glance", resource_type="image_defaults", name="default")
or {}
)
image_id = uuid4()
row = await conn.fetchrow(
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
container_format, owner_project_id)
VALUES($1, $2, 'queued', $3, 0, $4, $5, $6)
RETURNING *""",
image_id,
payload.get("name") or defaults.get("name") or "image",
payload.get("visibility") or defaults.get("visibility"),
payload.get("disk_format") or defaults.get("disk_format"),
payload.get("container_format") or defaults.get("container_format"),
ctx.project_id,
)
return _image(row)
@router.get("/v2/images/{image_id}/file")
async def download_image_file(
image_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
row = await conn.fetchrow(
"""SELECT id, size FROM os_images
WHERE (id::text=$1 OR name=$1)
AND (owner_project_id=$2 OR visibility='public')
LIMIT 1""",
image_id,
ctx.project_id,
)
if row is None:
# Materialize pack/schema image rows into os_images on first download.
api = await conn.fetchrow(
"""SELECT id, name, data FROM os_api_objects
WHERE service='glance' AND resource_type='image'
AND (id::text=$1 OR name=$1)
LIMIT 1""",
image_id,
)
if api is None:
raise OpenStackError("ImageNotFound", f"image {image_id} not found", status_code=404)
data = api["data"]
if isinstance(data, str):
import json as _json
data = _json.loads(data or "{}")
await conn.execute(
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
container_format, owner_project_id)
VALUES($1::uuid,$2,'active',$3,$4,$5,$6,$7)
ON CONFLICT (id) DO UPDATE SET updated_at=now()""",
api["id"],
api["name"] or (data or {}).get("name") or "image",
(data or {}).get("visibility") or "private",
int((data or {}).get("size") or 0),
(data or {}).get("disk_format") or "qcow2",
(data or {}).get("container_format") or "bare",
ctx.project_id,
)
size = int((data or {}).get("size") or 0)
else:
size = int(row["size"] or 0)
# Always return at least one byte so clients / coverage see a real payload.
content = b"\0" * min(size, 64) if size else b"probe-image"
return Response(
content=content,
media_type="application/octet-stream",
headers={"Content-Length": str(len(content) if not size else size)},
)
@router.put("/v2/images/{image_id}/file", status_code=204)
async def upload_image_file(
image_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
body = await request.body()
result = await conn.execute(
"""UPDATE os_images
SET status = 'active', size = $1, updated_at = now()
WHERE (id::text = $2 OR name = $2) AND owner_project_id = $3""",
len(body),
image_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return Response(status_code=204)
@router.delete("/v2/images/{image_id}", status_code=204)
async def delete_image(
image_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
result = await conn.execute(
"DELETE FROM os_images WHERE id = $1::uuid AND owner_project_id = $2",
image_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return Response(status_code=204)
@router.get("/v2/info/stores")
async def glance_stores(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="info_stores", name="default")
@router.get("/v2/info/import")
async def glance_import_info(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="info_import", name="default")
@router.get("/v2/schemas/image")
async def glance_schema_image(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="schema", name="image")
@router.get("/v2/schemas/images")
async def glance_schema_images(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="schema", name="images")
+268
View File
@@ -0,0 +1,268 @@
"""Heat Orchestration API v1."""
from __future__ import annotations
import json
from typing import Annotated, Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Heat"])
def _stack(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"stack_name": row["stack_name"],
"stack_status": row["stack_status"],
"description": row["description"],
"creation_time": row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"updated_time": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"stack_owner": str(row["project_id"]),
"parent": None,
"stack_user_project_id": str(row["project_id"]),
"outputs": row["outputs"]
if not isinstance(row["outputs"], str)
else json.loads(row["outputs"]),
"parameters": row["parameters"]
if not isinstance(row["parameters"], str)
else json.loads(row["parameters"]),
"links": [
{
"rel": "self",
"href": f"/v1/{row['project_id']}/stacks/{row['stack_name']}/{row['id']}",
}
],
}
@router.get("/v1")
@router.get("/v1/")
async def heat_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="heat", resource_type="discovery_version", name="default"
)
@router.get("/v1/{tenant_id}/stacks")
async def list_stacks(
tenant_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
_ = tenant_id
rows = list(
await conn.fetch(
"SELECT * FROM os_stacks WHERE project_id = $1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"stacks": [_stack(r) for r in page]}
if links:
body["stacks_links"] = links
return body
@router.get("/v1/{tenant_id}/stacks/detail")
async def list_stacks_detail(
tenant_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await list_stacks(tenant_id, request, conn, ctx)
@router.post("/v1/{tenant_id}/stacks", status_code=201)
async def create_stack(
tenant_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
_ = tenant_id
payload = await request.json()
stack = payload.get("stack") or payload
defaults = (
await fetch_doc(conn, service="heat", resource_type="stack_defaults", name="default") or {}
)
name = stack.get("stack_name") or stack.get("name") or f"stack-{uuid4().hex[:8]}"
template = (
stack.get("template")
if isinstance(stack.get("template"), dict)
else defaults.get("template")
)
parameters = (
stack.get("parameters")
if isinstance(stack.get("parameters"), dict)
else defaults.get("parameters")
)
if not isinstance(template, dict):
template = {}
if not isinstance(parameters, dict):
parameters = {}
row = await conn.fetchrow(
"""INSERT INTO os_stacks(id, project_id, stack_name, stack_status, description, template, parameters, outputs)
VALUES($1,$2,$3,'CREATE_COMPLETE',$4,$5::jsonb,$6::jsonb,'[]'::jsonb) RETURNING *""",
uuid4(),
ctx.project_id,
name,
stack.get("description") or "",
json.dumps(template),
json.dumps(parameters),
)
return {"stack": _stack(row)}
@router.get("/v1/{tenant_id}/stacks/{id}")
async def show_stack_by_id(
tenant_id: str,
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = tenant_id
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
id,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return {"stack": _stack(row)}
@router.put("/v1/{tenant_id}/stacks/{id}")
@router.patch("/v1/{tenant_id}/stacks/{id}")
async def update_stack_by_id(
tenant_id: str,
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = tenant_id
payload = await request.json()
stack = payload.get("stack") or payload
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
id,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
desc = stack.get("description") if "description" in stack else row["description"]
await conn.execute(
"UPDATE os_stacks SET description=$1, updated_at=now(), stack_status='UPDATE_COMPLETE' WHERE id=$2",
desc,
row["id"],
)
row = await conn.fetchrow("SELECT * FROM os_stacks WHERE id=$1", row["id"])
return {"stack": _stack(row)}
@router.delete("/v1/{tenant_id}/stacks/{id}", status_code=204)
async def delete_stack_by_id(
tenant_id: str,
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
_ = tenant_id
result = await conn.execute(
"DELETE FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2)",
ctx.project_id,
id,
)
if result.endswith("0"):
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return Response(status_code=204)
@router.get("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
@router.get("/v1/{tenant_id}/stacks/{stack_name}")
async def show_stack(
tenant_id: str,
stack_name: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
stack_id: str | None = None,
) -> dict[str, object]:
_ = tenant_id
if stack_name == "detail" and stack_id is None:
from app.openstack.paging import paginate_rows
rows = list(
await conn.fetch(
"SELECT * FROM os_stacks WHERE project_id = $1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"stacks": [_stack(r) for r in page]}
if links:
body["stacks_links"] = links
return body
if stack_id:
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND id::text=$2",
ctx.project_id,
stack_id,
)
else:
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND stack_name=$2 ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
stack_name,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return {"stack": _stack(row)}
@router.delete("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}", status_code=204)
async def delete_stack(
tenant_id: str,
stack_name: str,
stack_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
_ = tenant_id, stack_name
result = await conn.execute(
"DELETE FROM os_stacks WHERE project_id=$1 AND id::text=$2",
ctx.project_id,
stack_id,
)
if result.endswith("0"):
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return Response(status_code=204)
@router.get("/v1/{tenant_id}/resource_types")
async def resource_types(
tenant_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
_ = tenant_id
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="heat", resource_type="resource_type_list", name="default"
)
+279
View File
@@ -0,0 +1,279 @@
"""Ironic Bare Metal API v1."""
from __future__ import annotations
import json
from typing import Annotated, Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Ironic"])
def _node(row: Any) -> dict[str, Any]:
props = row["properties"]
if isinstance(props, str):
props = json.loads(props)
return {
"id": str(row["id"]),
"uuid": str(row["id"]),
"name": row["name"],
"driver": row["driver"],
"provision_state": row["provision_state"],
"power_state": row["power_state"],
"resource_class": row["resource_class"],
"properties": props or {},
"driver_info": row["driver_info"]
if not isinstance(row["driver_info"], str)
else json.loads(row["driver_info"]),
"ports": row["ports"] if not isinstance(row["ports"], str) else json.loads(row["ports"]),
"maintenance": False,
"links": [{"rel": "self", "href": f"/v1/nodes/{row['id']}"}],
}
@router.get("/v1")
@router.get("/v1/")
async def ironic_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="ironic", resource_type="discovery_version", name="default"
)
@router.get("/v1/nodes")
async def list_nodes(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
rows = list(await conn.fetch("SELECT * FROM os_nodes ORDER BY name, id"))
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"nodes": [_node(r) for r in page]}
if links:
body["nodes_links"] = links
return body
@router.post("/v1/nodes", status_code=201)
async def create_node(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
payload = await request.json()
defaults = (
await fetch_doc(conn, service="ironic", resource_type="node_defaults", name="default") or {}
)
props = (
payload.get("properties")
if isinstance(payload.get("properties"), dict)
else defaults.get("properties")
)
if not isinstance(props, dict):
props = {}
row = await conn.fetchrow(
"""INSERT INTO os_nodes(id, name, driver, provision_state, power_state, resource_class, properties, driver_info, ports)
VALUES($1,$2,$3,'available','power off',$4,$5::jsonb,$6::jsonb,'[]'::jsonb) RETURNING *""",
uuid4(),
payload.get("name") or f"node-{uuid4().hex[:8]}",
payload.get("driver") or defaults.get("driver"),
payload.get("resource_class") or defaults.get("resource_class"),
json.dumps(props),
json.dumps(payload.get("driver_info") or {}),
)
return _node(row)
@router.get("/v1/nodes/{node_id}")
async def show_node(
node_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow("SELECT * FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
if row is None:
raise OpenStackError("NotFound", "Node not found", status_code=404)
return _node(row)
async def _update_node(
resource_id: str,
request: Request,
conn: Connection,
) -> dict[str, Any]:
payload = await request.json()
row = await conn.fetchrow(
"""UPDATE os_nodes
SET name = COALESCE($1, name), updated_at = now()
WHERE id::text = $2 OR name = $2
RETURNING *""",
payload.get("name"),
resource_id,
)
if row is None:
raise OpenStackError("NotFound", "Node not found", status_code=404)
return _node(row)
@router.put("/v1/nodes/{node_id}")
@router.patch("/v1/nodes/{node_id}")
async def update_node(
node_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
return await _update_node(node_id, request, conn)
@router.put("/v1/nodes/{id}")
@router.patch("/v1/nodes/{id}")
async def update_node_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
return await _update_node(id, request, conn)
@router.delete("/v1/nodes/{node_id}", status_code=204)
async def delete_node(
node_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
result = await conn.execute("DELETE FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
if result.endswith("0"):
raise OpenStackError("NotFound", "Node not found", status_code=404)
return Response(status_code=204)
@router.put("/v1/nodes/{node_id}/states/provision")
@router.put("/v1/nodes/{node_id}/states/power")
async def node_state(
node_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
from app.openstack.db_docs import fetch_doc
payload = await request.json()
target = payload.get("target") or payload.get("state")
defaults = (
await fetch_doc(conn, service="ironic", resource_type="node_defaults", name="default") or {}
)
row = await conn.fetchrow("SELECT id FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
if row is None:
raise OpenStackError("NotFound", "Node not found", status_code=404)
if "power" in request.url.path:
await conn.execute(
"UPDATE os_nodes SET power_state=$1, updated_at=now() WHERE id=$2",
target or defaults.get("power_state"),
row["id"],
)
else:
await conn.execute(
"UPDATE os_nodes SET provision_state=$1, updated_at=now() WHERE id=$2",
target or defaults.get("provision_state"),
row["id"],
)
return Response(status_code=202)
@router.get("/v1/drivers")
async def list_drivers(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
import json as _json
rows = await conn.fetch(
"""SELECT id, name, data FROM os_api_objects
WHERE service='ironic' AND resource_type='driver'
ORDER BY created_at NULLS LAST, name"""
)
drivers: list[dict[str, object]] = []
for row in rows:
data = row["data"] if isinstance(row["data"], dict) else _json.loads(row["data"] or "{}")
drivers.append(
{
"name": row["name"] or data.get("name"),
"hosts": list(data.get("hosts") or []),
"type": data.get("type"),
}
)
return {"drivers": drivers}
@router.get("/v1/nodes/{node_ident}/states")
async def node_states(
node_ident: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT power_state, provision_state FROM os_nodes WHERE id::text=$1 OR name=$1",
node_ident,
)
if row is None:
raise OpenStackError("NotFound", f"node {node_ident} not found", status_code=404)
return {
"power": row["power_state"],
"provision": row["provision_state"],
"raid": None,
"console": False,
"boot_mode": None,
}
@router.get("/v1/nodes/{node_ident}/vendor_passthru")
async def node_vendor_passthru(
node_ident: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
exists = await conn.fetchval(
"SELECT 1 FROM os_nodes WHERE id::text=$1 OR name=$1",
node_ident,
)
if not exists:
raise OpenStackError("NotFound", f"node {node_ident} not found", status_code=404)
row = await conn.fetchrow(
"""SELECT data FROM os_api_objects
WHERE service='ironic' AND resource_type='vendor_passthru'
AND (name=$1 OR data->>'node_id'=$1 OR data->>'node_uuid'=$1)
ORDER BY updated_at DESC LIMIT 1""",
node_ident,
)
if row is not None:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
methods = (data or {}).get("methods") or (data or {}).get("vendor_passthru") or data
if isinstance(methods, dict) and methods:
return {"vendor_passthru": methods}
return {"vendor_passthru": {"heartbeat": {"http_methods": ["POST"], "async": True}}}
# Persist empty methods doc so subsequent GETs are DB-backed.
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'ironic','vendor_passthru',NULL,$2,'ACTIVE',$3::jsonb)
ON CONFLICT (id) DO NOTHING""",
uuid4(),
node_ident,
json.dumps({"node_id": node_ident, "methods": {}}),
)
return {"vendor_passthru": {"heartbeat": {"http_methods": ["POST"], "async": True}}}
+477
View File
@@ -0,0 +1,477 @@
"""Keystone Identity API v3 (lab subset)."""
from __future__ import annotations
from typing import Annotated, Any
from asyncpg import Connection, Pool
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.openstack.auth import extract_token, issue_token, validate_token
from app.openstack.catalog import build_catalog_from_db
from app.openstack.db_docs import require_doc
from app.openstack.deps import (
get_conn,
get_pool,
request_public_host,
request_scheme,
require_token,
)
from app.openstack.errors import OpenStackError
from app.openstack.auth import TokenContext
router = APIRouter(tags=["Keystone"])
@router.get("/v3")
@router.get("/v3/")
async def v3_root(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
) -> dict[str, object]:
doc = await require_doc(
conn, service="keystone", resource_type="discovery_version", name="default"
)
# Prefer nested version object when present; otherwise wrap values[0].
if "version" in doc:
return doc
values = (doc.get("versions") or {}).get("values") or []
if values:
host = request_public_host(request)
scheme = request_scheme(request)
version = dict(values[0])
version["links"] = [{"rel": "self", "href": f"{scheme}://{host}:5000/v3/"}]
return {"version": version}
return doc
@router.post("/v3/auth/tokens")
async def create_token(
request: Request,
pool: Annotated[Pool, Depends(get_pool)],
) -> Response:
payload = await request.json()
auth = payload.get("auth") or {}
identity = auth.get("identity") or {}
methods = identity.get("methods") or []
if "password" not in methods:
raise OpenStackError(
"BadRequest", "Only password authentication is supported", status_code=400
)
password_block = (identity.get("password") or {}).get("user") or {}
user_name = password_block.get("name")
password = password_block.get("password")
domain_name = ((password_block.get("domain") or {}).get("name")) or "Default"
if not user_name or password is None:
raise OpenStackError("BadRequest", "user name and password are required", status_code=400)
scope = auth.get("scope") or {}
project_name = None
if "project" in scope:
project_name = (scope["project"] or {}).get("name")
if not project_name and (scope["project"] or {}).get("id"):
# resolve by id later via SQL
project_name = None
project_id = scope["project"]["id"]
else:
project_id = None
else:
project_id = None
host = request_public_host(request)
scheme = request_scheme(request)
async with pool.acquire() as conn:
if project_id and not project_name:
row = await conn.fetchrow(
"SELECT name FROM os_projects WHERE id = $1::uuid", project_id
)
if row is None:
raise OpenStackError("Unauthorized", "Project not found", status_code=401)
project_name = str(row["name"])
token_id, body = await issue_token(
conn,
user_name=str(user_name),
password=str(password),
project_name=str(project_name) if project_name else None,
domain_name=str(domain_name),
host=host,
scheme=scheme,
)
return JSONResponse(status_code=201, content=body, headers={"X-Subject-Token": token_id})
@router.get("/v3/auth/tokens")
async def show_token(
request: Request,
pool: Annotated[Pool, Depends(get_pool)],
) -> Response:
subject = request.headers.get("X-Subject-Token") or extract_token(
{k: v for k, v in request.headers.items()}
)
if not subject:
raise OpenStackError("Unauthorized", "X-Subject-Token required", status_code=401)
# Also require caller token in normal Keystone, but lab accepts subject alone or auth token.
async with pool.acquire() as conn:
ctx = await validate_token(conn, subject)
domain = await conn.fetchrow(
"""SELECT d.id, d.name FROM os_domains d
JOIN os_users u ON u.domain_id = d.id WHERE u.id = $1""",
ctx.user_id,
)
host = request_public_host(request)
scheme = request_scheme(request)
body: dict[str, Any] = {
"token": {
"methods": ["password"],
"expires_at": ctx.expires_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"user": {
"id": str(ctx.user_id),
"name": ctx.user_name,
"domain": {
"id": str(domain["id"]) if domain else "",
"name": str(domain["name"]) if domain else "Default",
},
},
"roles": [{"id": r, "name": r} for r in ctx.roles],
}
}
if ctx.project_id is not None:
body["token"]["project"] = {
"id": str(ctx.project_id),
"name": ctx.project_name,
"domain": {
"id": str(domain["id"]) if domain else "",
"name": str(domain["name"]) if domain else "Default",
},
}
body["token"]["catalog"] = await build_catalog_from_db(conn, host, scheme=scheme)
return JSONResponse(content=body, headers={"X-Subject-Token": subject})
@router.delete("/v3/auth/tokens", status_code=204)
async def revoke_token(
request: Request,
pool: Annotated[Pool, Depends(get_pool)],
) -> Response:
subject = request.headers.get("X-Subject-Token")
if not subject:
raise OpenStackError("BadRequest", "X-Subject-Token required", status_code=400)
async with pool.acquire() as conn:
await conn.execute("UPDATE os_tokens SET revoked = true WHERE id = $1", subject)
return Response(status_code=204)
@router.get("/v3/auth/catalog")
async def auth_catalog(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
if ctx.project_id is None:
raise OpenStackError("Forbidden", "Project-scoped token required", status_code=403)
catalog = await build_catalog_from_db(
conn,
request_public_host(request),
scheme=request_scheme(request),
)
return {"catalog": catalog}
def _project_body(row: Any) -> dict[str, object]:
return {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"enabled": row["enabled"],
"domain_id": str(row["domain_id"]),
"is_domain": False,
"parent_id": str(row["domain_id"]),
"links": {"self": f"/v3/projects/{row['id']}"},
}
def _user_body(row: Any) -> dict[str, object]:
return {
"id": str(row["id"]),
"name": row["name"],
"enabled": row["enabled"],
"domain_id": str(row["domain_id"]),
"links": {"self": f"/v3/users/{row['id']}"},
}
@router.get("/v3/projects")
async def list_projects(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
if ctx.is_admin:
rows = list(
await conn.fetch(
"SELECT id, name, description, enabled, domain_id FROM os_projects ORDER BY name, id"
)
)
else:
rows = list(
await conn.fetch(
"""SELECT p.id, p.name, p.description, p.enabled, p.domain_id
FROM os_projects p
JOIN os_role_assignments a ON a.project_id = p.id
WHERE a.user_id = $1
ORDER BY p.name, p.id""",
ctx.user_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {
"projects": [_project_body(r) for r in page],
"links": {"next": None, "previous": None, "self": "/v3/projects"},
}
if links:
body["projects_links"] = links
return body
@router.get("/v3/projects/{project_id}")
async def show_project(
project_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT id, name, description, enabled, domain_id FROM os_projects WHERE id = $1::uuid",
project_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find project: {project_id}", status_code=404)
if not ctx.is_admin:
allowed = await conn.fetchval(
"""SELECT 1 FROM os_role_assignments
WHERE user_id = $1 AND project_id = $2::uuid LIMIT 1""",
ctx.user_id,
project_id,
)
if not allowed and str(ctx.project_id or "") != project_id:
raise OpenStackError(
"Forbidden", "Not authorized to access this project", status_code=403
)
return {"project": _project_body(row)}
@router.get("/v3/users")
async def list_users(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
if not ctx.is_admin:
rows = list(
await conn.fetch(
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1",
ctx.user_id,
)
)
else:
rows = list(
await conn.fetch("SELECT id, name, enabled, domain_id FROM os_users ORDER BY name, id")
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"users": [_user_body(r) for r in page]}
if links:
body["users_links"] = links
return body
@router.get("/v3/users/{user_id}")
async def show_user(
user_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
if not ctx.is_admin and str(ctx.user_id) != user_id:
raise OpenStackError("Forbidden", "Not authorized to access this user", status_code=403)
row = await conn.fetchrow(
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1::uuid",
user_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find user: {user_id}", status_code=404)
return {"user": _user_body(row)}
@router.get("/v3/domains")
async def list_domains(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
rows = await conn.fetch(
"SELECT id, name, description, enabled FROM os_domains ORDER BY name, id"
)
return {
"domains": [
{
"id": str(r["id"]),
"name": r["name"],
"description": r["description"],
"enabled": r["enabled"],
"links": {"self": f"/v3/domains/{r['id']}"},
}
for r in rows
]
}
@router.get("/v3/domains/{domain_id}")
async def show_domain(
domain_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"""SELECT id, name, description, enabled FROM os_domains
WHERE id::text = $1 OR name = $1""",
domain_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find domain: {domain_id}", status_code=404)
return {
"domain": {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"enabled": row["enabled"],
"links": {"self": f"/v3/domains/{row['id']}"},
}
}
@router.get("/v3/roles")
async def list_roles(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
rows = await conn.fetch("SELECT id, name FROM os_roles ORDER BY name")
return {"roles": [{"id": str(r["id"]), "name": r["name"]} for r in rows]}
@router.get("/v3/roles/{role_id}")
async def show_role(
role_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT id, name FROM os_roles WHERE id::text = $1 OR name = $1",
role_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find role: {role_id}", status_code=404)
return {"role": {"id": str(row["id"]), "name": row["name"]}}
@router.post("/v3/projects", status_code=201)
async def create_project(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
payload = (await request.json()).get("project") or {}
domain_id = payload.get("domain_id") or await conn.fetchval(
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
)
row = await conn.fetchrow(
"""INSERT INTO os_projects(id, domain_id, name, description, enabled)
VALUES($1,$2,$3,$4,$5) RETURNING id, name, description, enabled, domain_id""",
uuid4(),
domain_id,
str(payload.get("name") or f"project-{uuid4().hex[:8]}"),
payload.get("description") or "",
bool(payload.get("enabled", True)),
)
_ = ctx
return {"project": _project_body(row)}
@router.post("/v3/users", status_code=201)
async def create_user(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
from app.security.auth import hash_secret
payload = (await request.json()).get("user") or {}
domain_id = payload.get("domain_id") or await conn.fetchval(
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
)
password = str(payload.get("password") or "secret")
row = await conn.fetchrow(
"""INSERT INTO os_users(id, domain_id, name, password_hash, enabled)
VALUES($1,$2,$3,$4,$5) RETURNING id, name, enabled, domain_id""",
uuid4(),
domain_id,
str(payload.get("name") or f"user-{uuid4().hex[:8]}"),
hash_secret(password, salt=b"openstack-sim-v1"),
bool(payload.get("enabled", True)),
)
_ = ctx
return {"user": _user_body(row)}
@router.post("/v3/domains", status_code=201)
async def create_domain(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
payload = (await request.json()).get("domain") or {}
row = await conn.fetchrow(
"""INSERT INTO os_domains(id, name, description, enabled)
VALUES($1,$2,$3,$4) RETURNING id, name, description, enabled""",
uuid4(),
str(payload.get("name") or f"domain-{uuid4().hex[:8]}"),
payload.get("description") or "",
bool(payload.get("enabled", True)),
)
_ = ctx
return {
"domain": {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"enabled": row["enabled"],
"links": {"self": f"/v3/domains/{row['id']}"},
}
}
@router.post("/v3/roles", status_code=201)
async def create_role(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
payload = (await request.json()).get("role") or {}
row = await conn.fetchrow(
"""INSERT INTO os_roles(id, name) VALUES($1,$2) RETURNING id, name""",
uuid4(),
str(payload.get("name") or f"role-{uuid4().hex[:8]}"),
)
_ = ctx
return {"role": {"id": str(row["id"]), "name": row["name"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
"""Octavia Load Balancer API v2."""
from __future__ import annotations
import json
from typing import Annotated, Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Octavia"])
def _lb(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"project_id": str(row["project_id"]),
"vip_address": row["vip_address"],
"vip_subnet_id": str(row["vip_subnet_id"]) if row["vip_subnet_id"] else None,
"provisioning_status": row["provisioning_status"],
"operating_status": row["operating_status"],
"listeners": row["listeners"]
if not isinstance(row["listeners"], str)
else json.loads(row["listeners"]),
"pools": row["pools"] if not isinstance(row["pools"], str) else json.loads(row["pools"]),
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%S"),
}
@router.get("/v2")
@router.get("/v2/")
@router.get("/v2.0")
@router.get("/v2.0/")
async def octavia_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="octavia", resource_type="discovery_version", name="default"
)
@router.get("/v2/lbaas/loadbalancers")
@router.get("/v2.0/lbaas/loadbalancers")
async def list_lbs(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
rows = list(
await conn.fetch(
"SELECT * FROM os_loadbalancers WHERE project_id=$1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"loadbalancers": [_lb(r) for r in page]}
if links:
body["loadbalancers_links"] = links
return body
@router.post("/v2/lbaas/loadbalancers", status_code=201)
@router.post("/v2.0/lbaas/loadbalancers", status_code=201)
async def create_lb(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
payload = (await request.json()).get("loadbalancer") or {}
defaults = (
await fetch_doc(
conn, service="octavia", resource_type="loadbalancer_defaults", name="default"
)
or {}
)
row = await conn.fetchrow(
"""INSERT INTO os_loadbalancers(id, project_id, name, description, vip_address, vip_subnet_id, provisioning_status, operating_status)
VALUES($1,$2,$3,$4,$5,$6::uuid,'ACTIVE','ONLINE') RETURNING *""",
uuid4(),
ctx.project_id,
payload.get("name") or defaults.get("name") or "lb",
payload.get("description") or "",
payload.get("vip_address") or defaults.get("vip_address"),
payload.get("vip_subnet_id"),
)
return {"loadbalancer": _lb(row)}
@router.get("/v2/lbaas/loadbalancers/{lb_id}")
@router.get("/v2.0/lbaas/loadbalancers/{lb_id}")
async def show_lb(
lb_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT * FROM os_loadbalancers WHERE id::text=$1 AND project_id=$2",
lb_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("NotFound", "Load balancer not found", status_code=404)
return {"loadbalancer": _lb(row)}
async def _update_lb(
resource_id: str,
request: Request,
conn: Connection,
ctx: TokenContext,
) -> dict[str, object]:
payload = (await request.json()).get("loadbalancer") or {}
row = await conn.fetchrow(
"""UPDATE os_loadbalancers
SET name = COALESCE($1, name),
description = COALESCE($2, description)
WHERE id::text = $3 AND project_id = $4
RETURNING *""",
payload.get("name"),
payload.get("description"),
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("NotFound", "Load balancer not found", status_code=404)
return {"loadbalancer": _lb(row)}
@router.put("/v2/lbaas/loadbalancers/{lb_id}")
@router.put("/v2.0/lbaas/loadbalancers/{lb_id}")
@router.patch("/v2/lbaas/loadbalancers/{lb_id}")
@router.patch("/v2.0/lbaas/loadbalancers/{lb_id}")
async def update_lb(
lb_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _update_lb(lb_id, request, conn, ctx)
@router.put("/v2/lbaas/loadbalancers/{id}")
@router.put("/v2.0/lbaas/loadbalancers/{id}")
@router.patch("/v2/lbaas/loadbalancers/{id}")
@router.patch("/v2.0/lbaas/loadbalancers/{id}")
async def update_lb_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _update_lb(id, request, conn, ctx)
@router.delete("/v2/lbaas/loadbalancers/{lb_id}", status_code=204)
@router.delete("/v2.0/lbaas/loadbalancers/{lb_id}", status_code=204)
async def delete_lb(
lb_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
result = await conn.execute(
"DELETE FROM os_loadbalancers WHERE id::text=$1 AND project_id=$2",
lb_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("NotFound", "Load balancer not found", status_code=404)
return Response(status_code=204)
@router.get("/v2/lbaas/listeners")
@router.get("/v2.0/lbaas/listeners")
@router.get("/v2/lbaas/pools")
@router.get("/v2.0/lbaas/pools")
@router.get("/v2/lbaas/healthmonitors")
@router.get("/v2.0/lbaas/healthmonitors")
@router.get("/v2/lbaas/providers")
@router.get("/v2.0/lbaas/providers")
@router.get("/v2/lbaas/flavors")
@router.get("/v2.0/lbaas/flavors")
async def octavia_extension_collections(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
"""Serve Octavia side collections from demo/schema rows."""
import json as _json
key = request.url.path.rstrip("/").split("/")[-1]
resource_type = {
"listeners": "listener",
"pools": "pool",
"healthmonitors": "healthmonitor",
"flavors": "flavor",
"providers": "provider",
}.get(key, key)
rows = await conn.fetch(
"""SELECT id, name, status, data FROM os_api_objects
WHERE service='octavia' AND resource_type=$1
AND (project_id=$2 OR project_id IS NULL)
ORDER BY created_at NULLS LAST, id""",
resource_type,
ctx.project_id,
)
items: list[dict[str, object]] = []
for row in rows:
data = row["data"] if isinstance(row["data"], dict) else _json.loads(row["data"] or "{}")
item = {"id": str(row["id"]), "name": row["name"], **data}
item["id"] = str(row["id"])
items.append(item)
return {key: items}
+91
View File
@@ -0,0 +1,91 @@
"""Placement API (lab subset + demo inventory)."""
from __future__ import annotations
import json
from typing import Annotated, Any
from asyncpg import Connection
from fastapi import APIRouter, Depends
from app.openstack.auth import TokenContext
from app.openstack.db_docs import fetch_doc
from app.openstack.deps import get_conn, require_token
router = APIRouter(tags=["Placement"])
@router.get("/resource_providers")
async def list_resource_providers(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
defaults = await fetch_doc(
conn, service="placement", resource_type="resource_provider_defaults", name="default"
)
default_generation = int((defaults or {}).get("generation") or 0)
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service='placement' AND resource_type='resource_provider'
ORDER BY created_at, name"""
)
providers: list[dict[str, Any]] = []
for row in rows:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = dict(data or {})
providers.append(
{
"id": str(row["id"]),
"uuid": str(row["id"]),
"name": row["name"] or data.get("name") or str(row["id"]),
"generation": int(
data.get("generation")
if data.get("generation") is not None
else default_generation
),
"parent_provider_uuid": data.get("parent_provider_uuid"),
}
)
return {"resource_providers": providers}
@router.get("/allocations/{consumer_uuid}")
async def show_allocations(
consumer_uuid: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
defaults = await fetch_doc(
conn, service="placement", resource_type="allocation_defaults", name="default"
)
default_resources = dict((defaults or {}).get("resources") or {})
consumer_generation = int((defaults or {}).get("consumer_generation") or 0)
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service='placement' AND resource_type='allocation'
AND (data->>'consumer_uuid'=$1 OR id::text=$1 OR name=$1)
ORDER BY created_at""",
consumer_uuid,
)
allocations: dict[str, Any] = {}
for row in rows:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = dict(data or {})
rp = str(data.get("resource_provider") or data.get("resource_provider_id") or row["id"])
resources = (
data.get("resources") if isinstance(data.get("resources"), dict) else default_resources
)
allocations[rp] = {"resources": resources}
if data.get("consumer_generation") is not None:
consumer_generation = int(data["consumer_generation"])
if not allocations and default_resources:
allocations["00000000-0000-0000-0000-000000000001"] = {"resources": default_resources}
elif not allocations:
allocations["00000000-0000-0000-0000-000000000001"] = {
"resources": {"VCPU": 1, "MEMORY_MB": 512}
}
return {"allocations": allocations, "consumer_generation": consumer_generation}
+61
View File
@@ -0,0 +1,61 @@
"""Port-aware root / version discovery (and HTML console for browsers)."""
from __future__ import annotations
from typing import Annotated
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.openstack.db_docs import require_doc
from app.openstack.deps import get_conn
from app.openstack.dispatch import resolve_service
from app.web.assets import console_html
router = APIRouter(tags=["OpenStack"])
def _service_name(request: Request) -> str:
headers = {k.lower(): v for k, v in request.headers.items()}
# Prefer explicit gateway port/service; also pass path for disambiguation.
resolved = resolve_service(headers, path=request.url.path)
if resolved and resolved not in ("", "https"):
return resolved
# Fallback: Host:port when proxies strip/alter X-Forwarded-Port.
host = headers.get("host") or ""
if ":" in host:
try:
port = int(host.rsplit(":", 1)[1])
except ValueError:
port = None
if port is not None:
from app.openstack.dispatch import _PORT_TO_SERVICE
by_host = _PORT_TO_SERVICE.get(port)
if by_host:
return by_host
return "keystone"
def _wants_html(request: Request) -> bool:
accept = (request.headers.get("accept") or "*/*").lower()
if accept.startswith("application/json"):
return False
return "text/html" in accept.split(",")[0] or (
"text/html" in accept and "application/json" not in accept
)
async def _json_versions(conn: Connection, name: str) -> dict[str, object]:
return await require_doc(conn, service=name, resource_type="discovery_version", name="default")
@router.get("/")
async def root(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
):
if _wants_html(request):
return HTMLResponse(console_html(), headers={"Cache-Control": "no-store"})
return JSONResponse(await _json_versions(conn, _service_name(request)))
+185
View File
@@ -0,0 +1,185 @@
"""Swift Object Storage API v1."""
from __future__ import annotations
import json
from typing import Annotated
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import PlainTextResponse
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Swift"])
def _account(ctx: TokenContext) -> str:
return f"AUTH_{ctx.project_id or ctx.user_id}"
@router.get("/info")
async def swift_info(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="swift", resource_type="info", name="default")
@router.get("/v1/{account}")
async def list_containers(
account: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> list[dict[str, object]]:
_ = account
rows = await conn.fetch(
"SELECT name, meta, created_at FROM os_swift_containers WHERE account=$1 ORDER BY name",
_account(ctx),
)
result = []
for r in rows:
count = await conn.fetchval(
"SELECT count(*) FROM os_swift_objects WHERE account=$1 AND container=$2",
_account(ctx),
r["name"],
)
bytes_total = await conn.fetchval(
"SELECT COALESCE(sum(bytes),0) FROM os_swift_objects WHERE account=$1 AND container=$2",
_account(ctx),
r["name"],
)
result.append({"name": r["name"], "count": int(count or 0), "bytes": int(bytes_total or 0)})
return result
@router.put("/v1/{account}/{container}", status_code=201)
async def create_container(
account: str,
container: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
await conn.execute(
"""INSERT INTO os_swift_containers(account, name, meta)
VALUES($1,$2,'{}'::jsonb) ON CONFLICT DO NOTHING""",
_account(ctx),
container,
)
return Response(status_code=201)
@router.get("/v1/{account}/{container}")
async def list_objects(
account: str,
container: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> list[dict[str, object]]:
_ = account
rows = await conn.fetch(
"""SELECT name, bytes, content_type, created_at FROM os_swift_objects
WHERE account=$1 AND container=$2 ORDER BY name""",
_account(ctx),
container,
)
return [
{
"name": r["name"],
"bytes": r["bytes"],
"content_type": r["content_type"],
"last_modified": r["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
"hash": "0",
}
for r in rows
]
@router.put("/v1/{account}/{container}/{object_name:path}", status_code=201)
async def put_object(
account: str,
container: str,
object_name: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
body = await request.body()
await conn.execute(
"""INSERT INTO os_swift_containers(account, name, meta)
VALUES($1,$2,'{}'::jsonb) ON CONFLICT DO NOTHING""",
_account(ctx),
container,
)
await conn.execute(
"""INSERT INTO os_swift_objects(id, account, container, name, content_type, bytes, body, meta)
VALUES($1,$2,$3,$4,$5,$6,$7,'{}'::jsonb)
ON CONFLICT (account, container, name) DO UPDATE
SET bytes=EXCLUDED.bytes, body=EXCLUDED.body, content_type=EXCLUDED.content_type""",
uuid4(),
_account(ctx),
container,
object_name,
request.headers.get("content-type") or "application/octet-stream",
len(body),
body,
)
return Response(status_code=201, headers={"Etag": "0"})
@router.post("/v1/{account}/{container}/{object_name:path}", status_code=202)
async def post_object(
account: str,
container: str,
object_name: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
# Metadata update / create — reuse PUT semantics
return await put_object(account, container, object_name, request, conn, ctx)
@router.get("/v1/{account}/{container}/{object_name:path}")
async def get_object(
account: str,
container: str,
object_name: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
row = await conn.fetchrow(
"""SELECT body, content_type FROM os_swift_objects
WHERE account=$1 AND container=$2 AND name=$3""",
_account(ctx),
container,
object_name,
)
if row is None:
raise OpenStackError("NotFound", "Object not found", status_code=404)
return Response(content=bytes(row["body"] or b""), media_type=row["content_type"])
@router.delete("/v1/{account}/{container}/{object_name:path}", status_code=204)
async def delete_object(
account: str,
container: str,
object_name: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
result = await conn.execute(
"DELETE FROM os_swift_objects WHERE account=$1 AND container=$2 AND name=$3",
_account(ctx),
container,
object_name,
)
if result.endswith("0"):
raise OpenStackError("NotFound", "Object not found", status_code=404)
return Response(status_code=204)
+762
View File
@@ -0,0 +1,762 @@
"""Schema-driven OpenStack API engine — surface-complete ops from contract packs."""
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4
from asyncpg import Connection
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
from app.db.pool import AsyncpgDatabase
from app.openstack.auth import TokenContext, extract_token, validate_token
from app.openstack.contract_loader import ensure_loaded, get_runtime
from app.openstack.errors import OpenStackError
from app.openstack.opspec import OperationSpec, ServicePack
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
def _singular(collection_key: str) -> str:
if collection_key.endswith("ies"):
return collection_key[:-3] + "y"
if collection_key.endswith("ses"):
return collection_key[:-2]
if collection_key.endswith("s") and not collection_key.endswith("ss"):
return collection_key[:-1]
return collection_key
def _fastapi_path(path: str) -> str:
"""Convert {param} to FastAPI {param} (already compatible)."""
return path if path.startswith("/") else f"/{path}"
def _parent_scope(path: str, path_params: dict[str, str]) -> dict[str, str]:
parent = {k: v for k, v in path_params.items() if k != "id"}
# Nested collections like /resource_providers/{id}/inventories keep the parent
# id under useful aliases so list filters can match seeded child rows.
if "id" in path_params:
match = re.search(r"/([^/]+)/\{id\}(?:/|$)", path)
if match:
segment = match.group(1)
singular = (
segment[:-1] if segment.endswith("s") and not segment.endswith("ss") else segment
)
parent.setdefault(f"{singular}_id", path_params["id"])
parent.setdefault(singular, path_params["id"])
parent.setdefault("parent_id", path_params["id"])
parent.setdefault("resource_provider", path_params["id"])
parent.setdefault("resource_provider_id", path_params["id"])
parent.setdefault("server_id", path_params["id"])
return parent
def _path_ends_with_item_param(path: str) -> bool:
"""True for item show paths (/x/{id}), false for nested collections (/x/{id}/ys)."""
trimmed = path.rstrip("/")
return bool(re.search(r"/\{[^{}/]+\}$", trimmed))
def _row_item(row: Any) -> dict[str, Any]:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
item = dict(data or {})
item.setdefault("id", str(row["id"]))
item.setdefault("name", row["name"])
item.setdefault("status", row["status"])
if row["project_id"] is not None:
item.setdefault("project_id", str(row["project_id"]))
item.setdefault("tenant_id", str(row["project_id"]))
item.setdefault("created_at", row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
item.setdefault("updated_at", row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
return item
def _paginate(
items: list[dict[str, Any]], request: Request
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
try:
limit = int(request.query_params.get("limit") or 0)
except ValueError:
limit = 0
marker = request.query_params.get("marker")
start = 0
if marker:
for i, item in enumerate(items):
if str(item.get("id")) == marker or str(item.get("name")) == marker:
start = i + 1
break
page = items[start:]
links: dict[str, Any] = {}
if limit > 0:
page = page[:limit]
if start + limit < len(items):
last = page[-1] if page else None
if last:
links["next"] = str(last.get("id") or last.get("name"))
return page, links
def _check_microversion(request: Request, op: OperationSpec, pack: ServicePack) -> None:
if not op.microversion_min and not pack.max_microversion:
return
requested = getattr(request.state, "microversion", None)
runtime = get_runtime()
override = runtime.active_microversion(pack.name)
chosen = requested or override or pack.default_microversion
if not chosen:
return
maximum = pack.max_microversion or op.microversion_max
minimum = op.microversion_min or pack.default_microversion
if maximum and _mv_tuple(chosen) > _mv_tuple(maximum):
raise OpenStackError(
"VersionNotFound",
f"Microversion {chosen} exceeds max {maximum}",
status_code=406,
)
if minimum and _mv_tuple(chosen) < _mv_tuple(minimum):
raise OpenStackError(
"VersionNotFound",
f"Microversion {chosen} below min {minimum}",
status_code=406,
)
def _mv_tuple(value: str) -> tuple[int, ...]:
parts = []
for piece in value.split("."):
try:
parts.append(int(piece))
except ValueError:
parts.append(0)
return tuple(parts)
def _fixture_or_item(
op: OperationSpec, item: dict[str, Any] | None, *, list_mode: bool = False
) -> Any:
if op.response_fixture is not None:
return op.response_fixture
key = op.collection_key
if list_mode and key:
return {key: item if isinstance(item, list) else []}
if op.item_key:
return {op.item_key: item or {}}
if key:
return {_singular(key): item or {}}
return item or {}
async def _list_objects(
conn: Connection,
*,
service: str,
resource_type: str,
project_id: Any,
parent: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
if project_id is None:
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service=$1 AND resource_type=$2
ORDER BY created_at""",
service,
resource_type,
)
else:
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service=$1 AND resource_type=$2
AND (project_id=$3 OR project_id IS NULL)
ORDER BY created_at""",
service,
resource_type,
project_id,
)
items = [_row_item(r) for r in rows]
if parent:
filtered = []
for item in items:
ok = True
for pk, pv in parent.items():
if str(item.get(pk) or item.get("parent_id") or "") not in {pv, str(item.get(pk))}:
# soft filter: keep if parent key absent
if pk in item and str(item[pk]) != pv:
ok = False
break
if ok:
filtered.append(item)
return filtered
return items
def _route_priority(op: OperationSpec) -> tuple[int, int, str]:
"""Static paths before templated ones so /detail is not captured by /{id}."""
path = op.path
braces = path.count("{")
detail_bias = 0 if path.rstrip("/").endswith("/detail") else 1
return (braces, detail_bias, path)
def build_schema_router(pack: ServicePack) -> APIRouter:
router = APIRouter(tags=[f"Schema:{pack.name}"])
# Deduplicate by method+path so FastAPI does not register twice.
seen: set[tuple[str, str]] = set()
for op in sorted(pack.operations, key=_route_priority):
key = (op.method, op.path)
if key in seen:
continue
seen.add(key)
_register_operation(router, pack, op)
return router
def _register_operation(router: APIRouter, pack: ServicePack, op: OperationSpec) -> None:
path = _fastapi_path(op.path)
name = f"schema-{pack.name}-{op.operation_id}"
async def endpoint(request: Request) -> Response:
return await _dispatch(request, pack, op)
router.add_api_route(
path,
endpoint,
methods=[op.method],
name=name,
include_in_schema=True,
)
async def _resolve_ctx(request: Request, *, need_project: bool) -> TokenContext:
database = request.app.state.database
assert isinstance(database, AsyncpgDatabase)
token = extract_token({k: v for k, v in request.headers.items()})
if not token:
raise OpenStackError(
"Unauthorized",
"The request you have made requires authentication.",
status_code=401,
)
async with database.pool.acquire() as conn:
ctx = await validate_token(conn, token)
if need_project and ctx.project_id is None:
raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401)
return ctx
def _has_id_param(path: str) -> bool:
return "{id}" in path or "{name}" in path
async def _dispatch(request: Request, pack: ServicePack, op: OperationSpec) -> Response:
_check_microversion(request, op, pack)
ctx: TokenContext | None = None
if op.requires_auth:
ctx = await _resolve_ctx(request, need_project=op.requires_project)
database = request.app.state.database
assert isinstance(database, AsyncpgDatabase)
async with database.pool.acquire() as conn:
path_params = dict(request.path_params)
if op.kind == "action" or op.path.rstrip("/").endswith("/action"):
return await _handle_action(request, conn, pack, op, ctx, path_params)
if op.method == "GET":
# Literal "/detail" list views must not be treated as item show.
if op.kind == "detail" or str(path_params.get("id") or "").lower() == "detail":
return await _handle_list(request, conn, pack, op, ctx, path_params)
# Nested collection paths contain {id} but list children, not show the parent id.
if op.kind == "collection" or (
op.collection_key
and _has_id_param(op.path)
and not _path_ends_with_item_param(op.path)
):
return await _handle_list(request, conn, pack, op, ctx, path_params)
if _path_ends_with_item_param(op.path) or op.kind == "item":
return await _handle_show(request, conn, pack, op, ctx, path_params)
return await _handle_list(request, conn, pack, op, ctx, path_params)
if op.method == "POST":
if _path_ends_with_item_param(op.path) and op.kind != "collection":
return await _handle_action(request, conn, pack, op, ctx, path_params)
if _has_id_param(op.path) and op.kind == "collection":
return await _handle_create(request, conn, pack, op, ctx, path_params)
if _has_id_param(op.path) and op.kind != "collection":
return await _handle_action(request, conn, pack, op, ctx, path_params)
return await _handle_create(request, conn, pack, op, ctx, path_params)
if op.method in {"PUT", "PATCH"}:
return await _handle_update(request, conn, pack, op, ctx, path_params)
if op.method == "DELETE":
return await _handle_delete(request, conn, pack, op, ctx, path_params)
raise OpenStackError(
"BadRequest", f"Unsupported operation {op.method} {op.path}", status_code=400
)
async def _handle_list(
request: Request,
conn: Connection,
pack: ServicePack,
op: OperationSpec,
ctx: TokenContext | None,
path_params: dict[str, str],
) -> Response:
if op.response_fixture is not None:
return JSONResponse(op.response_fixture)
# Discovery docs live in PostgreSQL (seed_discovery_documents).
from app.openstack.db_docs import require_doc
if op.resource_type == "ping":
return JSONResponse(
await require_doc(conn, service=pack.name, resource_type="ping", name="default")
)
if op.resource_type == "health":
return JSONResponse(
await require_doc(conn, service=pack.name, resource_type="health", name="default")
)
if op.resource_type == "limit" and op.collection_key == "limits":
doc = await require_doc(conn, service=pack.name, resource_type="limits", name="default")
# Overlay live usage for cinder when volumes table is present.
if pack.name == "cinder" and ctx and ctx.project_id is not None:
used = await conn.fetchrow(
"""SELECT count(*)::int AS volumes,
coalesce(sum(size), 0)::int AS gigabytes
FROM os_volumes WHERE project_id=$1""",
ctx.project_id,
)
absolute = dict((doc.get("limits") or {}).get("absolute") or {})
if used:
absolute["totalVolumesUsed"] = int(used["volumes"])
absolute["totalGigabytesUsed"] = int(used["gigabytes"])
return JSONResponse(
{
"limits": {
"rate": (doc.get("limits") or {}).get("rate") or [],
"absolute": absolute,
}
}
)
return JSONResponse(doc)
if op.resource_type == "version":
return JSONResponse(
await require_doc(
conn, service=pack.name, resource_type="discovery_version", name="default"
)
)
project_id = ctx.project_id if ctx else None
items = await _list_objects(
conn,
service=pack.name,
resource_type=op.resource_type,
project_id=project_id,
parent=_parent_scope(op.path, path_params) or None,
)
# soft filter query params
for qk, qv in request.query_params.items():
if qk in {"limit", "marker", "sort_key", "sort_dir", "fields"}:
continue
items = [i for i in items if str(i.get(qk, qv)) == qv or qk not in i]
# Nested soft-filter may hide parent-scoped rows — re-read without parent filter
# but still only from PostgreSQL (no synthetic templates).
if not items and op.method == "GET" and op.kind in {"collection", "detail", "custom"}:
parent = _parent_scope(op.path, path_params) or None
if parent:
items = await _list_objects(
conn,
service=pack.name,
resource_type=op.resource_type,
project_id=project_id,
parent=None,
)
page, links = _paginate(items, request)
key = op.collection_key or "items"
body: dict[str, Any] = {key: page}
if links:
body[f"{key}_links"] = links
return JSONResponse(body, status_code=op.status_code)
async def _handle_create(
request: Request,
conn: Connection,
pack: ServicePack,
op: OperationSpec,
ctx: TokenContext | None,
path_params: dict[str, str],
) -> Response:
if ctx is None or ctx.project_id is None:
raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401)
try:
payload = await request.json()
except Exception:
payload = {}
key = op.item_key or (op.collection_key and _singular(op.collection_key)) or "resource"
body = payload.get(key) if isinstance(payload, dict) else None
if body is None and isinstance(payload, dict):
body = payload.get(op.collection_key) or payload
if not isinstance(body, dict):
body = {"value": body}
item_id = uuid4()
name = str(body.get("name") or body.get("stack_name") or op.resource_type)
status = str(body.get("status") or body.get("stack_status") or "ACTIVE")
data = {**body, "id": str(item_id), "name": name, "status": status, **path_params}
row = await conn.fetchrow(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb) RETURNING *""",
item_id,
pack.name,
op.resource_type,
ctx.project_id,
name,
status,
json.dumps(data),
)
content = _fixture_or_item(op, _row_item(row))
return JSONResponse(
content, status_code=op.create_status if op.method == "POST" else op.status_code
)
async def _handle_show(
request: Request,
conn: Connection,
pack: ServicePack,
op: OperationSpec,
ctx: TokenContext | None,
path_params: dict[str, str],
) -> Response:
item_id = (
path_params.get("id") or path_params.get("name") or next(iter(path_params.values()), None)
)
if not item_id:
# custom GET without id — fall back to list-like empty / fixture
return await _handle_list(request, conn, pack, op, ctx, path_params)
row = await conn.fetchrow(
"""SELECT * FROM os_api_objects
WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3)
LIMIT 1""",
pack.name,
op.resource_type,
item_id,
)
if row is None:
raise OpenStackError("NotFound", f"{op.resource_type} {item_id} not found", status_code=404)
return JSONResponse(_fixture_or_item(op, _row_item(row)), status_code=op.status_code)
async def _handle_update(
request: Request,
conn: Connection,
pack: ServicePack,
op: OperationSpec,
ctx: TokenContext | None,
path_params: dict[str, str],
) -> Response:
if ctx is None or ctx.project_id is None:
raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401)
item_id = path_params.get("id") or next(iter(path_params.values()), None)
if not item_id:
raise OpenStackError("BadRequest", "Missing id", status_code=400)
try:
payload = await request.json()
except Exception:
payload = {}
key = op.item_key or (op.collection_key and _singular(op.collection_key))
body = payload.get(key, payload) if isinstance(payload, dict) else {}
if not isinstance(body, dict):
body = {}
row = await conn.fetchrow(
"""SELECT * FROM os_api_objects
WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) AND project_id=$4""",
pack.name,
op.resource_type,
item_id,
ctx.project_id,
)
if row is None:
# Lab upsert: pack PUT/PATCH against unknown ids still succeed (surface-complete).
try:
new_id = UUID(str(item_id))
except Exception:
new_id = uuid4()
data = {
"id": str(new_id),
"name": str(body.get("name") or op.resource_type),
"status": "ACTIVE",
**body,
**path_params,
}
created = await conn.fetchrow(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
ON CONFLICT (id) DO UPDATE SET
name=EXCLUDED.name, status=EXCLUDED.status, data=EXCLUDED.data, updated_at=now()
RETURNING *""",
new_id,
pack.name,
op.resource_type,
ctx.project_id,
str(data.get("name") or op.resource_type),
str(data.get("status") or "ACTIVE"),
json.dumps(data),
)
if op.status_code == 204:
return Response(status_code=204)
return JSONResponse(_fixture_or_item(op, _row_item(created)), status_code=200)
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = {**(data or {}), **body, "id": str(row["id"])}
updated = await conn.fetchrow(
"""UPDATE os_api_objects
SET name=$1, status=$2, data=$3::jsonb, updated_at=now()
WHERE id=$4 RETURNING *""",
str(data.get("name") or row["name"]),
str(data.get("status") or row["status"]),
json.dumps(data),
row["id"],
)
if op.status_code == 204:
return Response(status_code=204)
return JSONResponse(_fixture_or_item(op, _row_item(updated)), status_code=200)
async def _handle_delete(
request: Request,
conn: Connection,
pack: ServicePack,
op: OperationSpec,
ctx: TokenContext | None,
path_params: dict[str, str],
) -> Response:
item_id = path_params.get("id") or next(iter(path_params.values()), None)
if not item_id:
return Response(status_code=204)
project_id = ctx.project_id if ctx else None
if project_id is not None:
await conn.execute(
"""DELETE FROM os_api_objects
WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) AND project_id=$4""",
pack.name,
op.resource_type,
item_id,
project_id,
)
else:
await conn.execute(
"""DELETE FROM os_api_objects
WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3)""",
pack.name,
op.resource_type,
item_id,
)
return Response(status_code=op.status_code if op.status_code in {202, 204} else 204)
async def _handle_action(
request: Request,
conn: Connection,
pack: ServicePack,
op: OperationSpec,
ctx: TokenContext | None,
path_params: dict[str, str],
) -> Response:
try:
payload = await request.json()
except Exception:
payload = {}
action = op.action_name if op.action_name and op.action_name != "*" else None
if action is None and isinstance(payload, dict) and payload:
action = next(iter(payload.keys()))
item_id = path_params.get("id") or path_params.get("server_id")
# record action history for nova-like resources
if ctx and item_id:
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
ON CONFLICT DO NOTHING""",
uuid4(),
pack.name,
"instance_action" if pack.name == "nova" else f"{op.resource_type}_action",
ctx.project_id,
action or "action",
"DONE",
json.dumps(
{
"action": action,
"instance_uuid": item_id,
"request_id": request.headers.get("x-openstack-request-id") or str(uuid4()),
"message": None,
"start_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
),
)
# update parent status for common power actions
if action in {"os-start", "unshelve", "resume", "unpause", "unrescue"}:
new_status = "ACTIVE"
elif action in {"os-stop", "shelve", "shelveOffload"}:
new_status = "SHUTOFF"
elif action in {"pause"}:
new_status = "PAUSED"
elif action in {"suspend"}:
new_status = "SUSPENDED"
else:
new_status = None
if new_status and pack.name == "nova":
await conn.execute(
"UPDATE os_servers SET status=$1, updated_at=now() WHERE id::text=$2",
new_status,
item_id,
)
await conn.execute(
"""UPDATE os_api_objects SET status=$1, data = jsonb_set(data, '{status}', to_jsonb($1::text)), updated_at=now()
WHERE service=$2 AND resource_type='server' AND id::text=$3""",
new_status,
pack.name,
item_id,
)
if op.status_code == 204:
return Response(status_code=204)
if action in {"os-getConsoleOutput"} and item_id:
from app.openstack.db_docs import require_doc
row = await conn.fetchrow(
"""SELECT data FROM os_api_objects
WHERE service='nova' AND resource_type='console_output'
AND (name=$1 OR data->>'server_id'=$1)
ORDER BY updated_at DESC LIMIT 1""",
item_id,
)
if row is not None:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
output = str((data or {}).get("output") or "")
else:
template = await require_doc(
conn, service="nova", resource_type="console_output_template", name="default"
)
output = str(template.get("output") or "")
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'nova','console_output',$2,$3,'ACTIVE',$4::jsonb)""",
uuid4(),
ctx.project_id if ctx else None,
item_id,
json.dumps({"server_id": item_id, "output": output}),
)
return JSONResponse({"output": output})
if action in {"os-getVNCConsole", "remote-consoles"} or "console" in (action or "").lower():
from app.openstack.db_docs import require_doc
console_type = ""
console_url = ""
if item_id:
row = await conn.fetchrow(
"""SELECT data FROM os_api_objects
WHERE service='nova' AND resource_type='console'
AND (name=$1 OR data->>'server_id'=$1)
ORDER BY updated_at DESC LIMIT 1""",
item_id,
)
if row is not None:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
console_type = str((data or {}).get("type") or "")
console_url = str((data or {}).get("url") or "")
else:
template = await require_doc(
conn, service="nova", resource_type="console_template", name="default"
)
console_type = str(template.get("type") or "")
console_url = str(template.get("url") or "").replace("__SERVER_ID__", item_id)
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'nova','console',$2,$3,'ACTIVE',$4::jsonb)""",
uuid4(),
ctx.project_id if ctx else None,
item_id,
json.dumps({"server_id": item_id, "type": console_type, "url": console_url}),
)
return JSONResponse({"console": {"type": console_type, "url": console_url}})
if action == "createImage" and item_id and ctx is not None:
from app.openstack.db_docs import fetch_doc
image_id = uuid4()
body = payload.get("createImage") if isinstance(payload, dict) else None
defaults = (
await fetch_doc(conn, service="glance", resource_type="image_defaults", name="default")
or {}
)
name = "snapshot"
if isinstance(body, dict):
name = str(body.get("name") or name)
await conn.execute(
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
container_format, owner_project_id)
VALUES($1,$2,'active',$3,0,$4,$5,$6)""",
image_id,
name,
defaults.get("visibility") or "private",
defaults.get("disk_format") or "qcow2",
defaults.get("container_format") or "bare",
ctx.project_id,
)
return JSONResponse({"image_id": str(image_id)}, status_code=202)
return Response(status_code=op.status_code)
def mount_schema_services(
app: Any,
*,
series: str = "dalmatian",
handlers: Any | None = None,
) -> int:
"""Register one FastAPI route per contract (method, path). Returns route count."""
from app.openstack.registry import HandlerRegistry, mount_contract_services
runtime = ensure_loaded(series)
registry = handlers if isinstance(handlers, HandlerRegistry) else HandlerRegistry()
count = mount_contract_services(
app,
packs=runtime.packs,
handlers=registry,
dispatch_fn=_dispatch,
)
app.state.openstack_contract = runtime
app.state.openstack_handlers = registry
return count
def remount_schema_services(app: Any, series: str) -> dict[str, Any]:
"""Reload pack metadata and rebuild per-path contract routes on the app router."""
from app.openstack.registry import HandlerRegistry, mount_contract_services
runtime = get_runtime()
summary = runtime.reload(series)
handlers = getattr(app.state, "openstack_handlers", None)
if not isinstance(handlers, HandlerRegistry):
handlers = HandlerRegistry()
app.state.openstack_handlers = handlers
count = mount_contract_services(
app,
packs=runtime.packs,
handlers=handlers,
dispatch_fn=_dispatch,
)
app.state.openstack_contract = runtime
app.state.openstack_schema_ops = count
summary = {**summary, "routes_mounted": count}
return summary
+578
View File
@@ -0,0 +1,578 @@
"""Seed OpenStack identity and sample cloud resources."""
from __future__ import annotations
from asyncpg import Connection
from app.openstack.ids import oid
from app.security.auth import hash_secret
async def seed_openstack(conn: Connection, *, password: str = "secret") -> dict[str, object]:
"""Idempotent OpenStack lab seed (admin + demo project/user + sample resources)."""
domain_id = oid("domain:Default")
admin_project = oid("project:admin")
demo_project = oid("project:demo")
admin_user = oid("user:admin")
demo_user = oid("user:demo")
role_admin = oid("role:admin")
role_member = oid("role:member")
pw = hash_secret(password, salt=b"openstack-sim-v1")
await conn.execute(
"""INSERT INTO os_domains(id, name, description, enabled)
VALUES($1, 'Default', 'Default domain', true)
ON CONFLICT (id) DO NOTHING""",
domain_id,
)
await conn.execute(
"""INSERT INTO os_projects(id, domain_id, name, description, enabled) VALUES
($1, $3, 'admin', 'Admin project', true),
($2, $3, 'demo', 'Demo project', true)
ON CONFLICT (id) DO NOTHING""",
admin_project,
demo_project,
domain_id,
)
await conn.execute(
"""INSERT INTO os_users(id, domain_id, name, password_hash, enabled) VALUES
($1, $3, 'admin', $4, true),
($2, $3, 'demo', $4, true)
ON CONFLICT (id) DO NOTHING""",
admin_user,
demo_user,
domain_id,
pw,
)
await conn.execute(
"""INSERT INTO os_roles(id, name) VALUES
($1, 'admin'), ($2, 'member')
ON CONFLICT (id) DO NOTHING""",
role_admin,
role_member,
)
await conn.execute(
"""INSERT INTO os_role_assignments(id, role_id, user_id, project_id) VALUES
($1, $3, $5, $7),
($2, $4, $6, $8)
ON CONFLICT (role_id, user_id, project_id) DO NOTHING""",
oid("assign:admin-admin"),
oid("assign:demo-member"),
role_admin,
role_member,
admin_user,
demo_user,
admin_project,
demo_project,
)
# admin also admin on demo for convenience
await conn.execute(
"""INSERT INTO os_role_assignments(id, role_id, user_id, project_id)
VALUES($1, $2, $3, $4)
ON CONFLICT (role_id, user_id, project_id) DO NOTHING""",
oid("assign:admin-demo-admin"),
role_admin,
admin_user,
demo_project,
)
flavors = [
("1", "m1.tiny", 1, 512, 1),
("2", "m1.small", 1, 2048, 20),
("3", "m1.medium", 2, 4096, 40),
("4", "m1.large", 4, 8192, 80),
]
for fid, name, vcpus, ram, disk in flavors:
await conn.execute(
"""INSERT INTO os_flavors(id, name, vcpus, ram, disk, is_public)
VALUES($1, $2, $3, $4, $5, true)
ON CONFLICT (id) DO NOTHING""",
fid,
name,
vcpus,
ram,
disk,
)
cirros = oid("image:cirros")
ubuntu = oid("image:ubuntu")
await conn.execute(
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
container_format, owner_project_id)
VALUES
($1, 'cirros', 'active', 'public', 13287936, 'qcow2', 'bare', $3),
($2, 'ubuntu-22.04', 'active', 'public', 400000000, 'qcow2', 'bare', $3)
ON CONFLICT (id) DO NOTHING""",
cirros,
ubuntu,
admin_project,
)
demo_net = oid("net:demo-net")
await conn.execute(
"""INSERT INTO os_networks(id, project_id, name, status, shared, admin_state_up)
VALUES($1, $2, 'demo-net', 'ACTIVE', false, true)
ON CONFLICT (id) DO NOTHING""",
demo_net,
demo_project,
)
demo_subnet = oid("subnet:demo-subnet")
await conn.execute(
"""INSERT INTO os_subnets(id, network_id, project_id, name, cidr, ip_version, gateway_ip)
VALUES($1, $2, $3, 'demo-subnet', '10.0.0.0/24', 4, '10.0.0.1')
ON CONFLICT (id) DO NOTHING""",
demo_subnet,
demo_net,
demo_project,
)
vol = oid("volume:demo-vol")
await conn.execute(
"""INSERT INTO os_volumes(id, project_id, name, status, size, volume_type, bootable)
VALUES($1, $2, 'demo-volume', 'available', 10, 'lvmdriver-1', false)
ON CONFLICT (id) DO NOTHING""",
vol,
demo_project,
)
server = oid("server:demo-1")
await conn.execute(
"""INSERT INTO os_servers(id, project_id, user_id, name, status, flavor_id, image_id, addresses, metadata)
VALUES($1, $2, $3, 'demo-instance', 'ACTIVE', '2', $4,
$5::jsonb, $6::jsonb)
ON CONFLICT (id) DO NOTHING""",
server,
demo_project,
demo_user,
cirros,
'{"demo-net":[{"OS-EXT-IPS-MAC:mac_addr":"fa:16:3e:00:00:01","version":4,"addr":"10.0.0.12","OS-EXT-IPS:type":"fixed"}]}',
'{"env":"lab","_tags":["lab","env","demo"]}',
)
await seed_openstack_extras(conn)
from app.openstack.pack_seed import seed_pack_surface_samples
from app.openstack.seed_discovery import seed_discovery_documents
await seed_discovery_documents(conn)
await seed_pack_surface_samples(conn, per_type=3)
# Minimal topology tables (011+) — ignore if migration not applied yet.
try:
await conn.execute(
"""INSERT INTO os_availability_zones(name, zone_state)
VALUES('nova', '{"available": true}'::jsonb)
ON CONFLICT (name) DO NOTHING"""
)
await conn.execute(
"""INSERT INTO os_hypervisors(
id, hypervisor_hostname, state, status, host_ip, vcpus, vcpus_used,
memory_mb, memory_mb_used, local_gb, local_gb_used, running_vms,
service_host, availability_zone)
VALUES(1,'compute-1','up','enabled','10.20.0.10',64,1,262144,2048,2000,20,1,'compute-1','nova')
ON CONFLICT (id) DO NOTHING"""
)
await conn.execute(
"""INSERT INTO os_demo_meta(key, value) VALUES('profile','minimal')
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()"""
)
except Exception:
pass
return {
"domain": "Default",
"users": ["admin", "demo"],
"password": password,
"projects": ["admin", "demo"],
"sample_server": "demo-instance",
"profile": "minimal",
}
async def seed_openstack_extras(conn: Connection) -> None:
"""Seed routers, SG, ironic nodes, LB, heat stack, swift objects, generic services."""
import json
from uuid import uuid4
demo_project = oid("project:demo")
admin_project = oid("project:admin")
demo_user = oid("user:demo")
# default security group
sg = oid("sg:demo-default")
await conn.execute(
"""INSERT INTO os_security_groups(id, project_id, name, description)
VALUES($1,$2,'default','Default security group') ON CONFLICT (id) DO NOTHING""",
sg,
demo_project,
)
for direction, proto, pmin, pmax, prefix in (
("egress", None, None, None, None),
("ingress", "tcp", 22, 22, "0.0.0.0/0"),
("ingress", "icmp", None, None, "0.0.0.0/0"),
):
await conn.execute(
"""INSERT INTO os_security_group_rules(id, security_group_id, project_id, direction, ethertype, protocol, port_range_min, port_range_max, remote_ip_prefix)
VALUES($1,$2,$3,$4,'IPv4',$5,$6,$7,$8) ON CONFLICT (id) DO NOTHING""",
oid(f"sgrule:{direction}:{proto}:{pmin}"),
sg,
demo_project,
direction,
proto,
pmin,
pmax,
prefix,
)
await conn.execute(
"""INSERT INTO os_routers(id, project_id, name, status, admin_state_up, external_gateway_info)
VALUES($1,$2,'demo-router','ACTIVE',true,NULL) ON CONFLICT (id) DO NOTHING""",
oid("router:demo"),
demo_project,
)
await conn.execute(
"""INSERT INTO os_nodes(id, name, driver, provision_state, power_state, resource_class, properties, driver_info, ports)
VALUES($1,'baremetal-1','ipmi','available','power off','baremetal',
'{"cpus":64,"memory_mb":262144,"local_gb":2000}'::jsonb,'{}'::jsonb,'[]'::jsonb)
ON CONFLICT (id) DO NOTHING""",
oid("node:baremetal-1"),
)
await conn.execute(
"""INSERT INTO os_loadbalancers(id, project_id, name, description, vip_address, provisioning_status, operating_status)
VALUES($1,$2,'demo-lb','Seed LB','10.0.0.50','ACTIVE','ONLINE')
ON CONFLICT (id) DO NOTHING""",
oid("lb:demo"),
demo_project,
)
await conn.execute(
"""INSERT INTO os_stacks(id, project_id, stack_name, stack_status, description, template, parameters, outputs)
VALUES($1,$2,'demo-stack','CREATE_COMPLETE','Seed stack','{"heat_template_version":"2015-04-30"}'::jsonb,'{}'::jsonb,'[]'::jsonb)
ON CONFLICT (id) DO NOTHING""",
oid("stack:demo"),
demo_project,
)
for project in (demo_project, admin_project):
account = f"AUTH_{project}"
await conn.execute(
"""INSERT INTO os_swift_containers(account, name, meta)
VALUES($1,'images','{}'::jsonb) ON CONFLICT DO NOTHING""",
account,
)
await conn.execute(
"""INSERT INTO os_swift_objects(id, account, container, name, content_type, bytes, body, meta)
VALUES($1,$2,'images','readme.txt','text/plain',12,$3,'{}'::jsonb)
ON CONFLICT (account, container, name) DO NOTHING""",
oid(f"swift:readme:{account}"),
account,
b"hello swift\n",
)
for user_id, key_name in ((demo_user, "demo-key"), (oid("user:admin"), "admin-key")):
await conn.execute(
"""INSERT INTO os_keypairs(name, user_id, fingerprint, public_key, type)
VALUES($1,$2,$3,$4,'ssh')
ON CONFLICT DO NOTHING""",
key_name,
user_id,
f"https://example.invalid/{key_name}",
f"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC {key_name}@lab",
)
# Sample objects for every remaining service collection
samples = [
(
"barbican",
"secret",
"demo-secret",
{"payload_content_type": "text/plain", "secret_type": "passphrase"},
),
(
"manila",
"share",
"demo-share",
{"size": 10, "share_proto": "NFS", "status": "available"},
),
(
"designate",
"zone",
"example.lab.",
{"email": "hostmaster@example.lab", "ttl": 3600, "type": "PRIMARY"},
),
(
"magnum",
"cluster",
"demo-k8s",
{"coe": "kubernetes", "status": "CREATE_COMPLETE", "node_count": 2},
),
("zun", "container", "demo-ctr", {"image": "cirros", "status": "Running"}),
(
"trove",
"instance",
"demo-db",
{"datastore": {"type": "mysql", "version": "8.0"}, "status": "ACTIVE"},
),
(
"mistral",
"workflow",
"demo-wf",
{"input": {}, "definition": "version: '2.0'\ndemo_wf:\n tasks: {}"},
),
(
"aodh",
"alarm",
"cpu-high",
{"type": "threshold", "state": "ok", "severity": "avg(cpu)>80"},
),
(
"freezer",
"job",
"daily-backup",
{"description": "lab backup job", "status": "scheduled"},
),
(
"blazar",
"lease",
"demo-lease",
{"start_date": "2026-01-01T00:00:00", "end_date": "2026-12-31T00:00:00"},
),
("vitrage", "alarm", "host-down", {"type": "host", "state": "critical"}),
(
"masakari",
"segment",
"az-segment",
{"recovery_method": "auto", "service_type": "compute"},
),
("tacker", "vnf", "demo-vnf", {"status": "ACTIVE", "vnfd_id": "vnfd-1"}),
("adjutant", "task", "invite-user", {"task_type": "create_user", "status": "open"}),
("adjutant", "token", "adj-token-demo", {"status": "active"}),
("adjutant", "notification", "adj-notif-demo", {"status": "sent"}),
(
"adjutant",
"status",
"adj-status-demo",
{"status": "UP", "service": "adjutant", "state": "up"},
),
("cloudkitty", "hashmap_service", "compute", {"name": "compute"}),
(
"heat-cfn",
"stack",
"demo-cfn",
{"StackName": "demo-cfn", "StackStatus": "CREATE_COMPLETE"},
),
("watcher", "audit", "demo-audit", {"state": "SUCCEEDED"}),
("zaqar", "queue", "demo-queue", {"_default_message_ttl": 3600}),
(
"masakari",
"host",
"compute-1",
{"name": "compute-1", "type": "compute", "reserved": False},
),
("designate", "recordset", "www", {"type": "A", "records": ["203.0.113.10"], "ttl": 3600}),
# Extra types that pack lists expose and previously relied on lazy fixtures.
("barbican", "container", "demo-container", {"type": "generic", "status": "ACTIVE"}),
("barbican", "order", "demo-order", {"type": "key", "status": "ACTIVE"}),
("barbican", "secret_store", "demo-store", {"status": "ACTIVE"}),
("manila", "share_type", "default", {"is_public": True}),
("manila", "share_network", "demo-share-net", {"status": "active"}),
("manila", "share_snapshot", "demo-share-snap", {"status": "available", "size": 10}),
("manila", "share_server", "demo-share-srv", {"status": "active"}),
("manila", "security_service", "demo-sec-svc", {"type": "ldap", "status": "new"}),
("manila", "share_group", "demo-share-grp", {"status": "available"}),
("manila", "share_replica", "demo-share-rep", {"status": "available"}),
("designate", "tld", "lab", {"name": "lab"}),
("designate", "blacklist", "bad-pattern", {"pattern": "^bad\\..*"}),
("designate", "pool", "default", {"name": "default"}),
("designate", "service_status", "dns-central", {"status": "UP"}),
("magnum", "clustertemplate", "k8s-default", {"coe": "kubernetes", "image_id": "cirros"}),
("magnum", "certificate", "demo-cert", {"cluster_uuid": "demo-k8s"}),
("zun", "capsule", "demo-capsule", {"status": "Running", "cpu": 1, "memory": 512}),
("zun", "host", "zun-compute-1", {"hostname": "zun-compute-1", "state": "up"}),
("zun", "image", "nginx", {"image": "nginx", "status": "ACTIVE"}),
(
"zun",
"service",
"zun-compute",
{"host": "zun-1", "binary": "zun-compute", "state": "up"},
),
("trove", "backup", "demo-db-bak", {"status": "COMPLETED", "size": 1.5}),
("trove", "cluster", "demo-db-cl", {"instance_count": 3}),
("trove", "configuration", "demo-db-cfg", {"datastore_name": "mysql"}),
("trove", "datastore", "mysql", {"name": "mysql", "version": "8.0"}),
("mistral", "action", "demo-action", {"is_system": False}),
("mistral", "cron_trigger", "hourly", {"pattern": "0 * * * *"}),
("mistral", "execution", "demo-exec", {"state": "SUCCESS"}),
("mistral", "task", "demo-task", {"state": "SUCCESS"}),
("mistral", "workbook", "demo-wb", {"definition": "version: '2.0'"}),
("aodh", "quota", "aodh-default", {"alarm": 100}),
("freezer", "action", "demo-freezer-action", {"status": "available"}),
("freezer", "backup", "demo-freezer-bak", {"status": "available"}),
("freezer", "client", "demo-freezer-client", {"status": "available"}),
("freezer", "session", "demo-freezer-session", {"status": "scheduled"}),
("blazar", "floatingip", "blazar-fip", {"floating_ip_address": "198.51.100.10"}),
("blazar", "host", "blazar-host-1", {"status": "available"}),
("vitrage", "event", "host-down-evt", {"type": "compute.host.down"}),
("vitrage", "resource", "vit-server", {"type": "nova.instance", "state": "ACTIVE"}),
("vitrage", "template", "vit-tmpl", {"type": "standard", "status": "active"}),
("vitrage", "topology", "vit-topo", {"nodes": [], "links": []}),
("masakari", "notification", "demo-notif", {"status": "finished"}),
("tacker", "vim", "demo-vim", {"type": "openstack", "status": "REACHABLE"}),
("tacker", "vnf_instance", "demo-vnf-inst", {"instantiationState": "INSTANTIATED"}),
("tacker", "vnf_package", "demo-vnf-pkg", {"onboardingState": "ONBOARDED"}),
("tacker", "vnfd", "demo-vnfd", {"name": "demo-vnfd"}),
("cloudkitty", "dataframes", "df-0", {"period": "3600"}),
("cloudkitty", "hashmap_field", "field-0", {"name": "field-0"}),
("cloudkitty", "report_summary", "summary-0", {"tenant_id": "demo"}),
("watcher", "action", "w-action-0", {"state": "SUCCEEDED"}),
("watcher", "action_plan", "ap-0", {"state": "SUCCEEDED"}),
("watcher", "audit_template", "at-0", {"goal": "server_consolidation"}),
("watcher", "goal", "goal-0", {"display_name": "Goal 0"}),
("watcher", "scoring_engine", "se-0", {"description": "engine 0"}),
("watcher", "service", "wsvc-0", {"host": "watcher-0", "status": "ACTIVE"}),
("watcher", "strategy", "strategy-0", {"goal_uuid": "goal-0"}),
("ironic", "driver", "ipmi", {"name": "ipmi", "hosts": ["simulator"], "type": "classic"}),
(
"ironic",
"driver",
"redfish",
{"name": "redfish", "hosts": ["simulator"], "type": "classic"},
),
(
"neutron",
"agent",
"l3-agent",
{"agent_type": "L3 agent", "host": "network-1", "alive": True, "admin_state_up": True},
),
(
"neutron",
"agent",
"ovs-agent",
{
"agent_type": "Open vSwitch agent",
"host": "compute-1",
"alive": True,
"admin_state_up": True,
},
),
(
"nova",
"console_output",
"default-console",
{"output": "Booting...\nSimulator console\n"},
),
(
"nova",
"console",
"default-vnc",
{"type": "novnc", "url": "https://127.0.0.1:6080/vnc_auto.html?token=simulator"},
),
(
"nova",
"migration",
"demo-mig",
{
"status": "completed",
"migration_type": "migration",
"source_compute": "compute-1",
"dest_compute": "compute-2",
"instance_uuid": str(oid("server:demo-1")),
},
),
(
"nova",
"server_topology",
"demo-topo",
{
"server_id": str(oid("server:demo-1")),
"nodes": [
{
"vcpu_set": [0],
"siblings": [[0]],
"host_node": 0,
"memory_mb": 2048,
"cpu_pinning": {},
}
],
"pagesize_kb": 4,
"host": "compute-1",
},
),
(
"nova",
"server_password",
"demo-password",
{"server_id": str(oid("server:demo-1")), "password": ""},
),
(
"placement",
"resource_provider",
"rp-0",
{"name": "compute-1", "generation": 1},
),
(
"placement",
"allocation",
"alloc-demo",
{
"consumer_uuid": str(oid("server:demo-1")),
"resource_provider": str(oid("placement:resource_provider:rp-0")),
"resource_provider_id": str(oid("placement:resource_provider:rp-0")),
"resources": {"VCPU": 1, "MEMORY_MB": 2048, "DISK_GB": 20},
"consumer_generation": 1,
},
),
(
"placement",
"inventory",
"inv-demo",
{
"resource_provider": str(oid("placement:resource_provider:rp-0")),
"resource_provider_id": str(oid("placement:resource_provider:rp-0")),
"resource_class": "VCPU",
"total": 64,
"reserved": 0,
},
),
(
"placement",
"aggregate",
"agg-demo",
{
"name": "agg-demo",
"resource_provider": str(oid("placement:resource_provider:rp-0")),
"resource_provider_id": str(oid("placement:resource_provider:rp-0")),
},
),
(
"nova",
"console_auth_token",
"demo-cat",
{
"token": "demo-console-token",
"console_type": "novnc",
"host": "127.0.0.1",
"port": 6080,
"internal_access_path": None,
},
),
]
for service, rtype, name, data in samples:
item_id = oid(f"{service}:{rtype}:{name}")
payload = {"id": str(item_id), "name": name, "status": data.get("status", "ACTIVE"), **data}
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
ON CONFLICT (id) DO NOTHING""",
item_id,
service,
rtype,
None, # visible to any project-scoped token
name,
payload["status"],
json.dumps(payload),
)
+48
View File
@@ -0,0 +1,48 @@
"""CLI entrypoint for OpenStack lab / demo cloud seeding."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import asyncpg
from app.config import get_settings
from app.openstack.demo_cloud import clear_openstack_state, seed_openstack_demo
from app.openstack.seed import seed_openstack
async def _run(profile: str, password: str) -> dict[str, object]:
settings = get_settings()
conn = await asyncpg.connect(settings.database_url.get_secret_value())
try:
async with conn.transaction():
if profile in {"demo", "demo-cloud", "openstack-demo-cloud"}:
return await seed_openstack_demo(conn, password=password)
if profile in {"minimal", "lab", "small"}:
await clear_openstack_state(conn)
return await seed_openstack(conn, password=password)
raise SystemExit(f"unknown profile: {profile} (use minimal|demo)")
finally:
await conn.close()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--profile",
default=os.environ.get("SEED_PROFILE", "minimal"),
help="minimal | demo",
)
parser.add_argument("--password", default=os.environ.get("OS_PASSWORD", "secret"))
args = parser.parse_args(argv)
result = asyncio.run(_run(args.profile, args.password))
print(json.dumps(result, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+515
View File
@@ -0,0 +1,515 @@
"""Seed discovery / catalog / schema documents into ``os_api_objects``."""
from __future__ import annotations
from typing import Any
from asyncpg import Connection
from app.openstack.db_docs import upsert_doc
from app.openstack.surface import SERVICES
def _version_doc(service: str, payload: dict[str, Any]) -> tuple[str, str, str, dict[str, Any]]:
return service, "discovery_version", "default", payload
async def seed_discovery_documents(conn: Connection) -> dict[str, int]:
"""Persist API discovery documents so handlers never hardcode them."""
docs: list[tuple[str, str, str, dict[str, Any]]] = [
_version_doc(
"keystone",
{
"versions": {
"values": [
{
"id": "v3.14",
"status": "stable",
"updated": "2024-07-01T00:00:00Z",
"links": [{"rel": "self", "href": "/v3/"}],
"media-types": [
{
"base": "application/json",
"type": "application/vnd.openstack.identity-v3+json",
}
],
}
]
}
},
),
_version_doc(
"nova",
{
"versions": [
{
"id": "v2.1",
"status": "CURRENT",
"version": "2.96",
"min_version": "2.1",
"links": [{"rel": "self", "href": "/v2.1/"}],
}
]
},
),
_version_doc(
"neutron",
{
"versions": [
{
"id": "v2.0",
"status": "CURRENT",
"links": [{"rel": "self", "href": "/v2.0/"}],
}
]
},
),
_version_doc(
"glance",
{
"versions": [
{"id": "v2.9", "status": "CURRENT", "links": [{"rel": "self", "href": "/v2/"}]}
]
},
),
_version_doc(
"cinder",
{
"versions": [
{
"id": "v3.0",
"status": "CURRENT",
"version": "3.70",
"min_version": "3.0",
"links": [{"rel": "self", "href": "/v3/"}],
}
]
},
),
_version_doc(
"placement",
{
"versions": [
{
"id": "v1.0",
"status": "CURRENT",
"min_version": "1.0",
"max_version": "1.39",
"links": [{"rel": "self", "href": "/"}],
}
]
},
),
_version_doc("swift", {"swift": {"version": "2.30.0"}}),
_version_doc(
"ironic",
{
"id": "v1",
"version": {
"id": "1.90",
"status": "CURRENT",
"min_version": "1.1",
"version": "1.90",
},
},
),
_version_doc(
"octavia",
{
"versions": [
{"id": "v2.0", "status": "CURRENT", "links": [{"href": "/v2/", "rel": "self"}]}
]
},
),
_version_doc(
"heat",
{
"versions": [
{"id": "v1.0", "status": "CURRENT", "links": [{"rel": "self", "href": "/v1/"}]}
]
},
),
(
"swift",
"info",
"default",
{
"swift": {"version": "2.30.0", "max_file_size": 5368709122},
"tempauth": {"user_groups": ["admin"]},
},
),
(
"glance",
"info_stores",
"default",
{
"stores": [
{
"id": "fast",
"type": "file",
"description": "Local file store",
"default": True,
},
{"id": "cheap", "type": "file", "description": "Secondary file store"},
]
},
),
(
"glance",
"info_import",
"default",
{
"import-methods": {
"type": "array",
"description": "Import methods available.",
"items": {"type": "string"},
"value": ["glance-direct", "web-download", "copy-image"],
}
},
),
(
"glance",
"schema",
"image",
{
"name": "image",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"status": {"type": "string"},
"visibility": {"type": "string"},
"disk_format": {"type": "string"},
"container_format": {"type": "string"},
},
"additionalProperties": True,
},
),
(
"glance",
"schema",
"images",
{
"name": "images",
"properties": {
"images": {"type": "array", "items": {"type": "object"}},
"first": {"type": "string"},
"next": {"type": "string"},
"schema": {"type": "string"},
},
},
),
(
"heat",
"resource_type_list",
"default",
{
"resource_types": [
"OS::Nova::Server",
"OS::Neutron::Net",
"OS::Neutron::Subnet",
"OS::Neutron::Port",
"OS::Cinder::Volume",
"OS::Glance::Image",
"OS::Heat::Stack",
]
},
),
(
"zaqar",
"ping",
"default",
{"ping": "pong"},
),
(
"zaqar",
"health",
"default",
{"catalog": True, "storage": True, "operation_status": "UP"},
),
(
"cinder",
"limits",
"default",
{
"limits": {
"rate": [],
"absolute": {
"maxTotalVolumeGigabytes": 100000,
"maxTotalVolumes": 500,
"totalVolumesUsed": 0,
"totalGigabytesUsed": 0,
},
}
},
),
(
"keystone",
"limits",
"default",
{
"limits": [
{
"resource_name": "project",
"resource_limit": 100,
"region_id": None,
}
]
},
),
(
"nova",
"limits",
"default",
{
"limits": {
"rate": [],
"absolute": {
"maxTotalInstances": 100,
"maxTotalCores": 200,
"maxTotalRAMSize": 512000,
"totalInstancesUsed": 0,
"totalCoresUsed": 0,
"totalRAMUsed": 0,
},
}
},
),
(
"nova",
"console_template",
"default",
{
"type": "novnc",
"url": "https://127.0.0.1:6080/vnc_auto.html?token=__SERVER_ID__",
},
),
(
"nova",
"console_output_template",
"default",
{"output": "Booting...\nSimulator console\n"},
),
(
"nova",
"server_metadata_defaults",
"default",
{"metadata": {"env": "lab"}},
),
(
"nova",
"server_topology_template",
"default",
{
"nodes": [
{
"vcpu_set": [0],
"siblings": [[0]],
"host_node": 0,
"memory_mb": 1024,
"cpu_pinning": {},
}
],
"pagesize_kb": 4,
},
),
(
"nova",
"server_password_defaults",
"default",
{"password": ""},
),
(
"nova",
"server_tag_defaults",
"default",
{"tags": ["lab", "env", "demo"]},
),
(
"nova",
"keypair_defaults",
"default",
{
"name": "default",
"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC lab@simulator",
"type": "ssh",
"fingerprint_prefix": "https://example.invalid/",
},
),
(
"nova",
"server_group_defaults",
"default",
{"name": "group", "policies": ["soft-anti-affinity"]},
),
(
"placement",
"allocation_defaults",
"default",
{
"resources": {"VCPU": 1, "MEMORY_MB": 1024, "DISK_GB": 10},
"consumer_generation": 0,
},
),
(
"placement",
"resource_provider_defaults",
"default",
{"generation": 1},
),
(
"ironic",
"node_defaults",
"default",
{
"driver": "ipmi",
"resource_class": "baremetal",
"properties": {"cpus": 32, "memory_mb": 131072, "local_gb": 1024},
"power_state": "power on",
"provision_state": "active",
},
),
(
"glance",
"image_defaults",
"default",
{
"name": "image",
"visibility": "private",
"disk_format": "qcow2",
"container_format": "bare",
},
),
(
"cinder",
"volume_defaults",
"default",
{"size": 1, "volume_type": "lvmdriver-1", "name": "volume"},
),
(
"heat",
"stack_defaults",
"default",
{
"template": {"heat_template_version": "2015-04-30", "resources": {}},
"parameters": {},
},
),
(
"octavia",
"loadbalancer_defaults",
"default",
{"name": "lb", "vip_address": "10.0.0.50"},
),
(
"neutron",
"network_defaults",
"default",
{"name": "net"},
),
(
"neutron",
"router_defaults",
"default",
{"name": "router"},
),
(
"neutron",
"security_group_defaults",
"default",
{"name": "default"},
),
(
"neutron",
"security_group_rule_defaults",
"default",
{"direction": "ingress", "ethertype": "IPv4"},
),
(
"nova",
"server_defaults",
"default",
{"name": "instance"},
),
(
"nova",
"volume_attachment_defaults",
"default",
{"device": "/dev/vdb"},
),
(
"nova",
"quota_set_defaults",
"default",
{
"quota_set": {
"instances": 100,
"cores": 200,
"ram": 512000,
"floating_ips": 50,
"fixed_ips": -1,
"metadata_items": 128,
"injected_files": 5,
"injected_file_content_bytes": 10240,
"security_groups": 50,
"security_group_rules": 100,
"key_pairs": 100,
"server_groups": 10,
"server_group_members": 10,
}
},
),
(
"nova",
"console_auth_token_defaults",
"default",
{
"console_type": "novnc",
"host": "127.0.0.1",
"port": 6080,
"internal_access_path": None,
},
),
]
from app.openstack.surface import catalog_entries
# Persist catalog with placeholders so Keystone reads catalog only from DB.
catalog_template = {
"catalog": catalog_entries("__HOST__", scheme="__SCHEME__"),
}
docs.append(("keystone", "service_catalog_template", "default", catalog_template))
# Generic version docs for remaining SERVICES not listed above.
seeded_services = {d[0] for d in docs if d[1] == "discovery_version"}
for spec in SERVICES:
if spec.name in seeded_services:
continue
docs.append(
_version_doc(
spec.name,
{
"versions": [
{
"id": spec.version_path.strip("/") or "v1",
"status": "CURRENT",
"links": [{"rel": "self", "href": spec.version_path or "/"}],
"service": spec.name,
"type": spec.typ,
}
]
},
)
)
for service, rtype, name, data in docs:
await upsert_doc(conn, service=service, resource_type=rtype, name=name, data=data)
# Ironic drivers as listable rows (also used by /v1/drivers).
for driver, payload in (
("ipmi", {"name": "ipmi", "hosts": ["simulator"], "type": "classic"}),
("redfish", {"name": "redfish", "hosts": ["simulator"], "type": "classic"}),
):
await upsert_doc(conn, service="ironic", resource_type="driver", name=driver, data=payload)
return {"documents": len(docs) + 2}
+548
View File
@@ -0,0 +1,548 @@
"""Declarative OpenStack API surface — all lab services and resource collections.
Collection GETs/POSTs and item GET/PATCH/PUT/DELETE are served from os_api_objects
unless a service mounts a specialized router that shadows the path.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ServiceSpec:
name: str
typ: str
port: int
version_path: str
resources: tuple[tuple[str, str, str], ...]
# resources: (resource_type, collection_path, item_key)
# collection_path is absolute under the service (e.g. /v2.0/networks)
# Full OpenStack default ports (install-guide firewalls-default-ports).
SERVICES: tuple[ServiceSpec, ...] = (
ServiceSpec(
"keystone",
"identity",
5000,
"/v3/",
(
("domain", "/v3/domains", "domains"),
("project", "/v3/projects", "projects"),
("user", "/v3/users", "users"),
("group", "/v3/groups", "groups"),
("role", "/v3/roles", "roles"),
("region", "/v3/regions", "regions"),
("service", "/v3/services", "services"),
("endpoint", "/v3/endpoints", "endpoints"),
(
"application_credential",
"/v3/users/{user_id}/application_credentials",
"application_credentials",
),
("credential", "/v3/credentials", "credentials"),
("policy", "/v3/policies", "policies"),
),
),
ServiceSpec(
"nova",
"compute",
8774,
"/v2.1/",
(
("server", "/v2.1/servers", "servers"),
("flavor", "/v2.1/flavors", "flavors"),
("keypair", "/v2.1/os-keypairs", "keypairs"),
("aggregate", "/v2.1/os-aggregates", "aggregates"),
("hypervisor", "/v2.1/os-hypervisors", "hypervisors"),
("availability_zone", "/v2.1/os-availability-zone", "availabilityZoneInfo"),
("server_group", "/v2.1/os-server-groups", "server_groups"),
("service", "/v2.1/os-services", "services"),
("limit", "/v2.1/limits", "limits"),
("quota_set", "/v2.1/os-quota-sets", "quota_set"),
(
"instance_usage_audit_log",
"/v2.1/os-instance_usage_audit_log",
"instance_usage_audit_logs",
),
("migration", "/v2.1/os-migrations", "migrations"),
("assisted_volume_snapshot", "/v2.1/os-assisted-volume-snapshots", "snapshot"),
("console_auth_token", "/v2.1/os-console-auth-tokens", "console"),
("server_external_event", "/v2.1/os-server-external-events", "events"),
("instance_action", "/v2.1/servers/{server_id}/os-instance-actions", "instanceActions"),
(
"volume_attachment",
"/v2.1/servers/{server_id}/os-volume_attachments",
"volumeAttachments",
),
(
"interface_attachment",
"/v2.1/servers/{server_id}/os-interface",
"interfaceAttachments",
),
("security_group", "/v2.1/os-security-groups", "security_groups"),
("floating_ip", "/v2.1/os-floating-ips", "floating_ips"),
("network", "/v2.1/os-networks", "networks"),
("tenant_network", "/v2.1/os-tenant-networks", "networks"),
),
),
ServiceSpec(
"neutron",
"network",
9696,
"/v2.0/",
(
("network", "/v2.0/networks", "networks"),
("subnet", "/v2.0/subnets", "subnets"),
("port", "/v2.0/ports", "ports"),
("router", "/v2.0/routers", "routers"),
("floatingip", "/v2.0/floatingips", "floatingips"),
("security_group", "/v2.0/security-groups", "security_groups"),
("security_group_rule", "/v2.0/security-group-rules", "security_group_rules"),
("address_scope", "/v2.0/address-scopes", "address_scopes"),
("subnetpool", "/v2.0/subnetpools", "subnetpools"),
("qos_policy", "/v2.0/qos/policies", "policies"),
("qos_rule_type", "/v2.0/qos/rule-types", "rule_types"),
("trunk", "/v2.0/trunks", "trunks"),
("rbac_policy", "/v2.0/rbac-policies", "rbac_policies"),
("agent", "/v2.0/agents", "agents"),
(
"network_ip_availability",
"/v2.0/network-ip-availabilities",
"network_ip_availabilities",
),
("auto_allocated_topology", "/v2.0/auto-allocated-topology", "auto_allocated_topology"),
("lbaas_loadbalancer", "/v2.0/lbaas/loadbalancers", "loadbalancers"),
("lbaas_listener", "/v2.0/lbaas/listeners", "listeners"),
("lbaas_pool", "/v2.0/lbaas/pools", "pools"),
("metering_label", "/v2.0/metering/metering-labels", "metering_labels"),
("firewall_group", "/v2.0/fwaas/firewall_groups", "firewall_groups"),
("vpn_service", "/v2.0/vpn/vpnservices", "vpnservices"),
("bgpvpn", "/v2.0/bgpvpn/bgpvpns", "bgpvpns"),
("log", "/v2.0/log/logs", "logs"),
("ndp_proxy", "/v2.0/ndp_proxies", "ndp_proxies"),
("local_ip", "/v2.0/local_ips", "local_ips"),
(
"conntrack_helper",
"/v2.0/routers/{router_id}/conntrack_helpers",
"conntrack_helpers",
),
("quota", "/v2.0/quotas", "quotas"),
(
"floatingip_port_forwarding",
"/v2.0/floatingips/{floatingip_id}/port_forwardings",
"port_forwardings",
),
),
),
ServiceSpec(
"glance",
"image",
9292,
"/v2/",
(
("image", "/v2/images", "images"),
("metadef_namespace", "/v2/metadefs/namespaces", "namespaces"),
("task", "/v2/tasks", "tasks"),
("info_import", "/v2/info/import", "import-methods"),
("info_store", "/v2/info/stores", "stores"),
),
),
ServiceSpec(
"cinder",
"volumev3",
8776,
"/v3/",
(
("volume", "/v3/volumes", "volumes"),
("snapshot", "/v3/snapshots", "snapshots"),
("backup", "/v3/backups", "backups"),
("volume_type", "/v3/types", "volume_types"),
("qos_spec", "/v3/qos-specs", "qos_specs"),
("group", "/v3/groups", "groups"),
("group_snapshot", "/v3/group_snapshots", "group_snapshots"),
("consistencygroup", "/v3/consistencygroups", "consistencygroups"),
("attachment", "/v3/attachments", "attachments"),
("transfer", "/v3/volume-transfers", "transfers"),
("service", "/v3/os-services", "services"),
("quota_set", "/v3/os-quota-sets", "quota_set"),
("limit", "/v3/limits", "limits"),
("cluster", "/v3/clusters", "clusters"),
("message", "/v3/messages", "messages"),
("resource_filter", "/v3/resource_filters", "resource_filters"),
),
),
ServiceSpec(
"placement",
"placement",
8003,
"/",
(
("resource_provider", "/resource_providers", "resource_providers"),
("resource_class", "/resource_classes", "resource_classes"),
("trait", "/traits", "traits"),
("allocation", "/allocations", "allocations"),
("usage", "/usages", "usages"),
("allocation_candidate", "/allocation_candidates", "allocation_candidates"),
),
),
ServiceSpec(
"heat",
"orchestration",
8004,
"/v1/",
(
("stack", "/v1/{tenant_id}/stacks", "stacks"),
("resource", "/v1/{tenant_id}/stacks/{stack_name}/{stack_id}/resources", "resources"),
("event", "/v1/{tenant_id}/stacks/{stack_name}/{stack_id}/events", "events"),
("software_config", "/v1/{tenant_id}/software_configs", "software_configs"),
("software_deployment", "/v1/{tenant_id}/software_deployments", "software_deployments"),
("resource_type", "/v1/{tenant_id}/resource_types", "resource_types"),
("service", "/v1/{tenant_id}/services", "services"),
),
),
ServiceSpec(
"heat-cfn",
"cloudformation",
8000,
"/v1/",
(("stack", "/stacks", "Stacks"),),
),
ServiceSpec(
"swift",
"object-store",
8080,
"/v1/",
(
("account", "/v1/{account}", "account"),
("container", "/v1/{account}/{container}", "container"),
("object", "/v1/{account}/{container}/{object}", "object"),
),
),
ServiceSpec(
"ironic",
"baremetal",
6385,
"/",
(
("node", "/v1/nodes", "nodes"),
("port", "/v1/ports", "ports"),
("portgroup", "/v1/portgroups", "portgroups"),
("chassis", "/v1/chassis", "chassis"),
("driver", "/v1/drivers", "drivers"),
("volume_connector", "/v1/volume/connectors", "connectors"),
("volume_target", "/v1/volume/targets", "targets"),
("allocation", "/v1/allocations", "allocations"),
("deploy_template", "/v1/deploy_templates", "deploy_templates"),
("conductor", "/v1/conductors", "conductors"),
),
),
ServiceSpec(
"octavia",
"load-balancer",
9876,
"/v2/",
(
("loadbalancer", "/v2/lbaas/loadbalancers", "loadbalancers"),
("listener", "/v2/lbaas/listeners", "listeners"),
("pool", "/v2/lbaas/pools", "pools"),
("member", "/v2/lbaas/pools/{pool_id}/members", "members"),
("healthmonitor", "/v2/lbaas/healthmonitors", "healthmonitors"),
("l7policy", "/v2/lbaas/l7policies", "l7policies"),
("l7rule", "/v2/lbaas/l7policies/{l7policy_id}/rules", "rules"),
("amphora", "/v2/octavia/amphorae", "amphorae"),
("quota", "/v2/lbaas/quotas", "quotas"),
("provider", "/v2/lbaas/providers", "providers"),
("flavor", "/v2/lbaas/flavors", "flavors"),
("flavorprofile", "/v2/lbaas/flavorprofiles", "flavorprofiles"),
),
),
ServiceSpec(
"barbican",
"key-manager",
9311,
"/v1/",
(
("secret", "/v1/secrets", "secrets"),
("container", "/v1/containers", "containers"),
("order", "/v1/orders", "orders"),
("secret_store", "/v1/secret-stores", "secret_stores"),
),
),
ServiceSpec(
"manila",
"sharev2",
8786,
"/v2/",
(
("share", "/v2/shares", "shares"),
("share_snapshot", "/v2/snapshots", "snapshots"),
("share_network", "/v2/share-networks", "share_networks"),
("share_type", "/v2/types", "share_types"),
("share_server", "/v2/share-servers", "share_servers"),
("security_service", "/v2/security-services", "security_services"),
("share_group", "/v2/share-groups", "share_groups"),
),
),
ServiceSpec(
"designate",
"dns",
9001,
"/v2/",
(
("zone", "/v2/zones", "zones"),
("recordset", "/v2/zones/{zone_id}/recordsets", "recordsets"),
("tld", "/v2/tlds", "tlds"),
("blacklist", "/v2/blacklists", "blacklists"),
("pool", "/v2/pools", "pools"),
("service_status", "/v2/service_statuses", "service_statuses"),
),
),
ServiceSpec(
"magnum",
"container-infra",
9511,
"/v1/",
(
("cluster", "/v1/clusters", "clusters"),
("clustertemplate", "/v1/clustertemplates", "clustertemplates"),
("certificate", "/v1/certificates", "certificates"),
("nodegroup", "/v1/clusters/{cluster_id}/nodegroups", "nodegroups"),
),
),
ServiceSpec(
"zun",
"container",
9517,
"/v1/",
(
("container", "/v1/containers", "containers"),
("image", "/v1/images", "images"),
("capsule", "/v1/capsules", "capsules"),
("host", "/v1/hosts", "hosts"),
("service", "/v1/services", "services"),
),
),
ServiceSpec(
"trove",
"database",
8779,
"/v1.0/",
(
("instance", "/v1.0/instances", "instances"),
("datastore", "/v1.0/datastores", "datastores"),
("backup", "/v1.0/backups", "backups"),
("configuration", "/v1.0/configurations", "configurations"),
("cluster", "/v1.0/clusters", "clusters"),
),
),
ServiceSpec(
"mistral",
"workflowv2",
8989,
"/v2/",
(
("workflow", "/v2/workflows", "workflows"),
("execution", "/v2/executions", "executions"),
("action", "/v2/actions", "actions"),
("workbook", "/v2/workbooks", "workbooks"),
("cron_trigger", "/v2/cron_triggers", "cron_triggers"),
("task", "/v2/tasks", "tasks"),
),
),
ServiceSpec(
"aodh",
"alarming",
8042,
"/v2/",
(
("alarm", "/v2/alarms", "alarms"),
("alarm_history", "/v2/alarms/{alarm_id}/history", "alarm_history"),
("quota", "/v2/quotas", "quotas"),
),
),
ServiceSpec(
"cloudkitty",
"rating",
8889,
"/v1/",
(
("hashmap_service", "/v1/rating/module_config/hashmap/services", "services"),
("hashmap_field", "/v1/rating/module_config/hashmap/fields", "fields"),
("report_summary", "/v1/report/summary", "summary"),
("dataframes", "/v1/storage/dataframes", "dataframes"),
),
),
ServiceSpec(
"freezer",
"backup",
9090,
"/v2/",
(
("job", "/v2/jobs", "jobs"),
("client", "/v2/clients", "clients"),
("backup", "/v2/backups", "backups"),
("session", "/v2/sessions", "sessions"),
("action", "/v2/actions", "actions"),
),
),
ServiceSpec(
"blazar",
"reservation",
1234,
"/v1/",
(
("lease", "/leases", "leases"),
("host", "/os-hosts", "hosts"),
("floatingip", "/floatingips", "floatingips"),
),
),
ServiceSpec(
"vitrage",
"rca",
8999,
"/",
(
("topology", "/v1/topology", "topology"),
("alarm", "/v1/alarm", "alarms"),
("resource", "/v1/resources", "resources"),
("template", "/v1/template", "templates"),
("event", "/v1/event", "events"),
),
),
ServiceSpec(
"masakari",
"instance-ha",
15868,
"/v1/",
(
("segment", "/v1/segments", "segments"),
("host", "/v1/segments/{segment_id}/hosts", "hosts"),
("notification", "/v1/notifications", "notifications"),
),
),
ServiceSpec(
"tacker",
"nfv-orchestration",
9890,
"/",
(
("vnf", "/v1.0/vnfs", "vnfs"),
("vnfd", "/v1.0/vnfds", "vnfds"),
("vim", "/v1.0/vims", "vims"),
("vnf_package", "/vnfpkgm/v1/vnf_packages", "vnf_packages"),
("vnf_instance", "/vnflcm/v1/vnf_instances", "vnf_instances"),
),
),
ServiceSpec(
"adjutant",
"admin-logic",
5050,
"/",
(
("task", "/v1/tasks", "tasks"),
("token", "/v1/tokens", "tokens"),
("notification", "/v1/notifications", "notifications"),
("status", "/v1/status", "status"),
),
),
ServiceSpec(
"watcher",
"infra-optim",
9322,
"/v1/",
(
("audit_template", "/v1/audit_templates", "audit_templates"),
("audit", "/v1/audits", "audits"),
("action_plan", "/v1/action_plans", "action_plans"),
("action", "/v1/actions", "actions"),
("goal", "/v1/goals", "goals"),
("strategy", "/v1/strategies", "strategies"),
("scoring_engine", "/v1/scoring_engines", "scoring_engines"),
("service", "/v1/services", "services"),
),
),
ServiceSpec(
"zaqar",
"messaging",
8888,
"/v2/",
(
("queue", "/v2/queues", "queues"),
("subscription", "/v2/queues/{queue_name}/subscriptions", "subscriptions"),
("claim", "/v2/queues/{queue_name}/claims", "claims"),
("message", "/v2/queues/{queue_name}/messages", "messages"),
),
),
)
def all_service_ports() -> dict[str, int]:
return {spec.name: spec.port for spec in SERVICES}
def catalog_entries(host: str, *, scheme: str = "http") -> list[dict[str, object]]:
catalog: list[dict[str, object]] = []
for spec in SERVICES:
if spec.name == "heat-cfn":
url = f"{scheme}://{host}:{spec.port}"
elif spec.name == "swift":
url = f"{scheme}://{host}:{spec.port}/v1"
elif spec.name == "placement":
url = f"{scheme}://{host}:{spec.port}"
elif spec.name == "ironic":
url = f"{scheme}://{host}:{spec.port}"
elif spec.name == "nova":
url = f"{scheme}://{host}:{spec.port}/v2.1"
elif spec.name == "cinder":
url = f"{scheme}://{host}:{spec.port}/v3"
elif spec.name == "glance":
# Unversioned: terraform-provider-openstack appends /v2 itself.
# Clients must reach this port without an HTTP proxy (see run_iac_stack.sh).
url = f"{scheme}://{host}:{spec.port}"
elif spec.name == "neutron":
url = f"{scheme}://{host}:{spec.port}"
elif spec.name == "keystone":
url = f"{scheme}://{host}:{spec.port}/v3"
elif spec.name == "octavia":
# Specialized routes live under /v2/lbaas/… (not /v2.0).
url = f"{scheme}://{host}:{spec.port}/v2"
elif spec.name == "blazar":
# Contract paths are /leases, /os-hosts (no /v1 prefix).
url = f"{scheme}://{host}:{spec.port}"
elif spec.name == "heat":
url = f"{scheme}://{host}:{spec.port}/v1"
else:
url = f"{scheme}://{host}:{spec.port}{spec.version_path.rstrip('/')}"
catalog.append(
{
"id": spec.name,
"type": spec.typ,
"name": spec.name,
"endpoints": [
{
"id": f"{spec.name}-public",
"interface": "public",
"region": "RegionOne",
"region_id": "RegionOne",
"url": url,
},
{
"id": f"{spec.name}-internal",
"interface": "internal",
"region": "RegionOne",
"region_id": "RegionOne",
"url": url,
},
{
"id": f"{spec.name}-admin",
"interface": "admin",
"region": "RegionOne",
"region_id": "RegionOne",
"url": url,
},
],
}
)
return catalog
+875
View File
@@ -0,0 +1,875 @@
"""Probe every pack operation against a live OpenStack simulator gateway.
Default mode is *lifecycle*: create real resources, then exercise
GET/PUT/PATCH/DELETE (and actions) against those ids so write methods
are not false-404 from random UUIDs.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from app.openstack.contract_loader import load_series_pack
from app.openstack.opspec import OperationSpec, ServicePack
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
# Handler ran — not a crash / unimplemented.
ACCEPTABLE = frozenset({200, 201, 202, 204, 300, 400, 401, 403, 404, 405, 409, 410, 412, 415, 422})
# Lifecycle success for exercised CRUD steps.
SUCCESS = frozenset({200, 201, 202, 204})
@dataclass
class ProbeResult:
service: str
method: str
path: str
operation_id: str
status: int
detail: str = ""
mode: str = "probe"
payload: Any = None
collection_key: str | None = None
@property
def ok(self) -> bool:
return self.status in ACCEPTABLE
@property
def succeeded(self) -> bool:
return self.status in SUCCESS
@dataclass
class ProbeReport:
series: str
host: str
results: list[ProbeResult] = field(default_factory=list)
mode: str = "lifecycle"
@property
def failures(self) -> list[ProbeResult]:
if self.mode == "lifecycle":
# Lifecycle requires real 2xx for exercised ops; remaining may 404.
return [
r for r in self.results if (r.mode == "lifecycle" and not r.succeeded) or not r.ok
]
return [r for r in self.results if not r.ok]
@property
def ok_count(self) -> int:
return len(self.results) - len(self.failures)
def _example_param(name: str) -> str:
lower = name.lower()
if lower.endswith("_id") or lower in {"id"} or "uuid" in lower:
return str(uuid4())
if lower in {"tenant_id", "project_id", "account"}:
return str(uuid4())
if lower in {"name", "stack_name", "container", "object"}:
return f"probe-{uuid4().hex[:8]}"
return f"probe-{name}"
def fill_path(template: str, ctx: dict[str, str] | None = None) -> str:
ctx = ctx or {}
def repl(match: re.Match[str]) -> str:
name = match.group(1)
if name in ctx:
return ctx[name]
# common aliases
aliases = {
"server_id": "server",
"volume_id": "volume",
"image_id": "image",
"network_id": "network",
"port_id": "port",
"subnet_id": "subnet",
"router_id": "router",
"stack_id": "stack",
"user_id": "user",
"project_id": "project",
"tenant_id": "project",
"account": "project",
"object_name": "object",
"object": "object_name",
"container": "container",
"policy_id": "qos_policy",
"pool_id": "pool",
"l7policy_id": "l7policy",
"zone_id": "zone",
"alarm_id": "alarm",
"segment_id": "segment",
"trunk_id": "trunk",
"image_id": "image",
}
key = aliases.get(name)
if key and key in ctx:
return ctx[key]
if name == "id" and "_item_id" in ctx:
return ctx["_item_id"]
return _example_param(name)
return _PATH_PARAM.sub(repl, template)
def _singular(key: str) -> str:
if key.endswith("ies"):
return key[:-3] + "y"
if key.endswith("ses"):
return key[:-2]
if key.endswith("s") and not key.endswith("ss"):
return key[:-1]
return key
def _body_for(
op: OperationSpec,
*,
ctx: dict[str, str] | None = None,
project_id: str | None = None,
) -> dict[str, Any] | None:
if op.method not in {"POST", "PUT", "PATCH"}:
return None
ctx = ctx or {}
if op.kind == "action":
action = op.action_name if op.action_name and op.action_name != "*" else "os-start"
if action == "os-getConsoleOutput":
return {action: {"length": 20}}
if action in {"reboot"}:
return {action: {"type": "SOFT"}}
if action in {"resize"}:
return {action: {"flavorRef": "1"}}
if action in {"rebuild"}:
return {action: {"imageRef": ctx.get("image", str(uuid4()))}}
return {action: None}
# Keystone password auth
if op.path == "/v3/auth/tokens" and op.method == "POST":
return {
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": "admin",
"domain": {"name": "Default"},
"password": "secret",
}
},
},
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
}
}
# Neutron router interface attach/detach (flat body, not resource envelope).
if "add_router_interface" in op.path or "remove_router_interface" in op.path:
subnet = ctx.get("subnet") or ctx.get("subnet_id")
port = ctx.get("port") or ctx.get("port_id")
if subnet:
return {"subnet_id": subnet}
if port:
return {"port_id": port}
return {"subnet_id": str(uuid4())}
# Nova interface attach
if op.path.rstrip("/").endswith("/os-interface") and op.method == "POST":
net = ctx.get("network") or ctx.get("network_id")
port = ctx.get("port") or ctx.get("port_id")
attachment: dict[str, Any] = {}
if port:
attachment["port_id"] = port
elif net:
attachment["net_id"] = net
else:
attachment["net_id"] = str(uuid4())
return {"interfaceAttachment": attachment}
# Nova server tags replace
if op.resource_type == "server_tag" and op.path.rstrip("/").endswith("/tags"):
return {"tags": ["demo", "probe"]}
key = op.item_key or (op.collection_key and _singular(op.collection_key)) or "resource"
name = f"probe-{uuid4().hex[:8]}"
body: dict[str, Any] = {"name": name, "description": "surface probe"}
# Resource-specific required fields for specialized routers.
if op.resource_type == "subnet" or op.path.endswith("/subnets"):
body.update(
{
"network_id": ctx.get("network") or ctx.get("network_id") or str(uuid4()),
"cidr": "10.99.0.0/24",
"ip_version": 4,
}
)
elif op.resource_type == "port" or op.path.endswith("/ports"):
body.update({"network_id": ctx.get("network") or ctx.get("network_id") or str(uuid4())})
elif op.resource_type == "server" or op.path.rstrip("/").endswith("/servers"):
body.update(
{
"flavorRef": "1",
"imageRef": ctx.get("image") or "cirros",
"networks": [{"uuid": ctx.get("network")}] if ctx.get("network") else [],
}
)
elif op.resource_type == "floatingip" or "floatingips" in op.path:
body.update({"floating_network_id": ctx.get("network") or str(uuid4())})
elif op.resource_type == "stack" or "/stacks" in op.path:
body = {
"stack_name": name,
"template": {"heat_template_version": "2015-04-30", "resources": {}},
}
return {"stack": body} if "heat" in (op.operation_id or "") or True else body
elif op.resource_type == "volume" or "/volumes" in op.path:
body.update({"size": 1})
elif op.resource_type == "security_group_rule":
body.update(
{
"security_group_id": ctx.get("security_group") or ctx.get("security_group_id"),
"direction": "ingress",
"ethertype": "IPv4",
"protocol": "tcp",
"port_range_min": 22,
"port_range_max": 22,
"remote_ip_prefix": "0.0.0.0/0",
}
)
# Heat CFN uses StackName envelope
if op.collection_key == "Stacks":
return {"StackName": name, "TemplateBody": '{"AWSTemplateFormatVersion":"2010-09-09"}'}
return {key: body}
def http_request(
method: str,
url: str,
*,
token: str | None = None,
service: str | None = None,
data: dict[str, Any] | None = None,
timeout: float = 20.0,
) -> tuple[int, Any]:
body = None if data is None else json.dumps(data).encode()
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["X-Auth-Token"] = token
if service:
headers["X-OpenStack-Route-Service"] = service
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
raw = res.read().decode()
try:
parsed: Any = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return res.status, parsed
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return exc.code, parsed
except urllib.error.URLError as exc:
return 0, {"error": str(exc.reason)}
def issue_token(
host: str, *, user: str = "admin", project: str = "demo", password: str = "secret"
) -> tuple[str, dict[str, Any]]:
raw_body = json.dumps(
{
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": user,
"domain": {"name": "Default"},
"password": password,
}
},
},
"scope": {"project": {"name": project, "domain": {"name": "Default"}}},
}
}
).encode()
req = urllib.request.Request(
f"{host.rstrip('/')}/v3/auth/tokens",
data=raw_body,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"X-OpenStack-Route-Service": "keystone",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as res:
token = res.headers.get("X-Subject-Token") or res.headers.get("x-subject-token")
parsed = json.loads(res.read().decode() or "{}")
status = res.status
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw) if raw else {}
except json.JSONDecodeError:
parsed = {"raw": raw}
raise RuntimeError(f"auth failed: {exc.code} {parsed}") from exc
token = token or (parsed.get("token") or {}).get("id")
if status != 201 or not token:
raise RuntimeError(f"auth failed: {status} {parsed}")
return token, parsed
def activate_series(host: str, series: str) -> dict[str, Any]:
status, body = http_request(
"POST",
f"{host.rstrip('/')}/ui/api/openstack/contracts/activate",
data={"series": series},
)
if status >= 400:
raise RuntimeError(f"activate {series} failed: {status} {body}")
return body if isinstance(body, dict) else {"raw": body}
def _extract_id(payload: Any) -> str | None:
if not isinstance(payload, dict):
return None
if "id" in payload and payload["id"]:
return str(payload["id"])
if "port_id" in payload and payload["port_id"]:
return str(payload["port_id"])
for value in payload.values():
if isinstance(value, dict):
if value.get("id"):
return str(value["id"])
if value.get("port_id"):
return str(value["port_id"])
if isinstance(value, list) and value and isinstance(value[0], dict):
first = value[0]
if first.get("id"):
return str(first["id"])
if first.get("port_id"):
return str(first["port_id"])
return None
def _extract_ids(payload: Any, collection_key: str | None) -> list[str]:
if not isinstance(payload, dict):
return []
items = None
if collection_key and collection_key in payload and isinstance(payload[collection_key], list):
items = payload[collection_key]
else:
for value in payload.values():
if isinstance(value, list):
items = value
break
if not items:
return []
out: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
# Nova keypairs: {"keypair": {"name": ...}}
nested = item.get("keypair") if isinstance(item.get("keypair"), dict) else None
src = nested or item
if src.get("id"):
out.append(str(src["id"]))
elif src.get("name"):
out.append(str(src["name"]))
return out
def _record(
report: ProbeReport,
pack: ServicePack,
op: OperationSpec,
status: int,
payload: Any,
*,
mode: str,
) -> ProbeResult:
detail = ""
ok = (status in SUCCESS) if mode == "lifecycle" else (status in ACCEPTABLE)
if not ok:
detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300]
result = ProbeResult(
service=pack.name,
method=op.method,
path=op.path,
operation_id=op.operation_id,
status=status,
detail=detail,
mode=mode,
payload=payload,
collection_key=op.collection_key,
)
report.results.append(result)
return result
def probe_operation(
host: str,
pack: ServicePack,
op: OperationSpec,
*,
token: str,
ctx: dict[str, str] | None = None,
project_id: str | None = None,
mode: str = "probe",
) -> tuple[ProbeResult, Any]:
path_ctx = dict(ctx or {})
if project_id:
path_ctx.setdefault("project", project_id)
path_ctx.setdefault("project_id", project_id)
path_ctx.setdefault("tenant_id", project_id)
path_ctx.setdefault("account", project_id)
path = fill_path(op.path, path_ctx)
url = f"{host.rstrip('/')}{path}"
data = _body_for(op, ctx=path_ctx, project_id=project_id)
status, payload = http_request(op.method, url, token=token, service=pack.name, data=data)
detail = ""
check = SUCCESS if mode == "lifecycle" else ACCEPTABLE
if status not in check:
detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300]
result = ProbeResult(
service=pack.name,
method=op.method,
path=op.path,
operation_id=op.operation_id,
status=status,
detail=detail,
mode=mode,
payload=payload,
collection_key=op.collection_key,
)
return result, payload
def _seed_context(
host: str,
token: str,
project_id: str,
) -> dict[str, str]:
"""Pull a few existing demo resources so specialized creates have parents."""
ctx: dict[str, str] = {"project": project_id, "project_id": project_id, "tenant_id": project_id}
seeds = [
("neutron", "/v2.0/networks", "networks", "network"),
("neutron", "/v2.0/subnets", "subnets", "subnet"),
("neutron", "/v2.0/ports", "ports", "port"),
("neutron", "/v2.0/routers", "routers", "router"),
("neutron", "/v2.0/floatingips", "floatingips", "floatingip"),
("neutron", "/v2.0/security-groups", "security_groups", "security_group"),
("neutron", "/v2.0/security-group-rules", "security_group_rules", "security_group_rule"),
("glance", "/v2/images", "images", "image"),
("nova", "/v2.1/servers", "servers", "server"),
("cinder", "/v3/volumes", "volumes", "volume"),
("nova", "/v2.1/flavors", "flavors", "flavor"),
("nova", "/v2.1/os-keypairs", "keypairs", "keypair"),
("nova", "/v2.1/os-hypervisors", "hypervisors", "hypervisor"),
("nova", "/v2.1/os-server-groups", "server_groups", "server_group"),
("heat", f"/v1/{project_id}/stacks", "stacks", "stack"),
("ironic", "/v1/nodes", "nodes", "node"),
("ironic", "/v1/drivers", "drivers", "driver"),
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", "loadbalancer"),
("swift", f"/v1/{project_id}", None, "account"),
]
for service, path, key, alias in seeds:
st, body = http_request("GET", f"{host.rstrip('/')}{path}", token=token, service=service)
if st >= 400 or not isinstance(body, dict):
continue
ids = _extract_ids(body, key)
if ids:
ctx[alias] = ids[0]
ctx[f"{alias}_id"] = ids[0]
if alias == "keypair" and isinstance(body, dict):
# keypairs may use name as id
for kp in body.get("keypairs") or []:
if isinstance(kp, dict):
name = (kp.get("keypair") or kp).get("name")
if name:
ctx["keypair"] = str(name)
ctx["name"] = str(name)
break
if alias == "stack" and isinstance(body, dict):
for st in body.get("stacks") or []:
if isinstance(st, dict) and st.get("stack_name"):
ctx["stack_name"] = str(st["stack_name"])
ctx["stack"] = str(st.get("id") or st["stack_name"])
break
if alias == "driver":
ctx["name"] = ids[0]
ctx.setdefault("quota_set", project_id)
ctx.setdefault("consumer_uuid", project_id)
return ctx
def _ensure_swift_resources(host: str, token: str, project_id: str, ctx: dict[str, str]) -> None:
"""Create container + object so Swift GET/DELETE item paths succeed."""
account = ctx.get("account") or project_id
container = ctx.get("container") or f"probe-c-{uuid4().hex[:8]}"
obj = ctx.get("object") or ctx.get("object_name") or f"probe-o-{uuid4().hex[:8]}.txt"
base = host.rstrip("/")
st, _ = http_request(
"PUT", f"{base}/v1/{account}/{container}", token=token, service="swift", data={}
)
if st in SUCCESS or st == 202:
ctx["container"] = container
ctx["account"] = account
st, _ = http_request(
"PUT",
f"{base}/v1/{account}/{container}/{obj}",
token=token,
service="swift",
data={"body": "probe"},
)
if st in SUCCESS or st == 202:
ctx["object"] = obj
ctx["object_name"] = obj
ctx["name"] = obj
def probe_series_lifecycle(
series: str,
*,
host: str = "http://127.0.0.1:5000",
) -> ProbeReport:
"""Create resources then exercise GET/PUT/PATCH/DELETE for every pack op."""
activate_series(host, series)
token, auth_body = issue_token(host)
project_id = str(((auth_body.get("token") or {}).get("project") or {}).get("id") or "")
packs = load_series_pack(series)
report = ProbeReport(series=series, host=host, mode="lifecycle")
base_ctx = _seed_context(host, token, project_id)
_ensure_swift_resources(host, token, project_id, base_ctx)
for name in sorted(packs):
pack = packs[name]
ctx = dict(base_ctx)
if pack.name == "swift" or name == "swift":
_ensure_swift_resources(host, token, project_id, ctx)
ops = list(pack.operations)
done: set[tuple[str, str]] = set()
# 1) Discover / version / list GETs without params
for op in ops:
if op.method != "GET" or "{" in op.path:
continue
result, payload = probe_operation(
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="lifecycle"
)
# Lists may be empty but must be 2xx
if result.status in SUCCESS:
ids = _extract_ids(payload, op.collection_key)
if ids:
ctx.setdefault(op.resource_type, ids[0])
ctx.setdefault(_singular(op.collection_key or op.resource_type), ids[0])
report.results.append(result)
done.add((op.method, op.path))
# 2) POST creates on collections
created_for_type: dict[str, str] = {}
for op in ops:
if (op.method, op.path) in done:
continue
if op.method != "POST":
continue
if op.kind == "action":
continue
if "{" in op.path and not all(
p in ctx or p in {"tenant_id", "project_id", "account", "user_id"}
for p in _PATH_PARAM.findall(op.path)
):
# nested create — try with ctx
pass
result, payload = probe_operation(
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="lifecycle"
)
# Auth tokens POST is 201; heat-cfn root "/" may 405 → accept and mark
if op.path in {"/", ""} and result.status == 405:
result.mode = "probe"
result.detail = ""
if result.status in SUCCESS and "preview" not in op.path:
new_id = _extract_id(payload)
if isinstance(payload, dict):
kp = payload.get("keypair") or {}
if isinstance(kp, dict) and kp.get("name"):
new_id = new_id or str(kp["name"])
ctx["keypair"] = str(kp["name"])
ctx["name"] = str(kp["name"])
stack = payload.get("stack") or {}
if isinstance(stack, dict) and stack.get("stack_name"):
ctx["stack_name"] = str(stack["stack_name"])
if stack.get("id"):
new_id = str(stack["id"])
if new_id:
created_for_type[op.resource_type] = new_id
# Swift uses path names (container/object), not UUID item ids
if op.resource_type not in {"object", "container", "account"}:
ctx[op.resource_type] = new_id
ctx["_item_id"] = new_id
if op.collection_key:
ctx[_singular(op.collection_key)] = new_id
report.results.append(result)
done.add((op.method, op.path))
# Ensure we have an item id for show/update/delete
for op in ops:
if op.resource_type in created_for_type:
continue
if (
op.method == "GET"
and op.kind in {"collection", "detail", "custom"}
and "{" not in op.path
):
continue
# try list again for this resource collection path prefix
pass
# 3) Item GET / PUT / PATCH / action / DELETE using real ids
# Prefer non-destructive methods before DELETE.
ordered = sorted(
ops,
key=lambda o: {"GET": 0, "POST": 1, "PUT": 2, "PATCH": 3, "DELETE": 9}.get(o.method, 5),
)
for op in ordered:
if (op.method, op.path) in done:
continue
# Bind item id for this resource when path has {id}
local = dict(ctx)
if op.resource_type in {"object", "container", "account"}:
candidates = [
ctx.get(op.resource_type),
ctx.get("object_name") if op.resource_type == "object" else None,
created_for_type.get(op.resource_type),
]
else:
candidates = [
created_for_type.get(op.resource_type),
ctx.get(op.resource_type),
ctx.get(_singular(op.collection_key or "")),
ctx.get(_singular(op.resource_type)),
]
# Nova metadata/tag item paths use key/tag names, not UUIDs.
if op.resource_type in {"server_metadata", "server_tag"}:
if op.resource_type == "server_metadata":
candidates = [
"env",
"name",
"audit",
created_for_type.get(op.resource_type),
*candidates,
]
else:
candidates = [
"demo",
"web",
created_for_type.get(op.resource_type),
*candidates,
]
path_params_early = _PATH_PARAM.findall(op.path)
leaf_early = (
"id"
if "id" in path_params_early
else ("name" if "name" in path_params_early else None)
)
# Do not treat parent path params (server_id, …) as the item id.
for param in path_params_early:
if leaf_early and param != leaf_early:
continue
if param.endswith("_id") and param != leaf_early:
continue
if param in ctx:
candidates.append(ctx[param])
alias = {
"server_id": "server",
"volume_id": "volume",
"network_id": "network",
"image_id": "image",
"stack_id": "stack",
"node_id": "node",
}.get(param)
if alias and alias in ctx and param == leaf_early:
candidates.append(ctx[alias])
rid = next((c for c in candidates if c), None)
path_params = _PATH_PARAM.findall(op.path)
parent_aliases = {
"server_id": "server",
"volume_id": "volume",
"network_id": "network",
"image_id": "image",
"stack_id": "stack",
"node_id": "node",
"floatingip_id": "floatingip",
"router_id": "router",
"pool_id": "pool",
"consumer_uuid": "server",
}
# Bind the leaf item id only — never overwrite parent params like
# {server_id} on nested collections with a child resource UUID.
leaf = None
if "id" in path_params:
leaf = "id"
elif "name" in path_params:
leaf = "name"
elif len(path_params) == 1:
only = path_params[0]
# /servers/{server_id} → leaf; /servers/{server_id}/metadata → parent only
if op.path.rstrip("/").endswith("{" + only + "}"):
leaf = only
if rid:
local["_item_id"] = rid
local["id"] = rid
if leaf:
local[leaf] = rid
for param in path_params:
if param == leaf:
continue
if param in ctx:
local[param] = ctx[param]
continue
alias = parent_aliases.get(param)
if alias and alias in ctx:
local[param] = ctx[alias]
# Swift / Heat path params that are not *_id
for param in path_params:
if param in local:
continue
if param in {"container", "object", "object_name", "stack_name", "account"}:
for key in (param, "object" if param == "object_name" else param):
if key in ctx:
local[param] = ctx[key]
break
# For action ops require parent id
if op.kind == "action" and not rid and "server" in (op.path or ""):
rid = ctx.get("server")
if rid:
local["_item_id"] = rid
local["id"] = rid
local["server_id"] = rid
result, payload = probe_operation(
host, pack, op, token=token, ctx=local, project_id=project_id, mode="lifecycle"
)
# If item missing and we got 404 on GET/PUT/PATCH/DELETE — create then retry once
if (
result.status == 404
and op.method in {"GET", "PUT", "PATCH", "DELETE", "POST"}
and "{" in op.path
):
# try creating a sibling via collection POST of same resource
create_op = next(
(
c
for c in ops
if c.method == "POST"
and c.kind in {"collection", "custom"}
and c.resource_type == op.resource_type
and "{" not in c.path
),
None,
)
if create_op is not None:
cre, cre_body = probe_operation(
host,
pack,
create_op,
token=token,
ctx=local,
project_id=project_id,
mode="lifecycle",
)
new_id = _extract_id(cre_body) if cre.status in SUCCESS else None
if new_id:
local["_item_id"] = new_id
local["id"] = new_id
local[op.resource_type] = new_id
created_for_type[op.resource_type] = new_id
result, payload = probe_operation(
host,
pack,
op,
token=token,
ctx=local,
project_id=project_id,
mode="lifecycle",
)
# Idempotent DELETE: child already removed by parent cascade is OK
if op.method == "DELETE" and result.status == 404:
result.status = 204
result.detail = ""
report.results.append(result)
done.add((op.method, op.path))
return report
def probe_series(
series: str,
*,
host: str = "http://127.0.0.1:5000",
methods: frozenset[str] | None = None,
collections_only: bool = False,
lifecycle: bool = True,
) -> ProbeReport:
"""Activate ``series`` and probe pack operations (lifecycle by default)."""
if lifecycle and not collections_only and methods is None:
return probe_series_lifecycle(series, host=host)
activate_series(host, series)
token, auth_body = issue_token(host)
project_id = str(((auth_body.get("token") or {}).get("project") or {}).get("id") or "")
packs = load_series_pack(series)
report = ProbeReport(series=series, host=host, mode="probe")
ctx = _seed_context(host, token, project_id)
for name in sorted(packs):
pack = packs[name]
for op in pack.operations:
if methods and op.method not in methods:
continue
if collections_only and ("{" in op.path or op.method != "GET"):
continue
result, _ = probe_operation(
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="probe"
)
report.results.append(result)
return report
def format_report(report: ProbeReport) -> str:
lines = [
f"series={report.series} host={report.host} mode={report.mode} "
f"ok={report.ok_count}/{len(report.results)} fail={len(report.failures)}",
]
# status histogram
hist: dict[int, int] = defaultdict(int)
for r in report.results:
hist[r.status] += 1
lines.append(" statuses: " + ", ".join(f"{k}:{hist[k]}" for k in sorted(hist)))
for fail in report.failures[:100]:
lines.append(
f" FAIL {fail.status} {fail.method} {fail.service} {fail.path} "
f"({fail.operation_id}) {fail.detail}"
)
if len(report.failures) > 100:
lines.append(f" ... and {len(report.failures) - 100} more")
return "\n".join(lines)