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:
@@ -0,0 +1 @@
|
||||
"""OpenStack API surfaces (Keystone, Nova, Neutron, Glance, Cinder)."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP routers for OpenStack services."""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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}}}
|
||||
@@ -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
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user