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:
2026-07-18 04:49:28 +03:00
commit cbd0adca91
218 changed files with 246804 additions and 0 deletions
+9
View File
@@ -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)
+220
View File
@@ -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)
+174
View File
@@ -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
+436
View File
@@ -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"]
+22
View File
@@ -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))
+68
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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)
+15
View File
@@ -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))
+100
View File
@@ -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))
+37
View File
@@ -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}"
+40
View File
@@ -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)
+98
View File
@@ -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
+353
View File
@@ -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 "",
}
+1
View File
@@ -0,0 +1 @@
"""Specialized oVirt Engine route modules."""
File diff suppressed because it is too large Load Diff
+90
View File
@@ -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})
+330
View File
@@ -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)
+348
View File
@@ -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,
}
+41
View File
@@ -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()
+432
View File
@@ -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,
)
+173
View File
@@ -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)
+198
View File
@@ -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),
},
}
+55
View File
@@ -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