Initial release of the oVirt/RHV Engine API simulator.
Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""oVirt / RHV Engine API simulator domain package."""
|
||||
|
||||
__all__ = ["mount_ovirt_routes"]
|
||||
|
||||
|
||||
def mount_ovirt_routes(*args, **kwargs): # lazy re-export
|
||||
from app.ovirt.mount import mount_ovirt_routes as _mount
|
||||
|
||||
return _mount(*args, **kwargs)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""oVirt Engine SSO OAuth2 + Basic auth + session cookies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
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.ovirt.errors import OVirtError
|
||||
from app.ovirt.settings import (
|
||||
OPT_BASIC_SESSION_TTL_SECONDS,
|
||||
OPT_DEFAULT_API_SCOPE,
|
||||
OPT_DEFAULT_AUTH_DOMAIN,
|
||||
OPT_DEFAULT_TOKEN_TYPE,
|
||||
OPT_DEFAULT_USER_ROLE,
|
||||
OPT_OAUTH_TOKEN_TTL_SECONDS,
|
||||
option_int,
|
||||
option_value,
|
||||
)
|
||||
from app.security.auth import verify_secret
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
token_id: str
|
||||
user_id: UUID
|
||||
user_name: str
|
||||
domain: str
|
||||
roles: tuple[str, ...]
|
||||
expires_at: datetime
|
||||
is_admin: bool
|
||||
scope: str
|
||||
|
||||
|
||||
async def authenticate_password(
|
||||
conn: Connection,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve user@domain credentials."""
|
||||
|
||||
name, _, domain = username.partition("@")
|
||||
if not domain:
|
||||
domain = await option_value(conn, OPT_DEFAULT_AUTH_DOMAIN)
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT u.id, u.name, u.password_hash, u.enabled, d.name AS domain_name
|
||||
FROM ov_users u
|
||||
JOIN ov_domains d ON d.id = u.domain_id
|
||||
WHERE u.name = $1 AND d.name = $2""",
|
||||
name,
|
||||
domain,
|
||||
)
|
||||
if row is None or not row["enabled"] or not verify_secret(password, row["password_hash"]):
|
||||
raise OVirtError("Unauthorized", "Incorrect credentials", status_code=401)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def issue_oauth_token(
|
||||
conn: Connection,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
scope: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
token_type = await option_value(conn, OPT_DEFAULT_TOKEN_TYPE)
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = await option_int(conn, OPT_OAUTH_TOKEN_TTL_SECONDS)
|
||||
scope = scope or default_scope
|
||||
if scope and default_scope not in scope.split():
|
||||
raise OVirtError("Unauthorized", "Invalid scope", status_code=400)
|
||||
user = await authenticate_password(conn, username, password)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
expires = now + timedelta(seconds=ttl_seconds)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
|
||||
VALUES($1, $2, $3, $4, $5, false)""",
|
||||
token,
|
||||
user["id"],
|
||||
scope,
|
||||
expires,
|
||||
now,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
|
||||
return {
|
||||
"access_token": row["id"],
|
||||
"token_type": token_type,
|
||||
"scope": row["scope"],
|
||||
"exp": int(row["expires_at"].timestamp()),
|
||||
}
|
||||
|
||||
|
||||
async def validate_bearer(conn: Connection, token: str) -> AuthContext:
|
||||
if not token:
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT t.id, t.user_id, t.expires_at, t.revoked, t.scope,
|
||||
u.name AS user_name, d.name AS domain_name
|
||||
FROM ov_tokens t
|
||||
JOIN ov_users u ON u.id = t.user_id
|
||||
JOIN ov_domains d ON d.id = u.domain_id
|
||||
WHERE t.id = $1""",
|
||||
token,
|
||||
)
|
||||
if row is None or row["revoked"]:
|
||||
raise OVirtError("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 OVirtError("Unauthorized", "Token has expired", status_code=401)
|
||||
roles = await _roles_for_user(conn, row["user_id"])
|
||||
return AuthContext(
|
||||
token_id=str(row["id"]),
|
||||
user_id=row["user_id"],
|
||||
user_name=str(row["user_name"]),
|
||||
domain=str(row["domain_name"]),
|
||||
roles=roles,
|
||||
expires_at=expires,
|
||||
is_admin="SuperUser" in roles or "admin" in roles,
|
||||
scope=str(row["scope"] or ""),
|
||||
)
|
||||
|
||||
|
||||
async def validate_basic(conn: Connection, header_value: str) -> AuthContext:
|
||||
try:
|
||||
encoded = header_value.split(" ", 1)[1].strip()
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, _, password = decoded.partition(":")
|
||||
except Exception as exc:
|
||||
raise OVirtError("Unauthorized", "Malformed basic auth", status_code=401) from exc
|
||||
user = await authenticate_password(conn, username, password)
|
||||
token = secrets.token_urlsafe(24)
|
||||
now = datetime.now(UTC)
|
||||
ttl = await option_int(conn, OPT_BASIC_SESSION_TTL_SECONDS)
|
||||
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
expires = now + timedelta(seconds=ttl)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
|
||||
VALUES($1, $2, $3, $4, $5, false)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
token,
|
||||
user["id"],
|
||||
default_scope,
|
||||
expires,
|
||||
now,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
|
||||
roles = await _roles_for_user(conn, user["id"])
|
||||
return AuthContext(
|
||||
token_id=str(row["id"]),
|
||||
user_id=user["id"],
|
||||
user_name=str(user["name"]),
|
||||
domain=str(user["domain_name"]),
|
||||
roles=roles,
|
||||
expires_at=row["expires_at"] if row["expires_at"].tzinfo else row["expires_at"].replace(tzinfo=UTC),
|
||||
is_admin="SuperUser" in roles or "admin" in roles,
|
||||
scope=str(row["scope"] or default_scope),
|
||||
)
|
||||
|
||||
|
||||
async def _roles_for_user(conn: Connection, user_id: UUID) -> tuple[str, ...]:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT r.name FROM ov_permissions p
|
||||
JOIN ov_roles r ON r.id = p.role_id
|
||||
WHERE p.user_id = $1""",
|
||||
user_id,
|
||||
)
|
||||
names = [str(r["name"]) for r in rows]
|
||||
if not names:
|
||||
default_role = await option_value(conn, OPT_DEFAULT_USER_ROLE)
|
||||
exists = await conn.fetchval("SELECT 1 FROM ov_roles WHERE name=$1", default_role)
|
||||
if exists:
|
||||
names = [default_role]
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def extract_auth(headers: dict[str, str]) -> tuple[str, str] | None:
|
||||
"""Return ('bearer'|'basic'|'session', credential) or None."""
|
||||
|
||||
lower = {k.lower(): v for k, v in headers.items()}
|
||||
auth = lower.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
return "bearer", auth.split(" ", 1)[1].strip()
|
||||
if auth.lower().startswith("basic "):
|
||||
return "basic", auth
|
||||
session = lower.get("prefer") or ""
|
||||
if "jsessionid" in lower:
|
||||
return "session", lower["jsessionid"]
|
||||
cookie = lower.get("cookie", "")
|
||||
for part in cookie.split(";"):
|
||||
part = part.strip()
|
||||
if part.lower().startswith("jsessionid="):
|
||||
return "session", part.split("=", 1)[1]
|
||||
if part.lower().startswith("ovirt_token="):
|
||||
return "bearer", part.split("=", 1)[1]
|
||||
if session.lower().startswith("persistent-auth"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_request_auth(conn: Connection, headers: dict[str, str]) -> AuthContext:
|
||||
kind = extract_auth(headers)
|
||||
if kind is None:
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
mode, credential = kind
|
||||
if mode == "bearer":
|
||||
return await validate_bearer(conn, credential)
|
||||
if mode == "basic":
|
||||
return await validate_basic(conn, credential)
|
||||
if mode == "session":
|
||||
return await validate_bearer(conn, credential)
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Resolve oVirt Engine series contract pack locations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.ovirt.opspec import OperationSpec, SeriesPack
|
||||
|
||||
_SERIES_MAJOR = {
|
||||
"3.0": 30,
|
||||
"3.1": 31,
|
||||
"3.2": 32,
|
||||
"3.3": 33,
|
||||
"3.4": 34,
|
||||
"3.5": 35,
|
||||
"3.6": 36,
|
||||
"4.3": 43,
|
||||
"4.4": 44,
|
||||
"4.5": 45,
|
||||
"master": 50,
|
||||
}
|
||||
_MAJOR_SERIES = {v: k for k, v in _SERIES_MAJOR.items()}
|
||||
|
||||
|
||||
def contracts_root() -> Path:
|
||||
"""Locate ``contracts/ovirt`` for source, wheel, or Docker layouts."""
|
||||
|
||||
env = os.environ.get("OVIRT_CONTRACTS_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
|
||||
here = Path(__file__).resolve()
|
||||
candidates = [
|
||||
here.parents[2] / "contracts" / "ovirt", # repo checkout or site-packages
|
||||
Path("/app/contracts/ovirt"), # runtime image WORKDIR layout
|
||||
Path.cwd() / "contracts" / "ovirt",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.is_dir():
|
||||
return path
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def series_for_major(major: int) -> str:
|
||||
return _MAJOR_SERIES.get(major, "4.5")
|
||||
|
||||
|
||||
def major_for_series(series: str) -> int:
|
||||
return _SERIES_MAJOR.get(series.lower(), 45)
|
||||
|
||||
|
||||
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)),
|
||||
"api_version": data.get("api_version", "4"),
|
||||
"operation_count": data.get("operation_count", 0),
|
||||
"service_count": data.get("service_count", 1),
|
||||
"checksum": data.get("checksum", ""),
|
||||
"generated_at": data.get("generated_at", ""),
|
||||
"deltas": data.get("deltas", {}),
|
||||
"product": data.get("product", {}),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _op_from_dict(raw: dict[str, Any]) -> OperationSpec:
|
||||
return OperationSpec(
|
||||
operation_id=str(raw["operation_id"]),
|
||||
method=raw["method"],
|
||||
path=str(raw["path"]),
|
||||
resource_type=str(raw.get("resource_type") or "object"),
|
||||
collection_key=str(raw.get("collection_key") or "objects"),
|
||||
element=str(raw.get("element") or raw.get("resource_type") or "object"),
|
||||
kind=str(raw.get("kind") or "collection"),
|
||||
search=bool(raw.get("search", False)),
|
||||
introduced_in=str(raw.get("introduced_in") or "3.0"),
|
||||
requires_auth=bool(raw.get("requires_auth", True)),
|
||||
status_code=int(raw.get("status_code") or 200),
|
||||
create_status=int(raw.get("create_status") or 201),
|
||||
notes=str(raw.get("notes") or ""),
|
||||
response_fixture=raw.get("response_fixture"),
|
||||
)
|
||||
|
||||
|
||||
def load_series_pack(series: str) -> SeriesPack:
|
||||
series = series.lower()
|
||||
series_dir = contracts_root() / series
|
||||
man_path = series_dir / "manifest.json"
|
||||
api_path = series_dir / "api.json"
|
||||
if not man_path.is_file() or not api_path.is_file():
|
||||
raise FileNotFoundError(f"oVirt contract pack not found: {series_dir}")
|
||||
man = json.loads(man_path.read_text())
|
||||
data = json.loads(api_path.read_text())
|
||||
ops = [_op_from_dict(raw) for raw in data.get("operations") or []]
|
||||
return SeriesPack(
|
||||
series=str(man.get("series", series)),
|
||||
api_version=str(man.get("api_version") or data.get("api_version") or "4"),
|
||||
major=int(man.get("major") or major_for_series(series)),
|
||||
operations=ops,
|
||||
product=dict(man.get("product") or data.get("product") or {}),
|
||||
entry_point_links=list(man.get("entry_point_links") or []),
|
||||
checksum=str(man.get("checksum") or ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContractRuntime:
|
||||
series: str = "4.5"
|
||||
pack: SeriesPack | None = None
|
||||
_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.pack = load_series_pack(target)
|
||||
self.series = target
|
||||
return self.summary()
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
pack = self.pack
|
||||
if pack is None:
|
||||
return {
|
||||
"series": self.series,
|
||||
"operation_count": 0,
|
||||
"major": major_for_series(self.series),
|
||||
}
|
||||
return {
|
||||
"series": pack.series,
|
||||
"major": pack.major,
|
||||
"api_version": pack.api_version,
|
||||
"operation_count": pack.operation_count(),
|
||||
"service_count": 1,
|
||||
"checksum": pack.checksum,
|
||||
"product": pack.product,
|
||||
"deltas": next(
|
||||
(s.get("deltas") for s in list_series() if s["series"] == pack.series),
|
||||
{},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_RUNTIME = ContractRuntime()
|
||||
|
||||
|
||||
def get_runtime() -> ContractRuntime:
|
||||
return _RUNTIME
|
||||
|
||||
|
||||
def ensure_loaded(series: str = "4.5") -> ContractRuntime:
|
||||
rt = get_runtime()
|
||||
if rt.pack is None:
|
||||
try:
|
||||
rt.reload(series)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return rt
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Large demo datacenter seed (~1000 VMs + full inventory)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
from app.ovirt.seed import DEMO_PROFILE, clear_ovirt_state, seed_ovirt
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
DEMO_VM_COUNT = 1000
|
||||
|
||||
|
||||
async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
"""Replace state with a multi-DC demo inventory including ~1000 VMs."""
|
||||
|
||||
await clear_ovirt_state(conn)
|
||||
|
||||
domain_id = stable_id("domain", "internal")
|
||||
await conn.execute("INSERT INTO ov_domains(id, name) VALUES($1,'internal')", domain_id)
|
||||
pwd = hash_secret("secret", salt=b"ovirt-sim-v1-salt!")
|
||||
role_super = stable_id("role", "SuperUser")
|
||||
role_user = stable_id("role", "UserRole")
|
||||
role_cluster = stable_id("role", "ClusterAdmin")
|
||||
for rid, name, admin in (
|
||||
(role_super, "SuperUser", True),
|
||||
(role_user, "UserRole", False),
|
||||
(role_cluster, "ClusterAdmin", True),
|
||||
(stable_id("role", "TemplateAdmin"), "TemplateAdmin", True),
|
||||
(stable_id("role", "StorageAdmin"), "StorageAdmin", True),
|
||||
):
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_roles(id, name, administrative) VALUES($1,$2,$3)",
|
||||
rid,
|
||||
name,
|
||||
admin,
|
||||
)
|
||||
|
||||
users = {}
|
||||
for uname, role in (
|
||||
("admin", role_super),
|
||||
("ops", role_cluster),
|
||||
("developer", role_user),
|
||||
("demo", role_user),
|
||||
):
|
||||
uid = stable_id("user", uname)
|
||||
users[uname] = uid
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
|
||||
VALUES($1,$2,$3,$4,true,$5)""",
|
||||
uid,
|
||||
domain_id,
|
||||
uname,
|
||||
pwd,
|
||||
f"{uname}@internal",
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
|
||||
VALUES($1,$2,$3,'system')""",
|
||||
stable_id("perm", uname),
|
||||
role,
|
||||
uid,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,'engine-admins')",
|
||||
stable_id("group", "engine-admins"),
|
||||
domain_id,
|
||||
)
|
||||
for gname in ("developers", "operators", "readers"):
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,$3)",
|
||||
stable_id("group", gname),
|
||||
domain_id,
|
||||
gname,
|
||||
)
|
||||
|
||||
# 3 datacenters, multiple clusters/hosts/storage/networks
|
||||
dc_specs = [
|
||||
("dc-prod", "Production", False, 4, 5),
|
||||
("dc-stage", "Staging", False, 4, 4),
|
||||
("dc-edge", "Edge", True, 4, 3),
|
||||
]
|
||||
clusters: list[tuple[Any, Any, str]] = []
|
||||
hosts: list[Any] = []
|
||||
networks: list[Any] = []
|
||||
profiles: list[Any] = []
|
||||
storage_domains: list[Any] = []
|
||||
|
||||
storage_types = ["nfs", "iscsi", "fcp", "localfs"]
|
||||
for dc_key, dc_name, local, maj, minor in dc_specs:
|
||||
dc_id = stable_id("dc", dc_key)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_datacenters(id, name, description, local, status, version_major, version_minor)
|
||||
VALUES($1,$2,$3,$4,'up',$5,$6)""",
|
||||
dc_id,
|
||||
dc_name,
|
||||
f"{dc_name} datacenter",
|
||||
local,
|
||||
maj,
|
||||
minor,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'Default','Default quota')""",
|
||||
stable_id("quota", dc_key),
|
||||
dc_id,
|
||||
)
|
||||
for ci in range(2):
|
||||
cname = f"{dc_key}-cluster-{ci+1}"
|
||||
cid = stable_id("cluster", cname)
|
||||
clusters.append((cid, dc_id, cname))
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_clusters(id, datacenter_id, name, description, version_major, version_minor)
|
||||
VALUES($1,$2,$3,$4,$5,$6)""",
|
||||
cid,
|
||||
dc_id,
|
||||
cname,
|
||||
f"Cluster {ci+1} in {dc_name}",
|
||||
maj,
|
||||
minor,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_affinity_groups(id, cluster_id, name, enforcing, positive)
|
||||
VALUES($1,$2,'web-affinity',true,true)""",
|
||||
stable_id("ag", cname),
|
||||
cid,
|
||||
)
|
||||
for hi in range(4):
|
||||
hname = f"{cname}-host-{hi+1:02d}"
|
||||
hid = stable_id("host", hname)
|
||||
hosts.append(hid)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_hosts(id, cluster_id, name, address, status, memory, cpu_cores, type)
|
||||
VALUES($1,$2,$3,$4,'up',$5,32,'rhel')""",
|
||||
hid,
|
||||
cid,
|
||||
hname,
|
||||
f"10.{dc_specs.index((dc_key, dc_name, local, maj, minor))+10}.{ci+1}.{hi+10}",
|
||||
(256 + hi * 32) * 1024**3,
|
||||
)
|
||||
# networks
|
||||
for nname, vlan in (("ovirtmgmt", None), ("vm-net", 100), ("storage-net", 200)):
|
||||
nid = stable_id("net", dc_key, nname)
|
||||
networks.append(nid)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_networks(id, datacenter_id, name, description, vlan_id)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
nid,
|
||||
dc_id,
|
||||
nname if nname != "ovirtmgmt" else f"{dc_key}-ovirtmgmt" if dc_key != "dc-prod" else "ovirtmgmt",
|
||||
f"{nname} in {dc_name}",
|
||||
vlan,
|
||||
)
|
||||
pid = stable_id("vnic", dc_key, nname)
|
||||
profiles.append(pid)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_vnic_profiles(id, network_id, name) VALUES($1,$2,$3)",
|
||||
pid,
|
||||
nid,
|
||||
nname,
|
||||
)
|
||||
# storage domains
|
||||
for si, stype in enumerate(storage_types):
|
||||
sname = f"{dc_key}-{stype}-{si+1}"
|
||||
sid = stable_id("sd", sname)
|
||||
storage_domains.append(sid)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domains(id, name, type, storage_type, status, available, used)
|
||||
VALUES($1,$2,'data',$3,'active',$4,$5)""",
|
||||
sid,
|
||||
sname,
|
||||
stype,
|
||||
(5 + si) * 1024**4,
|
||||
si * 200 * 1024**3,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domain_attachments(id, storage_domain_id, datacenter_id, status)
|
||||
VALUES($1,$2,$3,'active')""",
|
||||
stable_id("sda", sname),
|
||||
sid,
|
||||
dc_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_connections(id, type, address, path)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
stable_id("sc", sname),
|
||||
stype if stype != "localfs" else "localfs",
|
||||
f"storage-{si}.lab.local",
|
||||
f"/export/{sname}",
|
||||
)
|
||||
|
||||
blank_id = stable_id("template", "Blank")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
|
||||
VALUES($1,$2,'Blank','Blank template','ok',$3,1,1)""",
|
||||
blank_id,
|
||||
clusters[0][0],
|
||||
1024**3,
|
||||
)
|
||||
for tname, mem, cores in (
|
||||
("rhel8-base", 4 * 1024**3, 2),
|
||||
("rhel9-base", 4 * 1024**3, 2),
|
||||
("win2022-base", 8 * 1024**3, 4),
|
||||
("ubuntu2204-base", 2 * 1024**3, 2),
|
||||
):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
|
||||
VALUES($1,$2,$3,$4,'ok',$5,1,$6)""",
|
||||
stable_id("template", tname),
|
||||
clusters[0][0],
|
||||
tname,
|
||||
f"Template {tname}",
|
||||
mem,
|
||||
cores,
|
||||
)
|
||||
|
||||
# ~1000 VMs spread across clusters
|
||||
statuses = ["up", "up", "up", "down", "down", "suspended", "powering_up"]
|
||||
os_types = ["rhel_8x64", "rhel_9x64", "ubuntu_22_04", "windows_2022", "other"]
|
||||
default_profile = profiles[0]
|
||||
default_sd = storage_domains[0]
|
||||
|
||||
vm_rows = []
|
||||
disk_rows = []
|
||||
da_rows = []
|
||||
nic_rows = []
|
||||
snap_rows = []
|
||||
|
||||
for i in range(DEMO_VM_COUNT):
|
||||
cluster_id, _dc, cname = clusters[i % len(clusters)]
|
||||
host_id = hosts[i % len(hosts)] if i % 3 != 0 else None
|
||||
status = statuses[i % len(statuses)]
|
||||
if status == "down":
|
||||
host_id = None
|
||||
name = f"vm-{i+1:04d}"
|
||||
vm_id = stable_id("vm", name)
|
||||
memory = (1 + (i % 8)) * 1024**3
|
||||
cores = 1 + (i % 8)
|
||||
vm_rows.append(
|
||||
(
|
||||
vm_id,
|
||||
cluster_id,
|
||||
blank_id,
|
||||
name,
|
||||
f"Demo VM {i+1}",
|
||||
status,
|
||||
memory,
|
||||
1,
|
||||
cores,
|
||||
1,
|
||||
os_types[i % len(os_types)],
|
||||
"server" if i % 5 else "desktop",
|
||||
host_id,
|
||||
)
|
||||
)
|
||||
disk_id = stable_id("disk", name)
|
||||
size = (10 + (i % 50)) * 1024**3
|
||||
disk_rows.append(
|
||||
(disk_id, f"{name}_Disk1", size, size, storage_domains[i % len(storage_domains)])
|
||||
)
|
||||
da_rows.append((stable_id("da", name), vm_id, disk_id, True))
|
||||
nic_rows.append(
|
||||
(
|
||||
stable_id("nic", name),
|
||||
vm_id,
|
||||
"nic1",
|
||||
f"00:1a:4a:{(i >> 16) & 0xFF:02x}:{(i >> 8) & 0xFF:02x}:{i & 0xFF:02x}",
|
||||
profiles[i % len(profiles)] if profiles else default_profile,
|
||||
)
|
||||
)
|
||||
if i % 7 == 0:
|
||||
snap_rows.append(
|
||||
(stable_id("snap", name, "1"), vm_id, f"snapshot-{name}", "ok")
|
||||
)
|
||||
|
||||
# Batch insert VMs
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
|
||||
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type, host_id)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)""",
|
||||
vm_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_disks(id, name, provisioned_size, actual_size, storage_domain_id)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
disk_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, bootable)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
da_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_nics(id, vm_id, name, mac_address, vnic_profile_id)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
nic_rows,
|
||||
)
|
||||
if snap_rows:
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_snapshots(id, vm_id, description, status)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
snap_rows,
|
||||
)
|
||||
|
||||
# Tags, bookmarks, events, jobs, surface objects
|
||||
for tname in ("production", "web", "database", "batch", "gpu"):
|
||||
tid = stable_id("tag", tname)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_tags(id, name, description) VALUES($1,$2,$3)",
|
||||
tid,
|
||||
tname,
|
||||
f"Tag {tname}",
|
||||
)
|
||||
for i in range(0, min(50, DEMO_VM_COUNT), 10):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
|
||||
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
|
||||
stable_id("ta", tname, str(i)),
|
||||
tid,
|
||||
stable_id("vm", f"vm-{i+1:04d}"),
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'UpVMs','Vms: status=up')",
|
||||
stable_id("bm", "UpVMs"),
|
||||
)
|
||||
for i in range(50):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_events(code, severity, description, user_id)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
1000 + i,
|
||||
"normal" if i % 4 else "warning",
|
||||
f"Demo event {i}",
|
||||
users["admin"],
|
||||
)
|
||||
for i in range(20):
|
||||
jid = stable_id("job", str(i))
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, owner_id)
|
||||
VALUES($1,$2,'finished',$3)""",
|
||||
jid,
|
||||
f"Demo job {i}",
|
||||
users["admin"],
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number)
|
||||
VALUES($1,$2,$3,'finished','executing',1)""",
|
||||
stable_id("step", str(i)),
|
||||
jid,
|
||||
f"Step for job {i}",
|
||||
)
|
||||
|
||||
for collection, names in (
|
||||
("instancetypes", ["Tiny", "Small", "Medium", "Large", "XLarge"]),
|
||||
("macpools", ["Default", "Secondary"]),
|
||||
("schedulingpolicies", ["evenly_distributed", "power_saving", "vm_evenly_distributed"]),
|
||||
("schedulingpolicyunits", ["EvenlyDistributed", "PowerSaving", "VmEvenlyDistributed"]),
|
||||
("clusterlevels", ["4.3", "4.4", "4.5"]),
|
||||
("icons", ["default", "custom"]),
|
||||
("operatingsystems", ["rhel_8x64", "rhel_9x64", "windows_2022", "ubuntu_22_04"]),
|
||||
("networkfilters", ["vdsm-no-mac-spoofing"]),
|
||||
("vmpools", ["web-pool", "batch-pool"]),
|
||||
("affinitylabels", ["label-a", "label-b"]),
|
||||
("katelloerrata", ["RHSA-2024:0001", "RHBA-2024:0002"]),
|
||||
("externalhostproviders", ["foreman-lab"]),
|
||||
("openstacknetworkproviders", ["ovn-provider"]),
|
||||
("openstackimageproviders", ["glance-lab"]),
|
||||
("openstackvolumeproviders", ["cinder-lab"]),
|
||||
("imagetransfers", ["transfer-1"]),
|
||||
):
|
||||
for name in names:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,'ok',$4::jsonb)""",
|
||||
stable_id("obj", collection, name),
|
||||
collection,
|
||||
name,
|
||||
json.dumps({"name": name, "description": f"demo {collection}"}),
|
||||
)
|
||||
|
||||
from app.ovirt.settings import seed_engine_options
|
||||
|
||||
await seed_engine_options(conn)
|
||||
|
||||
from app.ovirt.seed_nested import seed_nested_for_inventory
|
||||
|
||||
dc_ids = [stable_id("dc", key) for key, *_ in dc_specs]
|
||||
cluster_ids = [c[0] for c in clusters]
|
||||
template_ids = [
|
||||
blank_id,
|
||||
*[
|
||||
stable_id("template", n)
|
||||
for n in ("rhel8-base", "rhel9-base", "win2022-base", "ubuntu2204-base")
|
||||
],
|
||||
]
|
||||
tag_ids = [stable_id("tag", t) for t in ("production", "web", "database", "batch", "gpu")]
|
||||
await seed_nested_for_inventory(
|
||||
conn,
|
||||
admin_user_id=users["admin"],
|
||||
role_user_id=role_user,
|
||||
datacenter_ids=dc_ids,
|
||||
cluster_ids=cluster_ids,
|
||||
host_ids=list(hosts),
|
||||
network_ids=list(networks),
|
||||
storage_domain_ids=list(storage_domains),
|
||||
template_ids=template_ids,
|
||||
vm_ids=[stable_id("vm", f"vm-{i:04d}") for i in range(1, DEMO_VM_COUNT + 1)],
|
||||
disk_ids=[stable_id("disk", f"vm-{i:04d}") for i in range(1, DEMO_VM_COUNT + 1)],
|
||||
tag_ids=tag_ids,
|
||||
user_ids=list(users.values()),
|
||||
group_ids=[
|
||||
stable_id("group", n)
|
||||
for n in ("engine-admins", "developers", "operators", "readers")
|
||||
],
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", DEMO_PROFILE
|
||||
)
|
||||
return {
|
||||
"profile": DEMO_PROFILE,
|
||||
"vms": DEMO_VM_COUNT,
|
||||
"hosts": len(hosts),
|
||||
"datacenters": len(dc_specs),
|
||||
"clusters": len(clusters),
|
||||
"storage_domains": len(storage_domains),
|
||||
"networks": len(networks),
|
||||
}
|
||||
|
||||
|
||||
# Re-export for web routes
|
||||
__all__ = ["DEMO_PROFILE", "DEMO_VM_COUNT", "clear_ovirt_state", "seed_ovirt", "seed_ovirt_demo"]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""FastAPI dependencies for oVirt routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.ovirt.auth import AuthContext, resolve_request_auth
|
||||
from app.ovirt.errors import OVirtError
|
||||
|
||||
|
||||
def get_db(request: Request) -> AsyncpgDatabase:
|
||||
db = getattr(request.app.state, "database", None)
|
||||
if db is None:
|
||||
raise OVirtError("ServiceUnavailable", "Database not ready", status_code=503)
|
||||
return db # type: ignore[return-value]
|
||||
|
||||
|
||||
async def require_auth(request: Request) -> AuthContext:
|
||||
db = get_db(request)
|
||||
async with db.pool.acquire() as conn:
|
||||
return await resolve_request_auth(conn, dict(request.headers))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""oVirt Engine fault responses (XML/JSON)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
|
||||
class OVirtError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
reason: str,
|
||||
detail: str,
|
||||
*,
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
self.status_code = status_code
|
||||
self.code = code or reason
|
||||
|
||||
|
||||
def _wants_xml(request: Request) -> bool:
|
||||
accept = (request.headers.get("accept") or "").lower()
|
||||
content = (request.headers.get("content-type") or "").lower()
|
||||
if "application/xml" in accept or "text/xml" in accept:
|
||||
return True
|
||||
if "json" in accept:
|
||||
return False
|
||||
if "xml" in content:
|
||||
return True
|
||||
# Engine default is XML when Accept is omitted / */*
|
||||
return "json" not in accept
|
||||
|
||||
|
||||
def fault_body(error: OVirtError, *, as_xml: bool) -> str | dict[str, Any]:
|
||||
if as_xml:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
f"<fault><reason>{_esc(error.reason)}</reason>"
|
||||
f"<detail>{_esc(error.detail)}</detail></fault>"
|
||||
)
|
||||
return {"fault": {"reason": error.reason, "detail": error.detail}}
|
||||
|
||||
|
||||
def _esc(value: str) -> str:
|
||||
return (
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
async def ovirt_error_handler(request: Request, exc: OVirtError) -> Response:
|
||||
as_xml = _wants_xml(request)
|
||||
body = fault_body(exc, as_xml=as_xml)
|
||||
if as_xml:
|
||||
return Response(
|
||||
content=str(body),
|
||||
status_code=exc.status_code,
|
||||
media_type="application/xml",
|
||||
)
|
||||
return JSONResponse(content=body, status_code=exc.status_code)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Deterministic UUIDs for seed data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
NAMESPACE = UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
|
||||
|
||||
def stable_id(*parts: str) -> UUID:
|
||||
return uuid5(NAMESPACE, ":".join(parts))
|
||||
|
||||
|
||||
def stable_str(*parts: str) -> str:
|
||||
return str(stable_id(*parts))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Async job/task helpers for Engine actions — rows are always loaded from Postgres."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asyncpg import Connection
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.ovirt.repr import action_entity, job_entity
|
||||
from app.ovirt.serialize import respond
|
||||
from app.ovirt.settings import (
|
||||
OPT_DEFAULT_ACTION_STATUS,
|
||||
OPT_DEFAULT_JOB_STATUS_COMPLETE,
|
||||
OPT_DEFAULT_JOB_STATUS_STARTED,
|
||||
OPT_DEFAULT_JOB_STEP_TYPE,
|
||||
option_value,
|
||||
)
|
||||
|
||||
|
||||
async def create_job(
|
||||
conn: Connection,
|
||||
*,
|
||||
description: str,
|
||||
owner_id: Any = None,
|
||||
auto_complete: bool = True,
|
||||
action_status: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
job_id = uuid4()
|
||||
now = datetime.now(UTC)
|
||||
finished = await option_value(conn, OPT_DEFAULT_JOB_STATUS_COMPLETE)
|
||||
started = await option_value(conn, OPT_DEFAULT_JOB_STATUS_STARTED)
|
||||
status = finished if auto_complete else started
|
||||
ended = now if auto_complete else None
|
||||
if action_status is None:
|
||||
action_status = await option_value(conn, OPT_DEFAULT_ACTION_STATUS)
|
||||
step_type = await option_value(conn, OPT_DEFAULT_JOB_STEP_TYPE)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, started, ended, owner_id, data)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7::jsonb)""",
|
||||
job_id,
|
||||
description,
|
||||
status,
|
||||
now,
|
||||
ended,
|
||||
owner_id,
|
||||
json.dumps({"action_status": action_status}),
|
||||
)
|
||||
step_id = uuid4()
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number, started, ended)
|
||||
VALUES($1, $2, $3, $4, $5, 1, $6, $7)""",
|
||||
step_id,
|
||||
job_id,
|
||||
description,
|
||||
status,
|
||||
step_type,
|
||||
now,
|
||||
ended,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_jobs WHERE id=$1", job_id)
|
||||
return job_entity(row)
|
||||
|
||||
|
||||
async def complete_job(conn: Connection, job_id: str, *, status: str | None = None) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if status is None:
|
||||
status = await option_value(conn, OPT_DEFAULT_JOB_STATUS_COMPLETE)
|
||||
await conn.execute(
|
||||
"UPDATE ov_jobs SET status=$2, ended=$3 WHERE id=$1::uuid",
|
||||
job_id,
|
||||
status,
|
||||
now,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE ov_job_steps SET status=$2, ended=$3 WHERE job_id=$1::uuid",
|
||||
job_id,
|
||||
status,
|
||||
now,
|
||||
)
|
||||
|
||||
|
||||
async def respond_action(
|
||||
request: Request,
|
||||
conn: Connection,
|
||||
*,
|
||||
description: str,
|
||||
owner_id: Any = None,
|
||||
auto_complete: bool = True,
|
||||
) -> Response:
|
||||
"""Persist a job and return an action entity built solely from the DB row."""
|
||||
|
||||
job = await create_job(
|
||||
conn, description=description, owner_id=owner_id, auto_complete=auto_complete
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_jobs WHERE id=$1::uuid", job["id"])
|
||||
return respond(request, element="action", data=action_entity(row))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Mount oVirt Engine API + SSO onto the FastAPI application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.ovirt.contract_loader import ensure_loaded
|
||||
from app.ovirt.errors import OVirtError, ovirt_error_handler
|
||||
from app.ovirt.registry import register_ovirt_contract_routes
|
||||
from app.ovirt.routes import engine, sso
|
||||
|
||||
|
||||
def mount_ovirt_routes(app: FastAPI, *, series: str = "4.5") -> None:
|
||||
"""Register Engine REST API, SSO, and load the active series pack.
|
||||
|
||||
Contract operations are registered as individual OpenAPI routes.
|
||||
Catch-all handlers remain as a hidden fallback.
|
||||
"""
|
||||
|
||||
app.add_exception_handler(OVirtError, ovirt_error_handler)
|
||||
app.include_router(sso.router)
|
||||
|
||||
rt = ensure_loaded(series)
|
||||
try:
|
||||
summary = rt.reload(series)
|
||||
except FileNotFoundError:
|
||||
summary = {"operation_count": 0, "series": series}
|
||||
registered = 0
|
||||
if rt.pack is not None:
|
||||
registered = register_ovirt_contract_routes(app, rt.pack)
|
||||
|
||||
# Fallback catch-all after specific contract routes (first match wins).
|
||||
app.include_router(engine.router)
|
||||
|
||||
app.state.ovirt_series = series
|
||||
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
|
||||
app.state.runtime_version = f"ovirt-{series}"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Contract operation specifications for Engine API packs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationSpec:
|
||||
operation_id: str
|
||||
method: HttpMethod
|
||||
path: str
|
||||
resource_type: str
|
||||
collection_key: str
|
||||
element: str
|
||||
kind: str = "collection"
|
||||
search: bool = False
|
||||
introduced_in: str = "3.0"
|
||||
requires_auth: bool = True
|
||||
status_code: int = 200
|
||||
create_status: int = 201
|
||||
notes: str = ""
|
||||
response_fixture: Any | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesPack:
|
||||
series: str
|
||||
api_version: str
|
||||
major: int
|
||||
operations: list[OperationSpec] = field(default_factory=list)
|
||||
product: dict[str, str] = field(default_factory=dict)
|
||||
entry_point_links: list[dict[str, str]] = field(default_factory=list)
|
||||
checksum: str = ""
|
||||
|
||||
def operation_count(self) -> int:
|
||||
return len(self.operations)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Register each Engine contract operation as its own FastAPI route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.api.openapi import contract_openapi_tags
|
||||
from app.ovirt.opspec import OperationSpec, SeriesPack
|
||||
|
||||
Endpoint = Callable[[Request], Awaitable[Response]]
|
||||
_NON_ALNUM = re.compile(r"[^a-zA-Z0-9_]+")
|
||||
|
||||
|
||||
def clear_ovirt_contract_routes(app: FastAPI) -> None:
|
||||
"""Drop contract + Engine fallback routes before a remount."""
|
||||
|
||||
app.router.routes = [
|
||||
route for route in app.router.routes if not _is_swappable_ovirt_route(route)
|
||||
]
|
||||
app.openapi_schema = None
|
||||
|
||||
|
||||
def register_ovirt_contract_routes(app: FastAPI, pack: SeriesPack) -> int:
|
||||
"""Register one FastAPI route per contract operation.
|
||||
|
||||
Paths are taken from the pack as-is (including ``/v3`` / ``/v4`` variants).
|
||||
Returns the number of routes added.
|
||||
"""
|
||||
|
||||
seen: set[tuple[str, str]] = set()
|
||||
registered = 0
|
||||
for op in pack.operations:
|
||||
method = op.method.upper()
|
||||
path = _normalize_route_path(op.path)
|
||||
key = (path, method)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
app.add_api_route(
|
||||
path,
|
||||
_endpoint_for(op),
|
||||
methods=[method],
|
||||
name=f"contract:{method}:{path}",
|
||||
tags=cast(list[str | Enum], contract_openapi_tags(path)),
|
||||
summary=op.notes or op.operation_id,
|
||||
operation_id=_unique_operation_id(op, method, path),
|
||||
openapi_extra={
|
||||
"x-ovirt-operation-id": op.operation_id,
|
||||
"x-ovirt-series": pack.series,
|
||||
"x-ovirt-kind": op.kind,
|
||||
},
|
||||
)
|
||||
registered += 1
|
||||
app.openapi_schema = None
|
||||
return registered
|
||||
|
||||
|
||||
def _is_swappable_ovirt_route(route: object) -> bool:
|
||||
name = getattr(route, "name", None)
|
||||
return isinstance(name, str) and (
|
||||
name.startswith("contract:") or name.startswith("ovirt-fallback:")
|
||||
)
|
||||
|
||||
|
||||
def _unique_operation_id(op: OperationSpec, method: str, path: str) -> str:
|
||||
"""Build a unique OpenAPI operationId (packs repeat ids across /v3|/v4)."""
|
||||
|
||||
base = op.operation_id.replace(".", "_")
|
||||
suffix = _NON_ALNUM.sub("_", f"{method}_{path}").strip("_")
|
||||
return f"{base}__{suffix}"
|
||||
|
||||
|
||||
def _normalize_route_path(path: str) -> str:
|
||||
"""Ensure FastAPI path template form (leading slash, no trailing slash except root)."""
|
||||
|
||||
cleaned = path.strip() or "/"
|
||||
if not cleaned.startswith("/"):
|
||||
cleaned = f"/{cleaned}"
|
||||
if cleaned != "/" and cleaned.endswith("/"):
|
||||
cleaned = cleaned.rstrip("/")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _endpoint_for(op: OperationSpec) -> Endpoint:
|
||||
async def dispatch(request: Request) -> Response:
|
||||
from app.ovirt.routes.engine import handle_engine_request
|
||||
|
||||
return await handle_engine_request(request)
|
||||
|
||||
dispatch.__name__ = f"contract_{op.operation_id.replace('.', '_')}"
|
||||
dispatch.__doc__ = op.notes or op.operation_id
|
||||
return dispatch
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Map database rows to oVirt Engine API entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _data(row: Any) -> dict[str, Any]:
|
||||
raw = row["data"] if "data" in row.keys() else {}
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
return dict(raw or {})
|
||||
|
||||
|
||||
def href(collection: str, object_id: Any) -> str:
|
||||
return f"/ovirt-engine/api/{collection}/{object_id}"
|
||||
|
||||
|
||||
def link(rel: str, path: str) -> dict[str, str]:
|
||||
return {"@rel": rel, "href": path} if False else {"rel": rel, "href": path}
|
||||
|
||||
|
||||
def vm_entity(row: Any) -> dict[str, Any]:
|
||||
vid = str(row["id"])
|
||||
entity = {
|
||||
"id": vid,
|
||||
"href": href("vms", vid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"type": row["type"],
|
||||
"memory": int(row["memory"]),
|
||||
"cpu": {
|
||||
"topology": {
|
||||
"sockets": int(row["cpu_sockets"]),
|
||||
"cores": int(row["cpu_cores"]),
|
||||
"threads": int(row["cpu_threads"]),
|
||||
}
|
||||
},
|
||||
"os": {"type": row["os_type"]},
|
||||
"cluster": {"id": str(row["cluster_id"]), "href": href("clusters", row["cluster_id"])},
|
||||
"link": [
|
||||
{"rel": "diskattachments", "href": f"/ovirt-engine/api/vms/{vid}/diskattachments"},
|
||||
{"rel": "nics", "href": f"/ovirt-engine/api/vms/{vid}/nics"},
|
||||
{"rel": "snapshots", "href": f"/ovirt-engine/api/vms/{vid}/snapshots"},
|
||||
{"rel": "tags", "href": f"/ovirt-engine/api/vms/{vid}/tags"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/vms/{vid}/permissions"},
|
||||
{"rel": "cdroms", "href": f"/ovirt-engine/api/vms/{vid}/cdroms"},
|
||||
{"rel": "graphicsconsoles", "href": f"/ovirt-engine/api/vms/{vid}/graphicsconsoles"},
|
||||
],
|
||||
}
|
||||
if row["template_id"]:
|
||||
entity["template"] = {
|
||||
"id": str(row["template_id"]),
|
||||
"href": href("templates", row["template_id"]),
|
||||
}
|
||||
if row["host_id"]:
|
||||
entity["host"] = {"id": str(row["host_id"]), "href": href("hosts", row["host_id"])}
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def disk_entity(row: Any) -> dict[str, Any]:
|
||||
did = str(row["id"])
|
||||
entity = {
|
||||
"id": did,
|
||||
"href": href("disks", did),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"provisioned_size": int(row["provisioned_size"]),
|
||||
"actual_size": int(row["actual_size"]),
|
||||
"format": row["format"],
|
||||
"sparse": bool(row["sparse"]),
|
||||
"shareable": bool(row["shareable"]),
|
||||
"wipe_after_delete": bool(row["wipe_after_delete"]),
|
||||
"link": [
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/disks/{did}/permissions"},
|
||||
{"rel": "statistics", "href": f"/ovirt-engine/api/disks/{did}/statistics"},
|
||||
],
|
||||
}
|
||||
if row["storage_domain_id"]:
|
||||
entity["storage_domains"] = {
|
||||
"storage_domain": [
|
||||
{
|
||||
"id": str(row["storage_domain_id"]),
|
||||
"href": href("storagedomains", row["storage_domain_id"]),
|
||||
}
|
||||
]
|
||||
}
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def host_entity(row: Any) -> dict[str, Any]:
|
||||
hid = str(row["id"])
|
||||
return {
|
||||
"id": hid,
|
||||
"href": href("hosts", hid),
|
||||
"name": row["name"],
|
||||
"address": row["address"],
|
||||
"status": row["status"],
|
||||
"type": row["type"],
|
||||
"memory": int(row["memory"]),
|
||||
"cpu": {"topology": {"cores": int(row["cpu_cores"])}},
|
||||
"cluster": {"id": str(row["cluster_id"]), "href": href("clusters", row["cluster_id"])},
|
||||
"link": [
|
||||
{"rel": "nics", "href": f"/ovirt-engine/api/hosts/{hid}/nics"},
|
||||
{"rel": "tags", "href": f"/ovirt-engine/api/hosts/{hid}/tags"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/hosts/{hid}/permissions"},
|
||||
{"rel": "statistics", "href": f"/ovirt-engine/api/hosts/{hid}/statistics"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def datacenter_entity(row: Any) -> dict[str, Any]:
|
||||
did = str(row["id"])
|
||||
return {
|
||||
"id": did,
|
||||
"href": href("datacenters", did),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"local": bool(row["local"]),
|
||||
"status": row["status"],
|
||||
"version": {"major": int(row["version_major"]), "minor": int(row["version_minor"])},
|
||||
"link": [
|
||||
{"rel": "clusters", "href": f"/ovirt-engine/api/datacenters/{did}/clusters"},
|
||||
{"rel": "storagedomains", "href": f"/ovirt-engine/api/datacenters/{did}/storagedomains"},
|
||||
{"rel": "networks", "href": f"/ovirt-engine/api/datacenters/{did}/networks"},
|
||||
{"rel": "quotas", "href": f"/ovirt-engine/api/datacenters/{did}/quotas"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/datacenters/{did}/permissions"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def cluster_entity(row: Any) -> dict[str, Any]:
|
||||
cid = str(row["id"])
|
||||
return {
|
||||
"id": cid,
|
||||
"href": href("clusters", cid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"cpu": {"type": row["cpu_type"]},
|
||||
"version": {"major": int(row["version_major"]), "minor": int(row["version_minor"])},
|
||||
"data_center": {
|
||||
"id": str(row["datacenter_id"]),
|
||||
"href": href("datacenters", row["datacenter_id"]),
|
||||
},
|
||||
"link": [
|
||||
{"rel": "networks", "href": f"/ovirt-engine/api/clusters/{cid}/networks"},
|
||||
{"rel": "affinitygroups", "href": f"/ovirt-engine/api/clusters/{cid}/affinitygroups"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/clusters/{cid}/permissions"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def network_entity(row: Any) -> dict[str, Any]:
|
||||
nid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": nid,
|
||||
"href": href("networks", nid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"stp": bool(row["stp"]),
|
||||
"data_center": {
|
||||
"id": str(row["datacenter_id"]),
|
||||
"href": href("datacenters", row["datacenter_id"]),
|
||||
},
|
||||
"link": [
|
||||
{"rel": "vnicprofiles", "href": f"/ovirt-engine/api/networks/{nid}/vnicprofiles"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/networks/{nid}/permissions"},
|
||||
],
|
||||
}
|
||||
if row["vlan_id"] is not None:
|
||||
entity["vlan"] = {"id": int(row["vlan_id"])}
|
||||
entity.update(_data(row))
|
||||
return entity
|
||||
|
||||
|
||||
def storage_domain_entity(row: Any) -> dict[str, Any]:
|
||||
sid = str(row["id"])
|
||||
return {
|
||||
"id": sid,
|
||||
"href": href("storagedomains", sid),
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"storage": {"type": row["storage_type"]},
|
||||
"status": row["status"],
|
||||
"available": int(row["available"]),
|
||||
"used": int(row["used"]),
|
||||
"committed": int(row["committed"]),
|
||||
"link": [
|
||||
{"rel": "disks", "href": f"/ovirt-engine/api/storagedomains/{sid}/disks"},
|
||||
{"rel": "files", "href": f"/ovirt-engine/api/storagedomains/{sid}/files"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/storagedomains/{sid}/permissions"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def template_entity(row: Any) -> dict[str, Any]:
|
||||
tid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": tid,
|
||||
"href": href("templates", tid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"memory": int(row["memory"]),
|
||||
"cpu": {
|
||||
"topology": {"sockets": int(row["cpu_sockets"]), "cores": int(row["cpu_cores"])}
|
||||
},
|
||||
"link": [
|
||||
{"rel": "diskattachments", "href": f"/ovirt-engine/api/templates/{tid}/diskattachments"},
|
||||
{"rel": "nics", "href": f"/ovirt-engine/api/templates/{tid}/nics"},
|
||||
],
|
||||
}
|
||||
if row["cluster_id"]:
|
||||
entity["cluster"] = {
|
||||
"id": str(row["cluster_id"]),
|
||||
"href": href("clusters", row["cluster_id"]),
|
||||
}
|
||||
entity.update(_data(row))
|
||||
return entity
|
||||
|
||||
|
||||
def user_entity(row: Any) -> dict[str, Any]:
|
||||
uid = str(row["id"])
|
||||
return {
|
||||
"id": uid,
|
||||
"href": href("users", uid),
|
||||
"name": row["name"],
|
||||
"user_name": f"{row['name']}@{row['domain_name']}",
|
||||
"domain": {"id": str(row["domain_id"]), "name": row["domain_name"]},
|
||||
"link": [
|
||||
{"rel": "roles", "href": f"/ovirt-engine/api/users/{uid}/roles"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/users/{uid}/permissions"},
|
||||
{"rel": "tags", "href": f"/ovirt-engine/api/users/{uid}/tags"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def generic_entity(collection: str, element: str, row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
from app.ovirt.errors import OVirtError
|
||||
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
oid = str(row["id"])
|
||||
data = _data(row)
|
||||
entity = {
|
||||
"id": oid,
|
||||
"href": href(collection, oid),
|
||||
"name": row["name"] or data.get("name") or element,
|
||||
"status": row["status"],
|
||||
}
|
||||
entity.update(data)
|
||||
return entity
|
||||
|
||||
|
||||
def job_entity(row: Any) -> dict[str, Any]:
|
||||
jid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": jid,
|
||||
"href": href("jobs", jid),
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
}
|
||||
if row["started"] is not None:
|
||||
entity["started"] = row["started"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
if row["ended"] is not None:
|
||||
entity["ended"] = row["ended"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity and k != "action_status"})
|
||||
return entity
|
||||
|
||||
|
||||
def action_entity(job_row: Any) -> dict[str, Any]:
|
||||
"""Build an action response from a persisted job row (status from job.data)."""
|
||||
|
||||
data = _data(job_row)
|
||||
status = data.get("action_status")
|
||||
if status is None:
|
||||
status = job_row["status"]
|
||||
return {"status": str(status), "job": job_entity(job_row)}
|
||||
|
||||
|
||||
def disk_attachment_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
|
||||
vid = vm_id or str(row["vm_id"])
|
||||
aid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": aid,
|
||||
"href": f"/ovirt-engine/api/vms/{vid}/diskattachments/{aid}",
|
||||
"active": bool(row["active"]),
|
||||
"bootable": bool(row["bootable"]),
|
||||
"interface": row["interface"],
|
||||
"disk": {"id": str(row["disk_id"]), "href": href("disks", row["disk_id"])},
|
||||
"vm": {"id": vid, "href": href("vms", vid)},
|
||||
}
|
||||
if "disk_name" in row.keys() and row["disk_name"] is not None:
|
||||
entity["disk"]["name"] = row["disk_name"]
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def nic_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
|
||||
vid = vm_id or str(row["vm_id"])
|
||||
nid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": nid,
|
||||
"href": f"/ovirt-engine/api/vms/{vid}/nics/{nid}",
|
||||
"name": row["name"],
|
||||
"interface": row["interface"],
|
||||
"linked": bool(row["linked"]),
|
||||
"plugged": bool(row["plugged"]),
|
||||
"mac": {"address": row["mac_address"] or ""},
|
||||
}
|
||||
if row["vnic_profile_id"]:
|
||||
entity["vnic_profile"] = {
|
||||
"id": str(row["vnic_profile_id"]),
|
||||
"href": href("vnicprofiles", row["vnic_profile_id"]),
|
||||
}
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def snapshot_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
|
||||
vid = vm_id or str(row["vm_id"])
|
||||
sid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": sid,
|
||||
"href": f"/ovirt-engine/api/vms/{vid}/snapshots/{sid}",
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"snapshot_type": row["snapshot_type"],
|
||||
"persist_memorystate": bool(row["persist_memorystate"]),
|
||||
}
|
||||
if row["created_at"] is not None:
|
||||
entity["date"] = row["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def tag_entity(row: Any) -> dict[str, Any]:
|
||||
tid = str(row["id"])
|
||||
return {
|
||||
"id": tid,
|
||||
"href": href("tags", tid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Specialized oVirt Engine route modules."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
"""oVirt Engine SSO OAuth2 endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.ovirt.auth import issue_oauth_token
|
||||
from app.ovirt.deps import get_db
|
||||
from app.ovirt.errors import OVirtError
|
||||
from app.ovirt.settings import OPT_DEFAULT_API_SCOPE, option_value
|
||||
|
||||
router = APIRouter(tags=["sso"])
|
||||
|
||||
|
||||
@router.post("/ovirt-engine/sso/oauth/token")
|
||||
async def oauth_token(
|
||||
request: Request,
|
||||
grant_type: str = Form(default=""),
|
||||
username: str = Form(default=""),
|
||||
password: str = Form(default=""),
|
||||
scope: str = Form(default=""),
|
||||
) -> JSONResponse:
|
||||
if grant_type and grant_type != "password":
|
||||
raise OVirtError("BadRequest", f"Unsupported grant_type: {grant_type}", status_code=400)
|
||||
if not username:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = str(body.get("username") or "")
|
||||
password = str(body.get("password") or "")
|
||||
scope = str(body.get("scope") or scope or "")
|
||||
grant_type = str(body.get("grant_type") or grant_type or "password")
|
||||
if not username or not password:
|
||||
raise OVirtError("Unauthorized", "username and password required", status_code=401)
|
||||
db = get_db(request)
|
||||
async with db.pool.acquire() as conn:
|
||||
if not scope:
|
||||
scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
token = await issue_oauth_token(
|
||||
conn, username=username, password=password, scope=scope
|
||||
)
|
||||
return JSONResponse(token)
|
||||
|
||||
|
||||
@router.get("/ovirt-engine/sso/oauth/token-info")
|
||||
async def token_info(request: Request) -> JSONResponse:
|
||||
from app.ovirt.auth import resolve_request_auth
|
||||
|
||||
db = get_db(request)
|
||||
async with db.pool.acquire() as conn:
|
||||
ctx = await resolve_request_auth(conn, dict(request.headers))
|
||||
row = await conn.fetchrow(
|
||||
"SELECT scope, revoked, expires_at FROM ov_tokens WHERE id=$1", ctx.token_id
|
||||
)
|
||||
active = bool(row) and not row["revoked"]
|
||||
return JSONResponse(
|
||||
{
|
||||
"active": active,
|
||||
"user_id": str(ctx.user_id),
|
||||
"user_name": f"{ctx.user_name}@{ctx.domain}",
|
||||
"exp": int(ctx.expires_at.timestamp()),
|
||||
"scope": (row["scope"] if row else ctx.scope),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ovirt-engine/sso/oauth/revoke")
|
||||
async def revoke_token(request: Request) -> JSONResponse:
|
||||
from app.ovirt.auth import extract_auth
|
||||
|
||||
db = get_db(request)
|
||||
kind = extract_auth(dict(request.headers))
|
||||
token = None
|
||||
if kind and kind[0] in {"bearer", "session"}:
|
||||
token = kind[1]
|
||||
else:
|
||||
form = await request.form()
|
||||
token = str(form.get("token") or "")
|
||||
result = "missing"
|
||||
if token:
|
||||
async with db.pool.acquire() as conn:
|
||||
updated = await conn.fetchval(
|
||||
"""UPDATE ov_tokens SET revoked=true WHERE id=$1
|
||||
RETURNING id""",
|
||||
token,
|
||||
)
|
||||
result = "ok" if updated else "not_found"
|
||||
return JSONResponse({"result": result})
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Generic surface-complete CRUD backed by ov_api_objects for undeclared collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asyncpg import Connection
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.ovirt.errors import OVirtError
|
||||
from app.ovirt.repr import generic_entity
|
||||
from app.ovirt.serialize import respond, unwrap_entity
|
||||
|
||||
# Map collection path segment → (element singular, default status)
|
||||
_COLLECTIONS: dict[str, tuple[str, str]] = {
|
||||
"affinitylabels": ("affinity_label", "ok"),
|
||||
"bookmarks": ("bookmark", "ok"),
|
||||
"clusterlevels": ("cluster_level", "ok"),
|
||||
"domains": ("domain", "ok"),
|
||||
"externalhostproviders": ("external_host_provider", "ok"),
|
||||
"groups": ("group", "ok"),
|
||||
"icons": ("icon", "ok"),
|
||||
"instancetypes": ("instance_type", "ok"),
|
||||
"katelloerrata": ("katello_erratum", "ok"),
|
||||
"macpools": ("mac_pool", "ok"),
|
||||
"networkfilters": ("network_filter", "ok"),
|
||||
"openstackimageproviders": ("openstack_image_provider", "ok"),
|
||||
"openstacknetworkproviders": ("openstack_network_provider", "ok"),
|
||||
"openstackvolumeproviders": ("openstack_volume_provider", "ok"),
|
||||
"operatingsystems": ("operating_system", "ok"),
|
||||
"permissions": ("permission", "ok"),
|
||||
"roles": ("role", "ok"),
|
||||
"schedulingpolicies": ("scheduling_policy", "ok"),
|
||||
"schedulingpolicyunits": ("scheduling_policy_unit", "ok"),
|
||||
"tags": ("tag", "ok"),
|
||||
"vmpools": ("vm_pool", "ok"),
|
||||
"imagetransfers": ("image_transfer", "ok"),
|
||||
"options": ("engine_option", "ok"),
|
||||
"networklabels": ("network_label", "ok"),
|
||||
"cpuprofiles": ("cpu_profile", "ok"),
|
||||
"diskprofiles": ("disk_profile", "ok"),
|
||||
"qoss": ("qos", "ok"),
|
||||
"iscsibonds": ("iscsi_bond", "ok"),
|
||||
"glustervolumes": ("gluster_volume", "ok"),
|
||||
"files": ("file", "ok"),
|
||||
"images": ("image", "ok"),
|
||||
"permits": ("permit", "ok"),
|
||||
"filters": ("filter", "ok"),
|
||||
"weights": ("weight", "ok"),
|
||||
"balances": ("balance", "ok"),
|
||||
"enabledfeatures": ("cluster_enabled_feature", "ok"),
|
||||
"cdroms": ("cdrom", "ok"),
|
||||
"graphicsconsoles": ("graphics_console", "ok"),
|
||||
"reporteddevices": ("reported_device", "ok"),
|
||||
"sessions": ("session", "ok"),
|
||||
"applications": ("application", "ok"),
|
||||
"watchdogs": ("watchdog", "ok"),
|
||||
"hostdevices": ("host_device", "ok"),
|
||||
"numanodes": ("numa_node", "ok"),
|
||||
"mediateddevices": ("vm_mediated_device", "ok"),
|
||||
"statistics": ("statistic", "ok"),
|
||||
"hooks": ("hook", "ok"),
|
||||
"devices": ("host_device", "ok"),
|
||||
"sshpublickeys": ("ssh_public_key", "ok"),
|
||||
"networkfilterparameters": ("network_filter_parameter", "ok"),
|
||||
}
|
||||
|
||||
|
||||
def _meta(collection: str) -> tuple[str, str]:
|
||||
return _COLLECTIONS.get(collection, (collection.rstrip("s") or "object", "ok"))
|
||||
|
||||
|
||||
async def handle_generic(
|
||||
request: Request,
|
||||
conn: Connection,
|
||||
method: str,
|
||||
parts: list[str],
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
|
||||
|
||||
collection = parts[0]
|
||||
element, _catalog_status = _meta(collection)
|
||||
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
|
||||
if len(parts) == 1:
|
||||
if method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_api_objects WHERE collection=$1 ORDER BY name", collection
|
||||
)
|
||||
items = [generic_entity(collection, element, r) for r in rows]
|
||||
return respond(request, element=element, collection=collection, data=items)
|
||||
if method == "POST":
|
||||
body = unwrap_entity(payload, element)
|
||||
oid = uuid4()
|
||||
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,$4,$5::jsonb)""",
|
||||
oid,
|
||||
collection,
|
||||
name,
|
||||
str(body.get("status") or default_status),
|
||||
json.dumps(body),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
|
||||
return respond(
|
||||
request, element=element, data=generic_entity(collection, element, row), status_code=201
|
||||
)
|
||||
if len(parts) == 2:
|
||||
oid = parts[1]
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
|
||||
)
|
||||
if method == "GET":
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
return respond(request, element=element, data=generic_entity(collection, element, row))
|
||||
if method == "PUT":
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
body = unwrap_entity(payload, element)
|
||||
data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {})
|
||||
data.update(body)
|
||||
await conn.execute(
|
||||
"""UPDATE ov_api_objects SET name=COALESCE($2,name), data=$3::jsonb, updated_at=now()
|
||||
WHERE id=$1::uuid""",
|
||||
oid,
|
||||
body.get("name"),
|
||||
json.dumps(data),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
|
||||
return respond(request, element=element, data=generic_entity(collection, element, row))
|
||||
if method == "DELETE":
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
|
||||
)
|
||||
return Response(status_code=200)
|
||||
if len(parts) == 3 and method == "POST":
|
||||
from app.ovirt.jobs import respond_action
|
||||
|
||||
return await respond_action(
|
||||
request, conn, description=f"{collection} {parts[2]}"
|
||||
)
|
||||
if len(parts) >= 3:
|
||||
return await handle_subcollection(
|
||||
request, conn, method, collection, parts[1], parts[2], parts[3:], payload
|
||||
)
|
||||
raise OVirtError("NotFound", f"No handler for /{'/'.join(parts)}", status_code=404)
|
||||
|
||||
|
||||
_PARENT_OBJECT_TYPE: dict[str, str] = {
|
||||
"vms": "vm",
|
||||
"hosts": "host",
|
||||
"disks": "disk",
|
||||
"datacenters": "data_center",
|
||||
"clusters": "cluster",
|
||||
"networks": "network",
|
||||
"storagedomains": "storage_domain",
|
||||
"templates": "template",
|
||||
"users": "user",
|
||||
"groups": "group",
|
||||
"vnicprofiles": "vnic_profile",
|
||||
"vmpools": "vm_pool",
|
||||
}
|
||||
|
||||
|
||||
async def handle_subcollection(
|
||||
request: Request,
|
||||
conn: Connection,
|
||||
method: str,
|
||||
parent_collection: str,
|
||||
parent_id: str,
|
||||
sub: str,
|
||||
rest: list[str],
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
|
||||
|
||||
element, _catalog_status = _meta(sub)
|
||||
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
|
||||
collection_key = sub
|
||||
if not rest and method == "GET" and sub == "permissions":
|
||||
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
|
||||
rows = await conn.fetch(
|
||||
"""SELECT p.*, r.name AS role_name, u.name AS user_name
|
||||
FROM ov_permissions p
|
||||
JOIN ov_roles r ON r.id = p.role_id
|
||||
LEFT JOIN ov_users u ON u.id = p.user_id
|
||||
WHERE p.object_type=$1 AND p.object_id=$2::uuid
|
||||
ORDER BY r.name""",
|
||||
object_type,
|
||||
parent_id,
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/{parent_collection}/{parent_id}/permissions/{r['id']}",
|
||||
"role": {"id": str(r["role_id"]), "name": r["role_name"]},
|
||||
"object": {"type": r["object_type"], "id": str(parent_id)},
|
||||
**(
|
||||
{"user": {"id": str(r["user_id"]), "name": r["user_name"]}}
|
||||
if r["user_id"] is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="permission", collection="permissions", data=items)
|
||||
if not rest and method == "GET" and sub == "tags":
|
||||
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
|
||||
rows = await conn.fetch(
|
||||
"""SELECT t.*
|
||||
FROM ov_tag_assignments a
|
||||
JOIN ov_tags t ON t.id = a.tag_id
|
||||
WHERE a.object_type=$1 AND a.object_id=$2::uuid
|
||||
ORDER BY t.name""",
|
||||
object_type,
|
||||
parent_id,
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/tags/{r['id']}",
|
||||
"name": r["name"],
|
||||
"description": r["description"] or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="tag", collection="tags", data=items)
|
||||
if not rest:
|
||||
if method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"""SELECT * FROM ov_api_objects
|
||||
WHERE collection=$1 AND parent_collection=$2 AND parent_id=$3::uuid
|
||||
ORDER BY name""",
|
||||
collection_key,
|
||||
parent_collection,
|
||||
parent_id,
|
||||
)
|
||||
items = [
|
||||
generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, r) for r in rows
|
||||
]
|
||||
# Fix hrefs
|
||||
for item, row in zip(items, rows, strict=False):
|
||||
item["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{row['id']}"
|
||||
return respond(request, element=element, collection=sub, data=items)
|
||||
if method == "POST":
|
||||
body = unwrap_entity(payload, element)
|
||||
oid = uuid4()
|
||||
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, parent_collection, parent_id, data)
|
||||
VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)""",
|
||||
oid,
|
||||
collection_key,
|
||||
name,
|
||||
str(body.get("status") or default_status),
|
||||
parent_collection,
|
||||
parent_id,
|
||||
json.dumps(body),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
|
||||
data = generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, row)
|
||||
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
|
||||
return respond(request, element=element, data=data, status_code=201)
|
||||
if len(rest) == 1:
|
||||
oid = rest[0]
|
||||
if method == "GET":
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2
|
||||
AND parent_id=$3::uuid""",
|
||||
oid,
|
||||
collection_key,
|
||||
parent_id,
|
||||
)
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
data = generic_entity(sub, element, row)
|
||||
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
|
||||
return respond(request, element=element, data=data)
|
||||
if method == "DELETE":
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND parent_id=$2::uuid",
|
||||
oid,
|
||||
parent_id,
|
||||
)
|
||||
return Response(status_code=200)
|
||||
if method == "PUT":
|
||||
body = unwrap_entity(payload, element)
|
||||
await conn.execute(
|
||||
"UPDATE ov_api_objects SET data=$2::jsonb, updated_at=now() WHERE id=$1::uuid",
|
||||
oid,
|
||||
json.dumps(body),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
|
||||
return respond(request, element=element, data=generic_entity(sub, element, row))
|
||||
if len(rest) == 2 and method == "POST":
|
||||
from app.ovirt.jobs import respond_action
|
||||
|
||||
return await respond_action(
|
||||
request, conn, description=f"{parent_collection}/{sub} {rest[1]}"
|
||||
)
|
||||
raise OVirtError(
|
||||
"NotFound",
|
||||
f"No handler for /{parent_collection}/{parent_id}/{sub}/{'/'.join(rest)}",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
def remount_schema_services(app: Any, series: str) -> int:
|
||||
"""Hot-swap contract pack: reload ops and re-register OpenAPI routes."""
|
||||
|
||||
from app.ovirt.contract_loader import ensure_loaded
|
||||
from app.ovirt.registry import clear_ovirt_contract_routes, register_ovirt_contract_routes
|
||||
from app.ovirt.routes import engine
|
||||
|
||||
rt = ensure_loaded(series)
|
||||
summary = rt.reload(series)
|
||||
clear_ovirt_contract_routes(app)
|
||||
registered = 0
|
||||
if rt.pack is not None:
|
||||
registered = register_ovirt_contract_routes(app, rt.pack)
|
||||
# Re-attach catch-all after specific contract routes (match order matters).
|
||||
app.include_router(engine.router)
|
||||
app.state.ovirt_series = series
|
||||
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
|
||||
app.state.runtime_version = f"ovirt-{series}"
|
||||
return int(app.state.ovirt_schema_ops)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Minimal and demo seed profiles for the oVirt Engine simulator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
MINIMAL_PROFILE = "minimal"
|
||||
DEMO_PROFILE = "demo"
|
||||
|
||||
|
||||
async def clear_ovirt_state(conn: Connection) -> None:
|
||||
tables = [
|
||||
"ov_job_steps",
|
||||
"ov_jobs",
|
||||
"ov_events",
|
||||
"ov_tag_assignments",
|
||||
"ov_tags",
|
||||
"ov_snapshots",
|
||||
"ov_nics",
|
||||
"ov_disk_attachments",
|
||||
"ov_disks",
|
||||
"ov_vms",
|
||||
"ov_templates",
|
||||
"ov_affinity_groups",
|
||||
"ov_quotas",
|
||||
"ov_vnic_profiles",
|
||||
"ov_networks",
|
||||
"ov_storage_domain_attachments",
|
||||
"ov_storage_domains",
|
||||
"ov_storage_connections",
|
||||
"ov_hosts",
|
||||
"ov_clusters",
|
||||
"ov_datacenters",
|
||||
"ov_bookmarks",
|
||||
"ov_permissions",
|
||||
"ov_tokens",
|
||||
"ov_users",
|
||||
"ov_groups",
|
||||
"ov_roles",
|
||||
"ov_domains",
|
||||
"ov_api_objects",
|
||||
"ov_demo_meta",
|
||||
]
|
||||
for table in tables:
|
||||
await conn.execute(f"TRUNCATE TABLE {table} CASCADE")
|
||||
|
||||
|
||||
async def seed_ovirt(conn: Connection) -> dict[str, Any]:
|
||||
"""Minimal lab: 1 DC, 1 cluster, 1 host, Blank template, admin user."""
|
||||
|
||||
await clear_ovirt_state(conn)
|
||||
domain_id = stable_id("domain", "internal")
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_domains(id, name) VALUES($1, 'internal')", domain_id
|
||||
)
|
||||
roles = [
|
||||
(stable_id("role", "SuperUser"), "SuperUser", True),
|
||||
(stable_id("role", "UserRole"), "UserRole", False),
|
||||
(stable_id("role", "ClusterAdmin"), "ClusterAdmin", True),
|
||||
]
|
||||
for rid, name, admin in roles:
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_roles(id, name, administrative) VALUES($1,$2,$3)",
|
||||
rid,
|
||||
name,
|
||||
admin,
|
||||
)
|
||||
admin_id = stable_id("user", "admin")
|
||||
pwd = hash_secret("secret", salt=b"ovirt-sim-v1-salt!")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
|
||||
VALUES($1,$2,'admin',$3,true,'admin@internal')""",
|
||||
admin_id,
|
||||
domain_id,
|
||||
pwd,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
|
||||
VALUES($1,$2,$3,'system')""",
|
||||
stable_id("perm", "admin-super"),
|
||||
roles[0][0],
|
||||
admin_id,
|
||||
)
|
||||
# Extra lab users
|
||||
for uname in ("ops", "developer", "demo"):
|
||||
uid = stable_id("user", uname)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
|
||||
VALUES($1,$2,$3,$4,true,$5)""",
|
||||
uid,
|
||||
domain_id,
|
||||
uname,
|
||||
pwd,
|
||||
f"{uname}@internal",
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
|
||||
VALUES($1,$2,$3,'system')""",
|
||||
stable_id("perm", uname),
|
||||
roles[1][0],
|
||||
uid,
|
||||
)
|
||||
|
||||
dc_id = stable_id("dc", "Default")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_datacenters(id, name, description, status, version_major, version_minor)
|
||||
VALUES($1,'Default','Default datacenter','up',4,5)""",
|
||||
dc_id,
|
||||
)
|
||||
cluster_id = stable_id("cluster", "Default")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_clusters(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'Default','Default cluster')""",
|
||||
cluster_id,
|
||||
dc_id,
|
||||
)
|
||||
host_id = stable_id("host", "host01")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_hosts(id, cluster_id, name, address, status, memory, cpu_cores)
|
||||
VALUES($1,$2,'host01','192.168.1.10','up',$3,16)""",
|
||||
host_id,
|
||||
cluster_id,
|
||||
128 * 1024**3,
|
||||
)
|
||||
net_id = stable_id("net", "ovirtmgmt")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_networks(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'ovirtmgmt','Management network')""",
|
||||
net_id,
|
||||
dc_id,
|
||||
)
|
||||
profile_id = stable_id("vnic", "ovirtmgmt")
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_vnic_profiles(id, network_id, name) VALUES($1,$2,'ovirtmgmt')",
|
||||
profile_id,
|
||||
net_id,
|
||||
)
|
||||
sd_id = stable_id("sd", "data1")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domains(id, name, type, storage_type, status, available, used)
|
||||
VALUES($1,'data1','data','nfs','active',$2,$3)""",
|
||||
sd_id,
|
||||
2 * 1024**4,
|
||||
100 * 1024**3,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domain_attachments(id, storage_domain_id, datacenter_id, status)
|
||||
VALUES($1,$2,$3,'active')""",
|
||||
stable_id("sda", "data1"),
|
||||
sd_id,
|
||||
dc_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_connections(id, type, address, path)
|
||||
VALUES($1,'nfs','nfs.lab.local','/export/data1')""",
|
||||
stable_id("sc", "data1"),
|
||||
)
|
||||
blank_id = stable_id("template", "Blank")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory)
|
||||
VALUES($1,$2,'Blank','Blank template','ok',$3)""",
|
||||
blank_id,
|
||||
cluster_id,
|
||||
1024**3,
|
||||
)
|
||||
# One sample VM
|
||||
vm_id = stable_id("vm", "lab-vm-01")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
|
||||
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type)
|
||||
VALUES($1,$2,$3,'lab-vm-01','Sample VM','down',$4,1,2,1,'rhel_8x64','server')""",
|
||||
vm_id,
|
||||
cluster_id,
|
||||
blank_id,
|
||||
2 * 1024**3,
|
||||
)
|
||||
disk_id = stable_id("disk", "lab-vm-01")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_disks(id, name, provisioned_size, actual_size, storage_domain_id)
|
||||
VALUES($1,'lab-vm-01_Disk1',$2,$2,$3)""",
|
||||
disk_id,
|
||||
20 * 1024**3,
|
||||
sd_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, bootable)
|
||||
VALUES($1,$2,$3,true)""",
|
||||
stable_id("da", "lab-vm-01"),
|
||||
vm_id,
|
||||
disk_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_nics(id, vm_id, name, mac_address, vnic_profile_id)
|
||||
VALUES($1,$2,'nic1','00:1a:4a:16:01:01',$3)""",
|
||||
stable_id("nic", "lab-vm-01"),
|
||||
vm_id,
|
||||
profile_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'AllVMs','Vms:')",
|
||||
stable_id("bm", "AllVMs"),
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_events(code, severity, description, user_id, vm_id)
|
||||
VALUES(1,'normal','Engine started',$1,$2)""",
|
||||
admin_id,
|
||||
vm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,'engine-admins')",
|
||||
stable_id("group", "engine-admins"),
|
||||
domain_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_tags(id, name, description) VALUES($1,'lab','Lab tag')",
|
||||
stable_id("tag", "lab"),
|
||||
)
|
||||
job_id = stable_id("job", "seed-1")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, owner_id)
|
||||
VALUES($1,'Seed inventory job','finished',$2)""",
|
||||
job_id,
|
||||
admin_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number)
|
||||
VALUES($1,$2,'Finish seed','finished','executing',1)""",
|
||||
stable_id("step", "seed-1"),
|
||||
job_id,
|
||||
)
|
||||
# Surface-complete sample objects (generic collections)
|
||||
for collection, name in (
|
||||
("instancetypes", "Large"),
|
||||
("macpools", "Default"),
|
||||
("schedulingpolicies", "evenly_distributed"),
|
||||
("schedulingpolicyunits", "EvenlyDistributed"),
|
||||
("clusterlevels", "4.5"),
|
||||
("icons", "default"),
|
||||
("operatingsystems", "rhel_8x64"),
|
||||
("networkfilters", "vdsm-no-mac-spoofing"),
|
||||
("vmpools", "pool-demo"),
|
||||
("affinitylabels", "label-a"),
|
||||
("katelloerrata", "RHSA-2024:0001"),
|
||||
("externalhostproviders", "foreman-lab"),
|
||||
("openstacknetworkproviders", "ovn-provider"),
|
||||
("openstackimageproviders", "glance-lab"),
|
||||
("openstackvolumeproviders", "cinder-lab"),
|
||||
("imagetransfers", "transfer-1"),
|
||||
):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,'ok',$4::jsonb)""",
|
||||
stable_id("obj", collection, name),
|
||||
collection,
|
||||
name,
|
||||
json.dumps({"name": name, "description": f"seed {collection}"}),
|
||||
)
|
||||
|
||||
from app.ovirt.settings import seed_engine_options
|
||||
|
||||
await seed_engine_options(conn)
|
||||
|
||||
# Nested entity links + resource permissions (avoid empty subcollection GETs)
|
||||
from app.ovirt.seed_nested import seed_nested_for_inventory
|
||||
|
||||
tag_id = stable_id("tag", "lab")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
|
||||
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
|
||||
stable_id("ta", "lab", "lab-vm-01"),
|
||||
tag_id,
|
||||
vm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_snapshots(id, vm_id, description, status)
|
||||
VALUES($1,$2,'seed-snapshot','ok')""",
|
||||
stable_id("snap", "lab-vm-01", "1"),
|
||||
vm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'Default','Default quota')""",
|
||||
stable_id("quota", "Default"),
|
||||
dc_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_affinity_groups(id, cluster_id, name, enforcing, positive)
|
||||
VALUES($1,$2,'web-affinity',true,true)""",
|
||||
stable_id("ag", "Default"),
|
||||
cluster_id,
|
||||
)
|
||||
user_ids = [
|
||||
r["id"]
|
||||
for r in await conn.fetch("SELECT id FROM ov_users ORDER BY name")
|
||||
]
|
||||
group_ids = [r["id"] for r in await conn.fetch("SELECT id FROM ov_groups ORDER BY name")]
|
||||
await seed_nested_for_inventory(
|
||||
conn,
|
||||
admin_user_id=admin_id,
|
||||
role_user_id=roles[1][0],
|
||||
datacenter_ids=[dc_id],
|
||||
cluster_ids=[cluster_id],
|
||||
host_ids=[host_id],
|
||||
network_ids=[net_id],
|
||||
storage_domain_ids=[sd_id],
|
||||
template_ids=[blank_id],
|
||||
vm_ids=[vm_id],
|
||||
disk_ids=[disk_id],
|
||||
tag_ids=[tag_id],
|
||||
user_ids=user_ids,
|
||||
group_ids=group_ids,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", MINIMAL_PROFILE
|
||||
)
|
||||
return {
|
||||
"profile": MINIMAL_PROFILE,
|
||||
"vms": 1,
|
||||
"hosts": 1,
|
||||
"datacenters": 1,
|
||||
"users": 4,
|
||||
}
|
||||
|
||||
|
||||
async def ovirt_demo_summary(conn: Connection) -> dict[str, Any]:
|
||||
profile = await conn.fetchval("SELECT value FROM ov_demo_meta WHERE key='profile'")
|
||||
return {
|
||||
"profile": profile or MINIMAL_PROFILE,
|
||||
"loaded": profile == DEMO_PROFILE,
|
||||
"vms": await conn.fetchval("SELECT count(*) FROM ov_vms") or 0,
|
||||
"hosts": await conn.fetchval("SELECT count(*) FROM ov_hosts") or 0,
|
||||
"datacenters": await conn.fetchval("SELECT count(*) FROM ov_datacenters") or 0,
|
||||
"clusters": await conn.fetchval("SELECT count(*) FROM ov_clusters") or 0,
|
||||
"disks": await conn.fetchval("SELECT count(*) FROM ov_disks") or 0,
|
||||
"networks": await conn.fetchval("SELECT count(*) FROM ov_networks") or 0,
|
||||
"storage_domains": await conn.fetchval("SELECT count(*) FROM ov_storage_domains") or 0,
|
||||
"templates": await conn.fetchval("SELECT count(*) FROM ov_templates") or 0,
|
||||
"users": await conn.fetchval("SELECT count(*) FROM ov_users") or 0,
|
||||
"events": await conn.fetchval("SELECT count(*) FROM ov_events") or 0,
|
||||
"jobs": await conn.fetchval("SELECT count(*) FROM ov_jobs") or 0,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""CLI: python -m app.ovirt.seed_cli [--profile minimal|demo]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.ovirt.demo_datacenter import seed_ovirt_demo
|
||||
from app.ovirt.seed import seed_ovirt
|
||||
|
||||
|
||||
async def _run(profile: str) -> dict:
|
||||
dsn = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://ovirt:ovirt@localhost:5432/ovirt_simulator",
|
||||
)
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
if profile == "demo":
|
||||
result = await seed_ovirt_demo(conn)
|
||||
else:
|
||||
result = await seed_ovirt(conn)
|
||||
return result
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Seed oVirt Engine simulator")
|
||||
parser.add_argument("--profile", default=os.environ.get("SEED_PROFILE", "minimal"))
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(_run(args.profile))
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Seed nested Engine subcollections so entity `link` hrefs are non-empty.
|
||||
|
||||
Rows go into `ov_api_objects` (with parent_collection/parent_id) and into
|
||||
`ov_permissions` / `ov_tag_assignments` where specialized tables exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
|
||||
|
||||
def _obj_row(
|
||||
*,
|
||||
parent_collection: str,
|
||||
parent_id: UUID,
|
||||
collection: str,
|
||||
name: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
status: str = "ok",
|
||||
) -> tuple[Any, ...]:
|
||||
payload = {"name": name, **(data or {})}
|
||||
return (
|
||||
stable_id("nested", parent_collection, str(parent_id), collection, name),
|
||||
collection,
|
||||
name,
|
||||
status,
|
||||
parent_collection,
|
||||
parent_id,
|
||||
json.dumps(payload),
|
||||
)
|
||||
|
||||
|
||||
async def _insert_objs(conn: Connection, rows: list[tuple[Any, ...]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_api_objects(
|
||||
id, collection, name, status, parent_collection, parent_id, data
|
||||
) VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
rows,
|
||||
)
|
||||
|
||||
|
||||
async def seed_nested_for_inventory(
|
||||
conn: Connection,
|
||||
*,
|
||||
admin_user_id: UUID,
|
||||
role_user_id: UUID,
|
||||
datacenter_ids: list[UUID],
|
||||
cluster_ids: list[UUID],
|
||||
host_ids: list[UUID],
|
||||
network_ids: list[UUID],
|
||||
storage_domain_ids: list[UUID],
|
||||
template_ids: list[UUID],
|
||||
vm_ids: list[UUID],
|
||||
disk_ids: list[UUID],
|
||||
tag_ids: list[UUID] | None = None,
|
||||
user_ids: list[UUID] | None = None,
|
||||
group_ids: list[UUID] | None = None,
|
||||
) -> None:
|
||||
"""Populate nested surface for the given inventory sample."""
|
||||
|
||||
obj_rows: list[tuple[Any, ...]] = []
|
||||
perm_rows: list[tuple[Any, ...]] = []
|
||||
tag_rows: list[tuple[Any, ...]] = []
|
||||
if user_ids is None:
|
||||
user_ids = []
|
||||
if group_ids is None:
|
||||
group_ids = []
|
||||
|
||||
def perm(object_type: str, object_id: UUID, key: str) -> None:
|
||||
perm_rows.append(
|
||||
(
|
||||
stable_id("perm", object_type, key),
|
||||
role_user_id,
|
||||
admin_user_id,
|
||||
object_type,
|
||||
object_id,
|
||||
)
|
||||
)
|
||||
|
||||
for dc_id in datacenter_ids:
|
||||
perm("data_center", dc_id, f"dc-{dc_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="datacenters",
|
||||
parent_id=dc_id,
|
||||
collection="qoss",
|
||||
name="default-qos",
|
||||
data={"type": "storage"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="datacenters",
|
||||
parent_id=dc_id,
|
||||
collection="iscsibonds",
|
||||
name="iscsi-bond-1",
|
||||
data={"description": "iSCSI bond"},
|
||||
)
|
||||
)
|
||||
|
||||
for cluster_id in cluster_ids:
|
||||
perm("cluster", cluster_id, f"cluster-{cluster_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="clusters",
|
||||
parent_id=cluster_id,
|
||||
collection="cpuprofiles",
|
||||
name="Default",
|
||||
data={"description": "Default CPU profile"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="clusters",
|
||||
parent_id=cluster_id,
|
||||
collection="enabledfeatures",
|
||||
name="gluster",
|
||||
data={"description": "Gluster feature"},
|
||||
)
|
||||
)
|
||||
|
||||
for host_id in host_ids:
|
||||
perm("host", host_id, f"host-{host_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="nics",
|
||||
name="eth0",
|
||||
data={"mac": {"address": "00:1a:4a:00:00:01"}, "boot_protocol": "dhcp"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="nics",
|
||||
name="eth1",
|
||||
data={"mac": {"address": "00:1a:4a:00:00:02"}, "boot_protocol": "none"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="statistics",
|
||||
name="memory.used",
|
||||
data={"unit": "bytes", "values": {"value": [{"datum": 8 * 1024**3}]}},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="statistics",
|
||||
name="cpu.current.user",
|
||||
data={"unit": "percent", "values": {"value": [{"datum": 12.5}]}},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="devices",
|
||||
name="pci-0000:00:1f.2",
|
||||
data={"capability": "storage", "vendor": "lab"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="hooks",
|
||||
name="before_vm_start",
|
||||
data={"event_name": "before_vm_start"},
|
||||
)
|
||||
)
|
||||
if tag_ids:
|
||||
tag_rows.append(
|
||||
(
|
||||
stable_id("ta", "host", str(host_id), str(tag_ids[0])),
|
||||
tag_ids[0],
|
||||
"host",
|
||||
host_id,
|
||||
)
|
||||
)
|
||||
|
||||
for net_id in network_ids:
|
||||
perm("network", net_id, f"net-{net_id}")
|
||||
|
||||
for sd_id in storage_domain_ids:
|
||||
perm("storage_domain", sd_id, f"sd-{sd_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="files",
|
||||
name="rhel-9.iso",
|
||||
data={"type": "iso", "size": 8 * 1024**3},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="files",
|
||||
name="virtio-win.iso",
|
||||
data={"type": "iso", "size": 512 * 1024**2},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="images",
|
||||
name="base-image",
|
||||
data={"description": "Base disk image"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="diskprofiles",
|
||||
name="default",
|
||||
data={"description": "Default disk profile"},
|
||||
)
|
||||
)
|
||||
|
||||
for tpl_id in template_ids:
|
||||
perm("template", tpl_id, f"tpl-{tpl_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="nics",
|
||||
name="nic1",
|
||||
data={"interface": "virtio"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="diskattachments",
|
||||
name="disk1",
|
||||
data={"bootable": True, "interface": "virtio_scsi"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="cdroms",
|
||||
name="ide0",
|
||||
data={"file": {"id": "rhel-9.iso"}},
|
||||
)
|
||||
)
|
||||
|
||||
for disk_id in disk_ids:
|
||||
perm("disk", disk_id, f"disk-{disk_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="disks",
|
||||
parent_id=disk_id,
|
||||
collection="statistics",
|
||||
name="data.current.read",
|
||||
data={"unit": "bytespers", "values": {"value": [{"datum": 1024}]}},
|
||||
)
|
||||
)
|
||||
|
||||
if tag_ids:
|
||||
for user_id in user_ids:
|
||||
tag_rows.append(
|
||||
(
|
||||
stable_id("ta", "user", str(user_id), str(tag_ids[0])),
|
||||
tag_ids[0],
|
||||
"user",
|
||||
user_id,
|
||||
)
|
||||
)
|
||||
for group_id in group_ids:
|
||||
perm("group", group_id, f"group-{group_id}")
|
||||
tag_rows.append(
|
||||
(
|
||||
stable_id("ta", "group", str(group_id), str(tag_ids[0])),
|
||||
tag_ids[0],
|
||||
"group",
|
||||
group_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
for group_id in group_ids:
|
||||
perm("group", group_id, f"group-{group_id}")
|
||||
|
||||
for vm_id in vm_ids:
|
||||
perm("vm", vm_id, f"vm-{vm_id}")
|
||||
obj_rows.extend(
|
||||
[
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="cdroms",
|
||||
name="ide0",
|
||||
data={"file": {"id": "rhel-9.iso"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="graphicsconsoles",
|
||||
name="vnc",
|
||||
data={"protocol": "vnc", "port": 5900, "address": "127.0.0.1"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="graphicsconsoles",
|
||||
name="spice",
|
||||
data={"protocol": "spice", "port": 5901},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="reporteddevices",
|
||||
name="eth0",
|
||||
data={"type": "network", "mac": {"address": "00:1a:4a:01:00:01"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="sessions",
|
||||
name="console-1",
|
||||
data={"user": {"name": "admin@internal"}, "ip": {"address": "10.0.0.1"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="applications",
|
||||
name="qemu-guest-agent",
|
||||
data={"version": "8.2.0"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="watchdogs",
|
||||
name="i6300esb",
|
||||
data={"model": "i6300esb", "action": "reset"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="statistics",
|
||||
name="memory.installed",
|
||||
data={"unit": "bytes", "values": {"value": [{"datum": 2 * 1024**3}]}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="numanodes",
|
||||
name="0",
|
||||
data={"index": 0, "memory": 1024**3},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="hostdevices",
|
||||
name="pci_0000_00_02_0",
|
||||
data={"capability": "pci"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Scheduling policy children + role permits
|
||||
for sp_name in ("evenly_distributed", "power_saving", "vm_evenly_distributed"):
|
||||
sp_id = await conn.fetchval(
|
||||
"SELECT id FROM ov_api_objects WHERE collection='schedulingpolicies' AND name=$1",
|
||||
sp_name,
|
||||
)
|
||||
if sp_id is None:
|
||||
continue
|
||||
for sub, child in (
|
||||
("filters", "Memory"),
|
||||
("weights", "EvenlyDistributed"),
|
||||
("balances", "EvenlyDistributed"),
|
||||
):
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="schedulingpolicies",
|
||||
parent_id=sp_id,
|
||||
collection=sub,
|
||||
name=child,
|
||||
data={"factor": 1},
|
||||
)
|
||||
)
|
||||
for role_name in ("SuperUser", "UserRole", "ClusterAdmin"):
|
||||
role_id = await conn.fetchval("SELECT id FROM ov_roles WHERE name=$1", role_name)
|
||||
if role_id is None:
|
||||
continue
|
||||
for permit in ("create_vm", "login", "manipulate_vm"):
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="roles",
|
||||
parent_id=role_id,
|
||||
collection="permits",
|
||||
name=permit,
|
||||
data={"administrative": role_name != "UserRole"},
|
||||
)
|
||||
)
|
||||
|
||||
if perm_rows:
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type, object_id)
|
||||
VALUES($1,$2,$3,$4,$5::uuid)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
perm_rows,
|
||||
)
|
||||
await _insert_objs(conn, obj_rows)
|
||||
if tag_rows:
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
|
||||
VALUES($1,$2,$3,$4::uuid) ON CONFLICT DO NOTHING""",
|
||||
tag_rows,
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""XML and JSON representation helpers for oVirt Engine API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
_XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
|
||||
|
||||
def wants_xml(request: Request) -> bool:
|
||||
accept = (request.headers.get("accept") or "").lower()
|
||||
if "application/json" in accept or "+json" in accept:
|
||||
return False
|
||||
if "application/xml" in accept or "text/xml" in accept or "+xml" in accept:
|
||||
return True
|
||||
# Engine historically defaults to XML
|
||||
return True
|
||||
|
||||
|
||||
def parse_body(raw: bytes, content_type: str | None) -> dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
ct = (content_type or "").lower()
|
||||
text = raw.decode("utf-8", errors="replace").strip()
|
||||
if not text:
|
||||
return {}
|
||||
if "json" in ct or text.startswith("{") or text.startswith("["):
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else {"value": data}
|
||||
# Minimal XML → dict (element children)
|
||||
try:
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
root = fromstring(text)
|
||||
return {root.tag: _xml_node(root)}
|
||||
except Exception:
|
||||
return {"raw": text}
|
||||
|
||||
|
||||
def _xml_node(el: Element) -> Any:
|
||||
children = list(el)
|
||||
data: dict[str, Any] = {}
|
||||
if el.attrib:
|
||||
data.update({f"@{k}": v for k, v in el.attrib.items()})
|
||||
if not children:
|
||||
text = (el.text or "").strip()
|
||||
if data:
|
||||
if text:
|
||||
data["#text"] = text
|
||||
return data
|
||||
return text
|
||||
for child in children:
|
||||
value = _xml_node(child)
|
||||
if child.tag in data:
|
||||
existing = data[child.tag]
|
||||
if not isinstance(existing, list):
|
||||
data[child.tag] = [existing]
|
||||
data[child.tag].append(value)
|
||||
else:
|
||||
data[child.tag] = value
|
||||
return data
|
||||
|
||||
|
||||
def unwrap_entity(payload: dict[str, Any], element: str) -> dict[str, Any]:
|
||||
if element in payload and isinstance(payload[element], dict):
|
||||
return dict(payload[element])
|
||||
# XML parse nests under root tag
|
||||
if len(payload) == 1:
|
||||
only = next(iter(payload.values()))
|
||||
if isinstance(only, dict):
|
||||
return dict(only)
|
||||
return dict(payload)
|
||||
|
||||
|
||||
def _to_xml_value(parent: Element, key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
attrs = {k[1:]: str(v) for k, v in value.items() if k.startswith("@")}
|
||||
body = {k: v for k, v in value.items() if not k.startswith("@")}
|
||||
node = SubElement(parent, key, attrs)
|
||||
if "#text" in body and len(body) == 1:
|
||||
node.text = str(body["#text"])
|
||||
return
|
||||
for ck, cv in body.items():
|
||||
if ck == "#text":
|
||||
node.text = str(cv)
|
||||
else:
|
||||
_to_xml_value(node, ck, cv)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
_to_xml_value(parent, key, item)
|
||||
return
|
||||
if isinstance(value, bool):
|
||||
SubElement(parent, key).text = "true" if value else "false"
|
||||
return
|
||||
if isinstance(value, datetime):
|
||||
SubElement(parent, key).text = value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
return
|
||||
SubElement(parent, key).text = str(value)
|
||||
|
||||
|
||||
def entity_to_xml(element: str, data: dict[str, Any]) -> str:
|
||||
attrs = {}
|
||||
if "id" in data:
|
||||
attrs["id"] = str(data["id"])
|
||||
if "href" in data:
|
||||
attrs["href"] = str(data["href"])
|
||||
root = Element(element, attrs)
|
||||
for key, value in data.items():
|
||||
if key in {"id", "href"}:
|
||||
continue
|
||||
_to_xml_value(root, key, value)
|
||||
return _XML_DECL + tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def collection_to_xml(collection: str, element: str, items: list[dict[str, Any]]) -> str:
|
||||
root = Element(collection)
|
||||
for item in items:
|
||||
child_xml = entity_to_xml(element, item)
|
||||
# strip decl and wrap
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
root.append(fromstring(re.sub(r"^<\?xml[^?]*\?>", "", child_xml)))
|
||||
return _XML_DECL + tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def entity_to_json(element: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {element: data}
|
||||
|
||||
|
||||
def collection_to_json(element: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
# Engine JSON uses singular key with array
|
||||
return {element: items}
|
||||
|
||||
|
||||
def respond(
|
||||
request: Request,
|
||||
*,
|
||||
element: str,
|
||||
data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
collection: str | None = None,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Response:
|
||||
as_xml = wants_xml(request)
|
||||
hdrs = dict(headers or {})
|
||||
if collection is not None and isinstance(data, list):
|
||||
if as_xml:
|
||||
body = collection_to_xml(collection, element, data)
|
||||
return Response(content=body, status_code=status_code, media_type="application/xml", headers=hdrs)
|
||||
return JSONResponse(
|
||||
content=collection_to_json(element, data),
|
||||
status_code=status_code,
|
||||
headers=hdrs,
|
||||
)
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
if as_xml:
|
||||
return Response(
|
||||
content=entity_to_xml(element, payload),
|
||||
status_code=status_code,
|
||||
media_type="application/xml",
|
||||
headers=hdrs,
|
||||
)
|
||||
return JSONResponse(content=entity_to_json(element, payload), status_code=status_code, headers=hdrs)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Engine runtime settings stored in `ov_api_objects` (collection=options)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
|
||||
# Seeded option names (values live only in Postgres).
|
||||
OPT_DEFAULT_VM_MEMORY = "DEFAULT_VM_MEMORY"
|
||||
OPT_DEFAULT_DISK_SIZE = "DEFAULT_DISK_SIZE"
|
||||
OPT_DEFAULT_HOST_MEMORY = "DEFAULT_HOST_MEMORY"
|
||||
OPT_DEFAULT_HOST_CPU_CORES = "DEFAULT_HOST_CPU_CORES"
|
||||
OPT_DEFAULT_CPU_SOCKETS = "DEFAULT_CPU_SOCKETS"
|
||||
OPT_DEFAULT_CPU_CORES = "DEFAULT_CPU_CORES"
|
||||
OPT_DEFAULT_CPU_THREADS = "DEFAULT_CPU_THREADS"
|
||||
OPT_DEFAULT_NIC_INTERFACE = "DEFAULT_NIC_INTERFACE"
|
||||
OPT_DEFAULT_DISK_INTERFACE = "DEFAULT_DISK_INTERFACE"
|
||||
OPT_DEFAULT_DISK_FORMAT = "DEFAULT_DISK_FORMAT"
|
||||
OPT_DEFAULT_VM_TYPE = "DEFAULT_VM_TYPE"
|
||||
OPT_DEFAULT_OS_TYPE = "DEFAULT_OS_TYPE"
|
||||
OPT_DEFAULT_VM_STATUS = "DEFAULT_VM_STATUS"
|
||||
OPT_DEFAULT_HOST_STATUS = "DEFAULT_HOST_STATUS"
|
||||
OPT_DEFAULT_DC_STATUS = "DEFAULT_DC_STATUS"
|
||||
OPT_DEFAULT_SD_STATUS = "DEFAULT_SD_STATUS"
|
||||
OPT_DEFAULT_HOST_ADDRESS = "DEFAULT_HOST_ADDRESS"
|
||||
OPT_DEFAULT_CLUSTER_CPU_TYPE = "DEFAULT_CLUSTER_CPU_TYPE"
|
||||
OPT_DEFAULT_SD_TYPE = "DEFAULT_SD_TYPE"
|
||||
OPT_DEFAULT_STORAGE_TYPE = "DEFAULT_STORAGE_TYPE"
|
||||
OPT_DEFAULT_SD_AVAILABLE = "DEFAULT_SD_AVAILABLE"
|
||||
OPT_DEFAULT_STORAGE_CONNECTION_TYPE = "DEFAULT_STORAGE_CONNECTION_TYPE"
|
||||
OPT_DEFAULT_MAC_PREFIX = "DEFAULT_MAC_PREFIX"
|
||||
OPT_DEFAULT_NIC_NAME = "DEFAULT_NIC_NAME"
|
||||
OPT_DEFAULT_SNAPSHOT_DESCRIPTION = "DEFAULT_SNAPSHOT_DESCRIPTION"
|
||||
OPT_DEFAULT_TAG_NAME = "DEFAULT_TAG_NAME"
|
||||
OPT_DEFAULT_API_OBJECT_STATUS = "DEFAULT_API_OBJECT_STATUS"
|
||||
OPT_DEFAULT_ACTION_STATUS = "DEFAULT_ACTION_STATUS"
|
||||
OPT_DEFAULT_JOB_STATUS_COMPLETE = "DEFAULT_JOB_STATUS_COMPLETE"
|
||||
OPT_DEFAULT_JOB_STATUS_STARTED = "DEFAULT_JOB_STATUS_STARTED"
|
||||
OPT_DEFAULT_JOB_STEP_TYPE = "DEFAULT_JOB_STEP_TYPE"
|
||||
OPT_DEFAULT_AUTH_DOMAIN = "DEFAULT_AUTH_DOMAIN"
|
||||
OPT_DEFAULT_API_SCOPE = "DEFAULT_API_SCOPE"
|
||||
OPT_DEFAULT_TOKEN_TYPE = "DEFAULT_TOKEN_TYPE"
|
||||
OPT_DEFAULT_USER_ROLE = "DEFAULT_USER_ROLE"
|
||||
OPT_OAUTH_TOKEN_TTL_SECONDS = "OAUTH_TOKEN_TTL_SECONDS"
|
||||
OPT_BASIC_SESSION_TTL_SECONDS = "BASIC_SESSION_TTL_SECONDS"
|
||||
OPT_SD_ATTACH_ACTIVE = "SD_ATTACH_STATUS_ACTIVE"
|
||||
OPT_SD_ATTACH_MAINTENANCE = "SD_ATTACH_STATUS_MAINTENANCE"
|
||||
OPT_PRODUCT_NAME = "PRODUCT_NAME"
|
||||
OPT_PRODUCT_VENDOR = "PRODUCT_VENDOR"
|
||||
OPT_PRODUCT_MAJOR = "PRODUCT_MAJOR"
|
||||
OPT_PRODUCT_MINOR = "PRODUCT_MINOR"
|
||||
OPT_PRODUCT_BUILD = "PRODUCT_BUILD"
|
||||
OPT_PRODUCT_REVISION = "PRODUCT_REVISION"
|
||||
OPT_PRODUCT_FULL = "PRODUCT_FULL"
|
||||
OPT_ENGINE_API_DEFAULT_VERSION = "ENGINE_API_DEFAULT_VERSION"
|
||||
OPT_VM_ACTION_STATUS_MAP = "VM_ACTION_STATUS_MAP"
|
||||
OPT_HOST_ACTION_STATUS_MAP = "HOST_ACTION_STATUS_MAP"
|
||||
|
||||
_VM_ACTION_MAP = {
|
||||
"start": "up",
|
||||
"stop": "down",
|
||||
"shutdown": "down",
|
||||
"reboot": "up",
|
||||
"suspend": "suspended",
|
||||
"migrate": "up",
|
||||
"cancelmigration": "up",
|
||||
"maintenance": "down",
|
||||
"logon": "up",
|
||||
"freeze_filesystems": "up",
|
||||
"thaw_filesystems": "up",
|
||||
}
|
||||
|
||||
_HOST_ACTION_MAP = {
|
||||
"activate": "up",
|
||||
"deactivate": "maintenance",
|
||||
"approve": "up",
|
||||
"install": "up",
|
||||
"fence": "down",
|
||||
"refresh": "up",
|
||||
"upgrade": "up",
|
||||
"upgradecheck": "up",
|
||||
"commitnetconfig": "up",
|
||||
"enrollcertificate": "up",
|
||||
"iscsidiscover": "up",
|
||||
"iscsilogin": "up",
|
||||
"unregisteredstoragedomainsdiscover": "up",
|
||||
}
|
||||
|
||||
_DEFAULT_OPTIONS: list[tuple[str, str]] = [
|
||||
(OPT_DEFAULT_VM_MEMORY, str(1024**3)),
|
||||
(OPT_DEFAULT_DISK_SIZE, str(10 * 1024**3)),
|
||||
(OPT_DEFAULT_HOST_MEMORY, str(64 * 1024**3)),
|
||||
(OPT_DEFAULT_HOST_CPU_CORES, "16"),
|
||||
(OPT_DEFAULT_CPU_SOCKETS, "1"),
|
||||
(OPT_DEFAULT_CPU_CORES, "1"),
|
||||
(OPT_DEFAULT_CPU_THREADS, "1"),
|
||||
(OPT_DEFAULT_NIC_INTERFACE, "virtio"),
|
||||
(OPT_DEFAULT_DISK_INTERFACE, "virtio_scsi"),
|
||||
(OPT_DEFAULT_DISK_FORMAT, "cow"),
|
||||
(OPT_DEFAULT_VM_TYPE, "server"),
|
||||
(OPT_DEFAULT_OS_TYPE, "other"),
|
||||
(OPT_DEFAULT_VM_STATUS, "down"),
|
||||
(OPT_DEFAULT_HOST_STATUS, "up"),
|
||||
(OPT_DEFAULT_DC_STATUS, "up"),
|
||||
(OPT_DEFAULT_SD_STATUS, "active"),
|
||||
(OPT_DEFAULT_HOST_ADDRESS, "127.0.0.1"),
|
||||
(OPT_DEFAULT_CLUSTER_CPU_TYPE, "Intel Conroe Family"),
|
||||
(OPT_DEFAULT_SD_TYPE, "data"),
|
||||
(OPT_DEFAULT_STORAGE_TYPE, "nfs"),
|
||||
(OPT_DEFAULT_SD_AVAILABLE, str(1024**4)),
|
||||
(OPT_DEFAULT_STORAGE_CONNECTION_TYPE, "nfs"),
|
||||
(OPT_DEFAULT_MAC_PREFIX, "00:1a:4a"),
|
||||
(OPT_DEFAULT_NIC_NAME, "nic1"),
|
||||
(OPT_DEFAULT_SNAPSHOT_DESCRIPTION, "snapshot"),
|
||||
(OPT_DEFAULT_TAG_NAME, "tag"),
|
||||
(OPT_DEFAULT_API_OBJECT_STATUS, "ok"),
|
||||
(OPT_DEFAULT_ACTION_STATUS, "complete"),
|
||||
(OPT_DEFAULT_JOB_STATUS_COMPLETE, "finished"),
|
||||
(OPT_DEFAULT_JOB_STATUS_STARTED, "started"),
|
||||
(OPT_DEFAULT_JOB_STEP_TYPE, "executing"),
|
||||
(OPT_DEFAULT_AUTH_DOMAIN, "internal"),
|
||||
(OPT_DEFAULT_API_SCOPE, "ovirt-app-api"),
|
||||
(OPT_DEFAULT_TOKEN_TYPE, "bearer"),
|
||||
(OPT_DEFAULT_USER_ROLE, "UserRole"),
|
||||
(OPT_OAUTH_TOKEN_TTL_SECONDS, "3600"),
|
||||
(OPT_BASIC_SESSION_TTL_SECONDS, "7200"),
|
||||
(OPT_SD_ATTACH_ACTIVE, "active"),
|
||||
(OPT_SD_ATTACH_MAINTENANCE, "maintenance"),
|
||||
(OPT_PRODUCT_NAME, "oVirt Engine"),
|
||||
(OPT_PRODUCT_VENDOR, "ovirt.org"),
|
||||
(OPT_PRODUCT_MAJOR, "4"),
|
||||
(OPT_PRODUCT_MINOR, "5"),
|
||||
(OPT_PRODUCT_BUILD, "0"),
|
||||
(OPT_PRODUCT_REVISION, "0"),
|
||||
(OPT_PRODUCT_FULL, "4.5.0"),
|
||||
(OPT_ENGINE_API_DEFAULT_VERSION, "4"),
|
||||
(OPT_VM_ACTION_STATUS_MAP, json.dumps(_VM_ACTION_MAP, separators=(",", ":"))),
|
||||
(OPT_HOST_ACTION_STATUS_MAP, json.dumps(_HOST_ACTION_MAP, separators=(",", ":"))),
|
||||
]
|
||||
|
||||
|
||||
async def seed_engine_options(conn: Connection) -> None:
|
||||
"""Upsert Engine options used by handlers (create defaults + product_info)."""
|
||||
|
||||
for name, value in _DEFAULT_OPTIONS:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,'options',$2,'ok',$3::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
data=EXCLUDED.data, name=EXCLUDED.name, status='ok', updated_at=now()""",
|
||||
stable_id("obj", "options", name),
|
||||
name,
|
||||
json.dumps({"name": name, "value": value, "description": f"engine option {name}"}),
|
||||
)
|
||||
|
||||
|
||||
async def option_value(conn: Connection, name: str) -> str:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT data FROM ov_api_objects
|
||||
WHERE collection='options' AND name=$1""",
|
||||
name,
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError(f"missing engine option in DB: {name} (reload seed)")
|
||||
data = row["data"]
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
data = dict(data or {})
|
||||
if "value" in data and data["value"] is not None:
|
||||
return str(data["value"])
|
||||
raise RuntimeError(f"engine option {name} has no value in DB")
|
||||
|
||||
|
||||
async def option_int(conn: Connection, name: str) -> int:
|
||||
return int(await option_value(conn, name))
|
||||
|
||||
|
||||
async def option_json(conn: Connection, name: str) -> Any:
|
||||
raw = await option_value(conn, name)
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def product_info_from_db(conn: Connection) -> dict[str, Any]:
|
||||
return {
|
||||
"name": await option_value(conn, OPT_PRODUCT_NAME),
|
||||
"vendor": await option_value(conn, OPT_PRODUCT_VENDOR),
|
||||
"version": {
|
||||
"major": await option_value(conn, OPT_PRODUCT_MAJOR),
|
||||
"minor": await option_value(conn, OPT_PRODUCT_MINOR),
|
||||
"build": await option_value(conn, OPT_PRODUCT_BUILD),
|
||||
"revision": await option_value(conn, OPT_PRODUCT_REVISION),
|
||||
"full_version": await option_value(conn, OPT_PRODUCT_FULL),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Resolve Engine API version (v3/v4) and active series pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.ovirt.errors import OVirtError
|
||||
|
||||
_VERSION_PREFIX = re.compile(r"^/ovirt-engine/api/(v[34])(/.*)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiVersionContext:
|
||||
api_version: str # "3" or "4"
|
||||
series: str
|
||||
path_suffix: str # path after /ovirt-engine/api[/vN]
|
||||
|
||||
|
||||
def resolve_api_version(request: Request, default_series: str = "4.5") -> ApiVersionContext:
|
||||
path = request.url.path
|
||||
header = (request.headers.get("version") or "").strip()
|
||||
series = getattr(request.app.state, "ovirt_series", None) or default_series
|
||||
|
||||
m = _VERSION_PREFIX.match(path)
|
||||
if m:
|
||||
ver = m.group(1)[1:] # strip v
|
||||
rest = m.group(2) or ""
|
||||
return ApiVersionContext(api_version=ver, series=series, path_suffix=rest or "/")
|
||||
|
||||
if path.startswith("/ovirt-engine/api"):
|
||||
rest = path[len("/ovirt-engine/api") :] or "/"
|
||||
if header in {"3", "4"}:
|
||||
return ApiVersionContext(api_version=header, series=series, path_suffix=rest)
|
||||
# default version 4 for 4.x series, 3 for 3.x
|
||||
default = "3" if str(series).startswith("3.") else "4"
|
||||
return ApiVersionContext(api_version=default, series=series, path_suffix=rest)
|
||||
|
||||
raise OVirtError("NotFound", f"Unknown path {path}", status_code=404)
|
||||
|
||||
|
||||
def strip_api_prefix(path: str) -> str:
|
||||
"""Normalize to collection-relative path starting with /."""
|
||||
|
||||
for prefix in (
|
||||
"/ovirt-engine/api/v4",
|
||||
"/ovirt-engine/api/v3",
|
||||
"/ovirt-engine/api",
|
||||
):
|
||||
if path.startswith(prefix):
|
||||
rest = path[len(prefix) :] or "/"
|
||||
return rest if rest.startswith("/") else f"/{rest}"
|
||||
return path
|
||||
Reference in New Issue
Block a user