Add OpenStack request-body schemas and nested console PARAM sync.
This commit is contained in:
+2
-2
@@ -38,7 +38,7 @@ def create_lifespan(
|
||||
await database.connect()
|
||||
app.state.database = database
|
||||
if isinstance(database, AsyncpgDatabase):
|
||||
from app.ovirt.demo_datacenter import DEMO_PROFILE
|
||||
from app.ovirt.demo_datacenter import DEMO_PROFILES
|
||||
from app.ovirt.seed import seed_ovirt
|
||||
from app.ovirt.settings import seed_engine_options
|
||||
|
||||
@@ -49,7 +49,7 @@ def create_lifespan(
|
||||
)
|
||||
except Exception:
|
||||
profile = None
|
||||
if profile != DEMO_PROFILE:
|
||||
if profile not in DEMO_PROFILES:
|
||||
await seed_ovirt(connection)
|
||||
else:
|
||||
# Keep Engine options current without wiping demo inventory.
|
||||
|
||||
+268
-90
@@ -1,21 +1,195 @@
|
||||
"""Large demo datacenter seed (~1000 VMs + full inventory)."""
|
||||
"""Sized cluster demo seeds: small / large / big (+ demo→large alias)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
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.ovirt.seed import clear_ovirt_state
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
DEMO_VM_COUNT = 1000
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClusterSizeSpec:
|
||||
"""Topology + inventory density for a demo cluster size."""
|
||||
|
||||
name: str
|
||||
hosts: int
|
||||
vms: int
|
||||
datacenters: int
|
||||
clusters_per_dc: int
|
||||
hosts_per_cluster: int
|
||||
networks_per_dc: int
|
||||
storage_per_dc: int
|
||||
templates: tuple[str, ...]
|
||||
tags: tuple[str, ...]
|
||||
groups: tuple[str, ...]
|
||||
events: int
|
||||
jobs: int
|
||||
bookmarks: tuple[tuple[str, str], ...]
|
||||
instancetypes: tuple[str, ...]
|
||||
macpools: tuple[str, ...]
|
||||
vmpools: tuple[str, ...]
|
||||
affinitylabels: tuple[str, ...]
|
||||
katelloerrata: tuple[str, ...]
|
||||
icons: tuple[str, ...]
|
||||
operatingsystems: tuple[str, ...]
|
||||
|
||||
|
||||
async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
"""Replace state with a multi-DC demo inventory including ~1000 VMs."""
|
||||
CLUSTER_SIZES: dict[str, ClusterSizeSpec] = {
|
||||
"small": ClusterSizeSpec(
|
||||
name="small",
|
||||
hosts=3,
|
||||
vms=50,
|
||||
datacenters=1,
|
||||
clusters_per_dc=1,
|
||||
hosts_per_cluster=3,
|
||||
networks_per_dc=2,
|
||||
storage_per_dc=2,
|
||||
templates=("rhel9-base", "ubuntu2204-base"),
|
||||
tags=("lab", "web"),
|
||||
groups=("developers", "readers"),
|
||||
events=15,
|
||||
jobs=5,
|
||||
bookmarks=(("UpVMs", "Vms: status=up"),),
|
||||
instancetypes=("Small", "Medium"),
|
||||
macpools=("Default",),
|
||||
vmpools=("web-pool",),
|
||||
affinitylabels=("label-a",),
|
||||
katelloerrata=("RHSA-2024:0001",),
|
||||
icons=("default",),
|
||||
operatingsystems=("rhel_9x64", "ubuntu_22_04"),
|
||||
),
|
||||
"large": ClusterSizeSpec(
|
||||
name="large",
|
||||
hosts=10,
|
||||
vms=1000,
|
||||
datacenters=2,
|
||||
clusters_per_dc=1,
|
||||
hosts_per_cluster=5,
|
||||
networks_per_dc=3,
|
||||
storage_per_dc=3,
|
||||
templates=("rhel8-base", "rhel9-base", "win2022-base", "ubuntu2204-base"),
|
||||
tags=("production", "web", "database", "batch", "gpu"),
|
||||
groups=("developers", "operators", "readers"),
|
||||
events=50,
|
||||
jobs=20,
|
||||
bookmarks=(
|
||||
("UpVMs", "Vms: status=up"),
|
||||
("DownVMs", "Vms: status=down"),
|
||||
),
|
||||
instancetypes=("Tiny", "Small", "Medium", "Large", "XLarge"),
|
||||
macpools=("Default", "Secondary"),
|
||||
vmpools=("web-pool", "batch-pool"),
|
||||
affinitylabels=("label-a", "label-b"),
|
||||
katelloerrata=("RHSA-2024:0001", "RHBA-2024:0002"),
|
||||
icons=("default", "custom"),
|
||||
operatingsystems=("rhel_8x64", "rhel_9x64", "windows_2022", "ubuntu_22_04"),
|
||||
),
|
||||
"big": ClusterSizeSpec(
|
||||
name="big",
|
||||
hosts=30,
|
||||
vms=2000,
|
||||
datacenters=3,
|
||||
clusters_per_dc=2,
|
||||
hosts_per_cluster=5,
|
||||
networks_per_dc=3,
|
||||
storage_per_dc=4,
|
||||
templates=(
|
||||
"rhel8-base",
|
||||
"rhel9-base",
|
||||
"win2022-base",
|
||||
"ubuntu2204-base",
|
||||
"centos-stream9",
|
||||
"debian12-base",
|
||||
),
|
||||
tags=(
|
||||
"production",
|
||||
"web",
|
||||
"database",
|
||||
"batch",
|
||||
"gpu",
|
||||
"edge",
|
||||
"staging",
|
||||
"critical",
|
||||
),
|
||||
groups=("developers", "operators", "readers", "auditors"),
|
||||
events=120,
|
||||
jobs=40,
|
||||
bookmarks=(
|
||||
("UpVMs", "Vms: status=up"),
|
||||
("DownVMs", "Vms: status=down"),
|
||||
("ProdHosts", "Hosts:"),
|
||||
),
|
||||
instancetypes=("Tiny", "Small", "Medium", "Large", "XLarge", "2XLarge", "4XLarge"),
|
||||
macpools=("Default", "Secondary", "Edge"),
|
||||
vmpools=("web-pool", "batch-pool", "gpu-pool", "edge-pool"),
|
||||
affinitylabels=("label-a", "label-b", "label-c", "label-d"),
|
||||
katelloerrata=("RHSA-2024:0001", "RHBA-2024:0002", "RHSA-2024:1001"),
|
||||
icons=("default", "custom", "windows", "linux"),
|
||||
operatingsystems=(
|
||||
"rhel_8x64",
|
||||
"rhel_9x64",
|
||||
"windows_2022",
|
||||
"ubuntu_22_04",
|
||||
"centos_stream9",
|
||||
"debian_12",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
# Canonical demo profile names that must not be wiped on simulator restart.
|
||||
DEMO_PROFILES: frozenset[str] = frozenset(CLUSTER_SIZES) | {"demo"}
|
||||
# Default / legacy alias target.
|
||||
DEMO_PROFILE = "large"
|
||||
DEMO_VM_COUNT = CLUSTER_SIZES["large"].vms
|
||||
|
||||
|
||||
def normalize_cluster_size(size: str | None) -> str:
|
||||
"""Map CLI/UI aliases to a ClusterSizeSpec name."""
|
||||
|
||||
key = (size or DEMO_PROFILE).strip().lower()
|
||||
if key == "demo":
|
||||
return "large"
|
||||
if key not in CLUSTER_SIZES:
|
||||
raise ValueError(f"unknown cluster size {size!r}; expected small|large|big|demo")
|
||||
return key
|
||||
|
||||
|
||||
def cluster_size_spec(size: str | None = None) -> ClusterSizeSpec:
|
||||
return CLUSTER_SIZES[normalize_cluster_size(size)]
|
||||
|
||||
|
||||
_DC_NAMES = (
|
||||
("dc-prod", "Production", False, 4, 5),
|
||||
("dc-stage", "Staging", False, 4, 4),
|
||||
("dc-edge", "Edge", True, 4, 3),
|
||||
)
|
||||
_NETWORK_SPECS = (("ovirtmgmt", None), ("vm-net", 100), ("storage-net", 200))
|
||||
_STORAGE_TYPES = ("nfs", "iscsi", "fcp", "localfs")
|
||||
_TEMPLATE_SPECS: dict[str, tuple[int, int]] = {
|
||||
"rhel8-base": (4 * 1024**3, 2),
|
||||
"rhel9-base": (4 * 1024**3, 2),
|
||||
"win2022-base": (8 * 1024**3, 4),
|
||||
"ubuntu2204-base": (2 * 1024**3, 2),
|
||||
"centos-stream9": (4 * 1024**3, 2),
|
||||
"debian12-base": (2 * 1024**3, 2),
|
||||
}
|
||||
|
||||
|
||||
async def seed_ovirt_demo(conn: Connection, size: str | None = None) -> dict[str, Any]:
|
||||
"""Replace state with a sized multi-host demo inventory."""
|
||||
|
||||
spec = cluster_size_spec(size)
|
||||
expected_hosts = spec.datacenters * spec.clusters_per_dc * spec.hosts_per_cluster
|
||||
if expected_hosts != spec.hosts:
|
||||
raise RuntimeError(
|
||||
f"cluster size {spec.name}: topology hosts {expected_hosts} != declared {spec.hosts}"
|
||||
)
|
||||
|
||||
await clear_ovirt_state(conn)
|
||||
|
||||
@@ -39,7 +213,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
admin,
|
||||
)
|
||||
|
||||
users = {}
|
||||
users: dict[str, Any] = {}
|
||||
for uname, role in (
|
||||
("admin", role_super),
|
||||
("ops", role_cluster),
|
||||
@@ -70,7 +244,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
stable_id("group", "engine-admins"),
|
||||
domain_id,
|
||||
)
|
||||
for gname in ("developers", "operators", "readers"):
|
||||
for gname in spec.groups:
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,$3)",
|
||||
stable_id("group", gname),
|
||||
@@ -78,20 +252,14 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
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),
|
||||
]
|
||||
dc_specs = list(_DC_NAMES[: spec.datacenters])
|
||||
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:
|
||||
for dc_idx, (dc_key, dc_name, local, maj, minor) in enumerate(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)
|
||||
@@ -109,8 +277,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
stable_id("quota", dc_key),
|
||||
dc_id,
|
||||
)
|
||||
for ci in range(2):
|
||||
cname = f"{dc_key}-cluster-{ci+1}"
|
||||
for ci in range(spec.clusters_per_dc):
|
||||
cname = f"{dc_key}-cluster-{ci + 1}"
|
||||
cid = stable_id("cluster", cname)
|
||||
clusters.append((cid, dc_id, cname))
|
||||
await conn.execute(
|
||||
@@ -119,7 +287,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
cid,
|
||||
dc_id,
|
||||
cname,
|
||||
f"Cluster {ci+1} in {dc_name}",
|
||||
f"Cluster {ci + 1} in {dc_name}",
|
||||
maj,
|
||||
minor,
|
||||
)
|
||||
@@ -129,8 +297,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
stable_id("ag", cname),
|
||||
cid,
|
||||
)
|
||||
for hi in range(4):
|
||||
hname = f"{cname}-host-{hi+1:02d}"
|
||||
for hi in range(spec.hosts_per_cluster):
|
||||
hname = f"{cname}-host-{hi + 1:02d}"
|
||||
hid = stable_id("host", hname)
|
||||
hosts.append(hid)
|
||||
await conn.execute(
|
||||
@@ -139,19 +307,27 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
hid,
|
||||
cid,
|
||||
hname,
|
||||
f"10.{dc_specs.index((dc_key, dc_name, local, maj, minor))+10}.{ci+1}.{hi+10}",
|
||||
f"10.{10 + dc_idx}.{ci + 1}.{hi + 10}",
|
||||
(256 + hi * 32) * 1024**3,
|
||||
)
|
||||
# networks
|
||||
for nname, vlan in (("ovirtmgmt", None), ("vm-net", 100), ("storage-net", 200)):
|
||||
|
||||
for nname, vlan in _NETWORK_SPECS[: spec.networks_per_dc]:
|
||||
nid = stable_id("net", dc_key, nname)
|
||||
networks.append(nid)
|
||||
if nname == "ovirtmgmt" and dc_key == "dc-prod":
|
||||
net_label = "ovirtmgmt"
|
||||
elif nname == "ovirtmgmt":
|
||||
net_label = f"{dc_key}-ovirtmgmt"
|
||||
elif spec.datacenters > 1:
|
||||
net_label = f"{dc_key}-{nname}"
|
||||
else:
|
||||
net_label = nname
|
||||
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",
|
||||
net_label,
|
||||
f"{nname} in {dc_name}",
|
||||
vlan,
|
||||
)
|
||||
@@ -163,9 +339,9 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
nid,
|
||||
nname,
|
||||
)
|
||||
# storage domains
|
||||
for si, stype in enumerate(storage_types):
|
||||
sname = f"{dc_key}-{stype}-{si+1}"
|
||||
|
||||
for si, stype in enumerate(_STORAGE_TYPES[: spec.storage_per_dc]):
|
||||
sname = f"{dc_key}-{stype}-{si + 1}"
|
||||
sid = stable_id("sd", sname)
|
||||
storage_domains.append(sid)
|
||||
await conn.execute(
|
||||
@@ -201,12 +377,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
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),
|
||||
):
|
||||
for tname in spec.templates:
|
||||
mem, cores = _TEMPLATE_SPECS.get(tname, (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)""",
|
||||
@@ -218,11 +390,9 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
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"]
|
||||
os_types = list(spec.operatingsystems) or ["other"]
|
||||
default_profile = profiles[0]
|
||||
default_sd = storage_domains[0]
|
||||
|
||||
vm_rows = []
|
||||
disk_rows = []
|
||||
@@ -230,13 +400,13 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
nic_rows = []
|
||||
snap_rows = []
|
||||
|
||||
for i in range(DEMO_VM_COUNT):
|
||||
cluster_id, _dc, cname = clusters[i % len(clusters)]
|
||||
for i in range(spec.vms):
|
||||
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}"
|
||||
name = f"vm-{i + 1:04d}"
|
||||
vm_id = stable_id("vm", name)
|
||||
memory = (1 + (i % 8)) * 1024**3
|
||||
cores = 1 + (i % 8)
|
||||
@@ -246,7 +416,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
cluster_id,
|
||||
blank_id,
|
||||
name,
|
||||
f"Demo VM {i+1}",
|
||||
f"Demo VM {i + 1}",
|
||||
status,
|
||||
memory,
|
||||
1,
|
||||
@@ -273,11 +443,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
)
|
||||
)
|
||||
if i % 7 == 0:
|
||||
snap_rows.append(
|
||||
(stable_id("snap", name, "1"), vm_id, f"snapshot-{name}", "ok")
|
||||
)
|
||||
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)
|
||||
@@ -306,8 +473,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
snap_rows,
|
||||
)
|
||||
|
||||
# Tags, bookmarks, events, jobs, surface objects
|
||||
for tname in ("production", "web", "database", "batch", "gpu"):
|
||||
tag_step = max(1, spec.vms // 10)
|
||||
for tname in spec.tags:
|
||||
tid = stable_id("tag", tname)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_tags(id, name, description) VALUES($1,$2,$3)",
|
||||
@@ -315,20 +482,23 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
tname,
|
||||
f"Tag {tname}",
|
||||
)
|
||||
for i in range(0, min(50, DEMO_VM_COUNT), 10):
|
||||
for i in range(0, min(spec.vms, tag_step * 5), tag_step):
|
||||
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}"),
|
||||
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):
|
||||
for bname, bvalue in spec.bookmarks:
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,$2,$3)",
|
||||
stable_id("bm", bname),
|
||||
bname,
|
||||
bvalue,
|
||||
)
|
||||
for i in range(spec.events):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_events(code, severity, description, user_id)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
@@ -337,7 +507,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
f"Demo event {i}",
|
||||
users["admin"],
|
||||
)
|
||||
for i in range(20):
|
||||
for i in range(spec.jobs):
|
||||
jid = stable_id("job", str(i))
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, owner_id)
|
||||
@@ -354,24 +524,31 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
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"]),
|
||||
):
|
||||
surface: list[tuple[str, tuple[str, ...]]] = [
|
||||
("instancetypes", spec.instancetypes),
|
||||
("macpools", spec.macpools),
|
||||
(
|
||||
"schedulingpolicies",
|
||||
("evenly_distributed", "power_saving", "vm_evenly_distributed"),
|
||||
),
|
||||
(
|
||||
"schedulingpolicyunits",
|
||||
("EvenlyDistributed", "PowerSaving", "VmEvenlyDistributed"),
|
||||
),
|
||||
("clusterlevels", ("4.3", "4.4", "4.5")),
|
||||
("icons", spec.icons),
|
||||
("operatingsystems", spec.operatingsystems),
|
||||
("networkfilters", ("vdsm-no-mac-spoofing",)),
|
||||
("vmpools", spec.vmpools),
|
||||
("affinitylabels", spec.affinitylabels),
|
||||
("katelloerrata", spec.katelloerrata),
|
||||
("externalhostproviders", ("foreman-lab",)),
|
||||
("openstacknetworkproviders", ("ovn-provider",)),
|
||||
("openstackimageproviders", ("glance-lab",)),
|
||||
("openstackvolumeproviders", ("cinder-lab",)),
|
||||
("imagetransfers", ("transfer-1",)),
|
||||
]
|
||||
for collection, names in surface:
|
||||
for name in names:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
@@ -389,41 +566,34 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
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")]
|
||||
template_ids = [blank_id, *[stable_id("template", n) for n in spec.templates]]
|
||||
tag_ids = [stable_id("tag", t) for t in spec.tags]
|
||||
await seed_nested_for_inventory(
|
||||
conn,
|
||||
admin_user_id=users["admin"],
|
||||
role_user_id=role_user,
|
||||
datacenter_ids=dc_ids,
|
||||
cluster_ids=cluster_ids,
|
||||
cluster_ids=[c[0] for c in clusters],
|
||||
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)],
|
||||
vm_ids=[stable_id("vm", f"vm-{i:04d}") for i in range(1, spec.vms + 1)],
|
||||
disk_ids=[stable_id("disk", f"vm-{i:04d}") for i in range(1, spec.vms + 1)],
|
||||
tag_ids=tag_ids,
|
||||
user_ids=list(users.values()),
|
||||
group_ids=[
|
||||
stable_id("group", n)
|
||||
for n in ("engine-admins", "developers", "operators", "readers")
|
||||
for n in ("engine-admins", *spec.groups)
|
||||
],
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", DEMO_PROFILE
|
||||
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", spec.name
|
||||
)
|
||||
return {
|
||||
"profile": DEMO_PROFILE,
|
||||
"vms": DEMO_VM_COUNT,
|
||||
"profile": spec.name,
|
||||
"vms": spec.vms,
|
||||
"hosts": len(hosts),
|
||||
"datacenters": len(dc_specs),
|
||||
"clusters": len(clusters),
|
||||
@@ -432,5 +602,13 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Re-export for web routes
|
||||
__all__ = ["DEMO_PROFILE", "DEMO_VM_COUNT", "clear_ovirt_state", "seed_ovirt", "seed_ovirt_demo"]
|
||||
__all__ = [
|
||||
"CLUSTER_SIZES",
|
||||
"DEMO_PROFILE",
|
||||
"DEMO_PROFILES",
|
||||
"DEMO_VM_COUNT",
|
||||
"clear_ovirt_state",
|
||||
"cluster_size_spec",
|
||||
"normalize_cluster_size",
|
||||
"seed_ovirt_demo",
|
||||
]
|
||||
|
||||
+425
-49
@@ -183,6 +183,18 @@ async def handle_engine_request(request: Request) -> Response:
|
||||
"DataError",
|
||||
"InvalidTextRepresentationError",
|
||||
} or "invalid input syntax for type uuid" in detail:
|
||||
if name == "UniqueViolationError" or "duplicate key" in detail.lower():
|
||||
raise OVirtError(
|
||||
"OperationFailed",
|
||||
"Entity already exists",
|
||||
status_code=409,
|
||||
) from exc
|
||||
if name == "ForeignKeyViolationError":
|
||||
raise OVirtError(
|
||||
"BadRequest",
|
||||
"Referenced entity does not exist",
|
||||
status_code=400,
|
||||
) from exc
|
||||
raise OVirtError("BadRequest", detail or name, status_code=400) from exc
|
||||
raise
|
||||
|
||||
@@ -354,6 +366,12 @@ async def _dispatch(
|
||||
payload,
|
||||
)
|
||||
|
||||
# Convenience top-level lists (pack ops are nested; avoid empty schema fallbacks).
|
||||
if parts[0] == "affinitygroups":
|
||||
return await _handle_top_affinity_groups(request, conn, method, parts)
|
||||
if parts[0] == "quotas":
|
||||
return await _handle_top_quotas(request, conn, method, parts)
|
||||
|
||||
# Fall through to schema/generic object store
|
||||
from app.ovirt.schema_engine import handle_generic
|
||||
|
||||
@@ -383,13 +401,39 @@ async def _handle_vms(
|
||||
body = unwrap_entity(payload, "vm")
|
||||
name = str(body.get("name") or f"vm-{uuid4().hex[:8]}")
|
||||
cluster = body.get("cluster") or {}
|
||||
cluster_id = cluster.get("id") if isinstance(cluster, dict) else None
|
||||
cluster_id = None
|
||||
if isinstance(cluster, dict):
|
||||
cluster_id = cluster.get("id")
|
||||
if cluster_id:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM ov_clusters WHERE id=$1::uuid", cluster_id
|
||||
)
|
||||
if not exists:
|
||||
cluster_id = None
|
||||
if not cluster_id and cluster.get("name"):
|
||||
cluster_id = await conn.fetchval(
|
||||
"SELECT id FROM ov_clusters WHERE name=$1 LIMIT 1",
|
||||
str(cluster["name"]),
|
||||
)
|
||||
if not cluster_id:
|
||||
cluster_id = await conn.fetchval("SELECT id FROM ov_clusters ORDER BY name LIMIT 1")
|
||||
if not cluster_id:
|
||||
raise OVirtError("BadRequest", "cluster is required", status_code=400)
|
||||
template = body.get("template") or {}
|
||||
template_id = template.get("id") if isinstance(template, dict) else None
|
||||
template_id = None
|
||||
if isinstance(template, dict):
|
||||
template_id = template.get("id")
|
||||
if template_id:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM ov_templates WHERE id=$1::uuid", template_id
|
||||
)
|
||||
if not exists:
|
||||
template_id = None
|
||||
if not template_id and template.get("name"):
|
||||
template_id = await conn.fetchval(
|
||||
"SELECT id FROM ov_templates WHERE name=$1 LIMIT 1",
|
||||
str(template["name"]),
|
||||
)
|
||||
if not template_id:
|
||||
template_id = await conn.fetchval(
|
||||
"SELECT id FROM ov_templates WHERE name='Blank' LIMIT 1"
|
||||
@@ -516,7 +560,20 @@ async def _handle_vms(
|
||||
raise OVirtError("NotFound", f"VM {vm_id} not found", status_code=404)
|
||||
if action == "clone":
|
||||
body = unwrap_entity(payload, "vm") if payload else {}
|
||||
new_name = str(body.get("name") or f"{row['name']}-clone")
|
||||
# Action root may wrap `vm: { name }` or place name on the action itself.
|
||||
nested = body.get("vm") if isinstance(body.get("vm"), dict) else None
|
||||
new_name = str(
|
||||
(nested or {}).get("name")
|
||||
or body.get("name")
|
||||
or f"{row['name']}-clone"
|
||||
)
|
||||
existing = await conn.fetchval("SELECT 1 FROM ov_vms WHERE name=$1", new_name)
|
||||
if existing:
|
||||
raise OVirtError(
|
||||
"OperationFailed",
|
||||
f"Cannot clone VM. VM name '{new_name}' is already used.",
|
||||
status_code=409,
|
||||
)
|
||||
new_id = uuid4()
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
|
||||
@@ -535,6 +592,7 @@ async def _handle_vms(
|
||||
row["os_type"],
|
||||
row["type"],
|
||||
)
|
||||
await _copy_vm_storage_and_nics(conn, source_vm_id=vm_id, target_vm_id=str(new_id))
|
||||
return await respond_action(
|
||||
request, conn, description=f"Clone VM {row['name']}", owner_id=user_id
|
||||
)
|
||||
@@ -605,7 +663,25 @@ async def _vm_disk_attachments(
|
||||
body = unwrap_entity(payload, "disk_attachment")
|
||||
disk = body.get("disk") or {}
|
||||
disk_id = disk.get("id") if isinstance(disk, dict) else None
|
||||
if not disk_id:
|
||||
if disk_id:
|
||||
disk_row = await conn.fetchrow(
|
||||
"SELECT id FROM ov_disks WHERE id=$1::uuid", disk_id
|
||||
)
|
||||
if disk_row is None:
|
||||
raise OVirtError("NotFound", f"Disk {disk_id} not found", status_code=404)
|
||||
already = await conn.fetchval(
|
||||
"""SELECT 1 FROM ov_disk_attachments
|
||||
WHERE vm_id=$1::uuid AND disk_id=$2::uuid""",
|
||||
vm_id,
|
||||
disk_id,
|
||||
)
|
||||
if already:
|
||||
raise OVirtError(
|
||||
"OperationFailed",
|
||||
"Cannot attach Disk. Disk is already attached to this VM.",
|
||||
status_code=409,
|
||||
)
|
||||
else:
|
||||
size = int(
|
||||
(disk or {}).get("provisioned_size")
|
||||
or await option_int(conn, OPT_DEFAULT_DISK_SIZE)
|
||||
@@ -695,6 +771,13 @@ async def _vm_nics(
|
||||
)
|
||||
if len(parts) >= 4:
|
||||
nic_id = parts[3]
|
||||
if len(parts) == 4 and method == "GET":
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_nics WHERE id=$1::uuid AND vm_id=$2::uuid", nic_id, vm_id
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "nic not found", status_code=404)
|
||||
return respond(request, element="nic", data=nic_entity(r, vm_id=vm_id))
|
||||
if len(parts) == 4 and method == "DELETE":
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_nics WHERE id=$1::uuid AND vm_id=$2::uuid", nic_id, vm_id
|
||||
@@ -712,9 +795,18 @@ async def _vm_nics(
|
||||
profile_id,
|
||||
body.get("name"),
|
||||
)
|
||||
elif body.get("name"):
|
||||
await conn.execute(
|
||||
"UPDATE ov_nics SET name=$3 WHERE id=$1::uuid AND vm_id=$2::uuid",
|
||||
nic_id,
|
||||
vm_id,
|
||||
body.get("name"),
|
||||
)
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_nics WHERE id=$1::uuid AND vm_id=$2::uuid", nic_id, vm_id
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "nic not found", status_code=404)
|
||||
return respond(request, element="nic", data=nic_entity(r, vm_id=vm_id))
|
||||
if len(parts) == 5 and method == "POST" and parts[4] in {"activate", "deactivate"}:
|
||||
plugged = parts[4] == "activate"
|
||||
@@ -763,6 +855,13 @@ async def _vm_snapshots(
|
||||
return respond(
|
||||
request, element="snapshot", data=snapshot_entity(row, vm_id=vm_id), status_code=201
|
||||
)
|
||||
if len(parts) == 4 and method == "GET":
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM ov_snapshots WHERE id=$1::uuid AND vm_id=$2::uuid", parts[3], vm_id
|
||||
)
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", "snapshot not found", status_code=404)
|
||||
return respond(request, element="snapshot", data=snapshot_entity(row, vm_id=vm_id))
|
||||
if len(parts) == 4 and method == "DELETE":
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_snapshots WHERE id=$1::uuid AND vm_id=$2::uuid", parts[3], vm_id
|
||||
@@ -788,6 +887,17 @@ async def _vm_tags(
|
||||
)
|
||||
items = [tag_entity(r) for r in rows]
|
||||
return respond(request, element="tag", collection="tags", data=items)
|
||||
if len(parts) == 4 and method == "GET":
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT t.* FROM ov_tags t
|
||||
JOIN ov_tag_assignments a ON a.tag_id=t.id
|
||||
WHERE a.object_type='vm' AND a.object_id=$1::uuid AND t.id=$2::uuid""",
|
||||
vm_id,
|
||||
parts[3],
|
||||
)
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", "tag not found on vm", status_code=404)
|
||||
return respond(request, element="tag", data=tag_entity(row))
|
||||
if len(parts) == 3 and method == "POST":
|
||||
body = unwrap_entity(payload, "tag")
|
||||
tag_id = body.get("id")
|
||||
@@ -1049,16 +1159,25 @@ async def _handle_datacenters(
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_quotas WHERE datacenter_id=$1::uuid ORDER BY name", dc_id
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/datacenters/{dc_id}/quotas/{r['id']}",
|
||||
"name": r["name"],
|
||||
"description": r["description"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
items = [_quota_entity(r, dc_id) for r in rows]
|
||||
return respond(request, element="quota", collection="quotas", data=items)
|
||||
if method == "POST" and len(parts) == 3:
|
||||
body = unwrap_entity(payload, "quota")
|
||||
qid = uuid4()
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
|
||||
VALUES($1,$2::uuid,$3,$4)""",
|
||||
qid,
|
||||
dc_id,
|
||||
str(body.get("name") or f"quota-{qid.hex[:6]}"),
|
||||
str(body.get("description") or ""),
|
||||
)
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_quotas WHERE id=$1 AND datacenter_id=$2::uuid", qid, dc_id
|
||||
)
|
||||
return respond(
|
||||
request, element="quota", data=_quota_entity(r, dc_id), status_code=201
|
||||
)
|
||||
if method == "GET" and len(parts) == 4:
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_quotas WHERE id=$1::uuid AND datacenter_id=$2::uuid",
|
||||
@@ -1067,16 +1186,32 @@ async def _handle_datacenters(
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "quota not found", status_code=404)
|
||||
return respond(
|
||||
request,
|
||||
element="quota",
|
||||
data={
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/datacenters/{dc_id}/quotas/{r['id']}",
|
||||
"name": r["name"],
|
||||
"description": r["description"],
|
||||
},
|
||||
return respond(request, element="quota", data=_quota_entity(r, dc_id))
|
||||
if method == "PUT" and len(parts) == 4:
|
||||
body = unwrap_entity(payload, "quota")
|
||||
await conn.execute(
|
||||
"""UPDATE ov_quotas SET name=COALESCE($3,name), description=COALESCE($4,description)
|
||||
WHERE id=$1::uuid AND datacenter_id=$2::uuid""",
|
||||
parts[3],
|
||||
dc_id,
|
||||
body.get("name"),
|
||||
body.get("description"),
|
||||
)
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_quotas WHERE id=$1::uuid AND datacenter_id=$2::uuid",
|
||||
parts[3],
|
||||
dc_id,
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "quota not found", status_code=404)
|
||||
return respond(request, element="quota", data=_quota_entity(r, dc_id))
|
||||
if method == "DELETE" and len(parts) == 4:
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_quotas WHERE id=$1::uuid AND datacenter_id=$2::uuid",
|
||||
parts[3],
|
||||
dc_id,
|
||||
)
|
||||
return Response(status_code=200)
|
||||
from app.ovirt.schema_engine import handle_subcollection
|
||||
|
||||
if len(parts) >= 3:
|
||||
@@ -1309,8 +1444,6 @@ async def _handle_clusters(
|
||||
if len(parts) == 2 and method == "DELETE":
|
||||
await conn.execute("DELETE FROM ov_clusters WHERE id=$1::uuid", cluster_id)
|
||||
return Response(status_code=200)
|
||||
if len(parts) == 3 and method == "POST":
|
||||
return await respond_action(request, conn, description=f"Cluster {parts[2]}")
|
||||
if len(parts) >= 3 and parts[2] == "affinitygroups":
|
||||
if method == "GET" and len(parts) == 3:
|
||||
rows = await conn.fetch(
|
||||
@@ -1357,6 +1490,30 @@ async def _handle_clusters(
|
||||
element="affinity_group",
|
||||
data=_affinity_group_entity(r, cluster_id),
|
||||
)
|
||||
if method == "PUT" and len(parts) == 4:
|
||||
body = unwrap_entity(payload, "affinity_group")
|
||||
await conn.execute(
|
||||
"""UPDATE ov_affinity_groups
|
||||
SET name=COALESCE($3,name), enforcing=COALESCE($4,enforcing),
|
||||
positive=COALESCE($5,positive), description=COALESCE($6,description)
|
||||
WHERE id=$1::uuid AND cluster_id=$2::uuid""",
|
||||
parts[3],
|
||||
cluster_id,
|
||||
body.get("name"),
|
||||
body.get("enforcing"),
|
||||
body.get("positive"),
|
||||
body.get("description"),
|
||||
)
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_affinity_groups WHERE id=$1::uuid AND cluster_id=$2::uuid",
|
||||
parts[3],
|
||||
cluster_id,
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "affinity group not found", status_code=404)
|
||||
return respond(
|
||||
request, element="affinity_group", data=_affinity_group_entity(r, cluster_id)
|
||||
)
|
||||
if method == "DELETE" and len(parts) == 4:
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_affinity_groups WHERE id=$1::uuid AND cluster_id=$2::uuid",
|
||||
@@ -1366,6 +1523,9 @@ async def _handle_clusters(
|
||||
return Response(status_code=200)
|
||||
if len(parts) >= 3 and parts[2] == "networks":
|
||||
return await _cluster_networks(request, conn, method, parts, payload)
|
||||
if len(parts) == 3 and method == "POST":
|
||||
# Known cluster actions only — do not steal collection POSTs.
|
||||
return await respond_action(request, conn, description=f"Cluster {parts[2]}")
|
||||
from app.ovirt.schema_engine import handle_subcollection
|
||||
|
||||
if len(parts) >= 3:
|
||||
@@ -1694,12 +1854,27 @@ async def _handle_templates(
|
||||
tid = uuid4()
|
||||
vm = body.get("vm") or {}
|
||||
cluster_id = None
|
||||
source_vm = None
|
||||
if isinstance(vm, dict) and vm.get("id"):
|
||||
cluster_id = await conn.fetchval(
|
||||
"SELECT cluster_id FROM ov_vms WHERE id=$1::uuid", vm["id"]
|
||||
)
|
||||
source_vm = await conn.fetchrow("SELECT * FROM ov_vms WHERE id=$1::uuid", vm["id"])
|
||||
if source_vm is None:
|
||||
raise OVirtError("NotFound", f"VM {vm['id']} not found", status_code=404)
|
||||
cluster_id = source_vm["cluster_id"]
|
||||
if not cluster_id:
|
||||
cref = body.get("cluster") or {}
|
||||
if isinstance(cref, dict) and cref.get("id"):
|
||||
cluster_id = cref["id"]
|
||||
elif isinstance(cref, dict) and cref.get("name"):
|
||||
cluster_id = await conn.fetchval(
|
||||
"SELECT id FROM ov_clusters WHERE name=$1", cref["name"]
|
||||
)
|
||||
if not cluster_id:
|
||||
cluster_id = await conn.fetchval("SELECT id FROM ov_clusters LIMIT 1")
|
||||
memory = int(
|
||||
body.get("memory")
|
||||
or (source_vm["memory"] if source_vm else None)
|
||||
or await option_int(conn, OPT_DEFAULT_VM_MEMORY)
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, memory)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
@@ -1707,8 +1882,13 @@ async def _handle_templates(
|
||||
cluster_id,
|
||||
str(body.get("name") or f"tpl-{tid.hex[:6]}"),
|
||||
str(body.get("description") or ""),
|
||||
int(body.get("memory") or await option_int(conn, OPT_DEFAULT_VM_MEMORY)),
|
||||
memory,
|
||||
)
|
||||
if source_vm is not None:
|
||||
await _seed_template_from_vm(conn, template_id=str(tid), vm_id=str(source_vm["id"]))
|
||||
await create_job(
|
||||
conn, description=f"Add Template {body.get('name') or tid}", owner_id=None
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_templates WHERE id=$1", tid)
|
||||
return respond(request, element="template", data=template_entity(row), status_code=201)
|
||||
tid = parts[1]
|
||||
@@ -2034,20 +2214,42 @@ async def _handle_jobs(
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "job not found", status_code=404)
|
||||
return respond(request, element="job", data=job_entity(r))
|
||||
if len(parts) == 3 and parts[2] == "steps" and method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_job_steps WHERE job_id=$1::uuid ORDER BY number", parts[1]
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"description": r["description"],
|
||||
"status": r["status"],
|
||||
"type": r["type"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="step", collection="steps", data=items)
|
||||
if len(parts) >= 3 and parts[2] == "steps":
|
||||
job_id = parts[1]
|
||||
if len(parts) == 3 and method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_job_steps WHERE job_id=$1::uuid ORDER BY number", job_id
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/jobs/{job_id}/steps/{r['id']}",
|
||||
"description": r["description"],
|
||||
"status": r["status"],
|
||||
"type": r["type"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="step", collection="steps", data=items)
|
||||
if len(parts) == 4 and method == "GET":
|
||||
r = await conn.fetchrow(
|
||||
"SELECT * FROM ov_job_steps WHERE id=$1::uuid AND job_id=$2::uuid",
|
||||
parts[3],
|
||||
job_id,
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "step not found", status_code=404)
|
||||
return respond(
|
||||
request,
|
||||
element="step",
|
||||
data={
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/jobs/{job_id}/steps/{r['id']}",
|
||||
"description": r["description"],
|
||||
"status": r["status"],
|
||||
"type": r["type"],
|
||||
},
|
||||
)
|
||||
raise OVirtError("NotFound", "jobs path", status_code=404)
|
||||
|
||||
|
||||
@@ -2055,7 +2257,7 @@ async def _handle_events(
|
||||
request: Request, conn: Connection, method: str, parts: list[str]
|
||||
) -> Response:
|
||||
if len(parts) == 1 and method == "GET":
|
||||
max_r = int(request.query_params.get("max") or 100)
|
||||
max_r = int(request.query_params.get("max") or 500)
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_events ORDER BY id DESC LIMIT $1", max_r
|
||||
)
|
||||
@@ -2080,20 +2282,27 @@ async def _handle_events(
|
||||
element="event",
|
||||
data={
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/events/{r['id']}",
|
||||
"code": r["code"],
|
||||
"severity": r["severity"],
|
||||
"description": r["description"],
|
||||
"time": r["time"].strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
)
|
||||
raise OVirtError("NotFound", "events path", status_code=404)
|
||||
|
||||
|
||||
def _row_entity(r: Any, collection: str, fields: list[str]) -> dict[str, Any]:
|
||||
item = {"id": str(r["id"]), "href": f"/ovirt-engine/api/{collection}/{r['id']}"}
|
||||
for f in fields:
|
||||
if f in r.keys():
|
||||
item[f] = r[f]
|
||||
return item
|
||||
def _quota_entity(r: Any, dc_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/datacenters/{dc_id}/quotas/{r['id']}",
|
||||
"name": r["name"],
|
||||
"description": r["description"] or "",
|
||||
"data_center": {
|
||||
"id": str(dc_id),
|
||||
"href": f"/ovirt-engine/api/datacenters/{dc_id}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _affinity_group_entity(r: Any, cluster_id: str) -> dict[str, Any]:
|
||||
@@ -2104,9 +2313,176 @@ def _affinity_group_entity(r: Any, cluster_id: str) -> dict[str, Any]:
|
||||
"description": r["description"] or "",
|
||||
"enforcing": bool(r["enforcing"]),
|
||||
"positive": bool(r["positive"]),
|
||||
"cluster": {
|
||||
"id": str(cluster_id),
|
||||
"href": f"/ovirt-engine/api/clusters/{cluster_id}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _handle_top_affinity_groups(
|
||||
request: Request, conn: Connection, method: str, parts: list[str]
|
||||
) -> Response:
|
||||
if len(parts) == 1 and method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_affinity_groups ORDER BY name"
|
||||
)
|
||||
items = [_affinity_group_entity(r, str(r["cluster_id"])) for r in rows]
|
||||
return respond(
|
||||
request, element="affinity_group", collection="affinity_groups", data=items
|
||||
)
|
||||
if len(parts) == 2 and method == "GET":
|
||||
r = await conn.fetchrow("SELECT * FROM ov_affinity_groups WHERE id=$1::uuid", parts[1])
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "affinity group not found", status_code=404)
|
||||
return respond(
|
||||
request, element="affinity_group", data=_affinity_group_entity(r, str(r["cluster_id"]))
|
||||
)
|
||||
raise OVirtError("NotFound", "affinitygroups path", status_code=404)
|
||||
|
||||
|
||||
async def _handle_top_quotas(
|
||||
request: Request, conn: Connection, method: str, parts: list[str]
|
||||
) -> Response:
|
||||
if len(parts) == 1 and method == "GET":
|
||||
rows = await conn.fetch("SELECT * FROM ov_quotas ORDER BY name")
|
||||
items = [_quota_entity(r, str(r["datacenter_id"])) for r in rows]
|
||||
return respond(request, element="quota", collection="quotas", data=items)
|
||||
if len(parts) == 2 and method == "GET":
|
||||
r = await conn.fetchrow("SELECT * FROM ov_quotas WHERE id=$1::uuid", parts[1])
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "quota not found", status_code=404)
|
||||
return respond(
|
||||
request, element="quota", data=_quota_entity(r, str(r["datacenter_id"]))
|
||||
)
|
||||
raise OVirtError("NotFound", "quotas path", status_code=404)
|
||||
|
||||
|
||||
async def _copy_vm_storage_and_nics(
|
||||
conn: Connection, *, source_vm_id: str, target_vm_id: str
|
||||
) -> None:
|
||||
"""Clone disk attachments (new disk rows) and NICs onto a target VM."""
|
||||
|
||||
attachments = await conn.fetch(
|
||||
"""SELECT a.*, d.name AS disk_name, d.provisioned_size, d.actual_size, d.format,
|
||||
d.sparse, d.storage_domain_id, d.description AS disk_description
|
||||
FROM ov_disk_attachments a
|
||||
JOIN ov_disks d ON d.id=a.disk_id
|
||||
WHERE a.vm_id=$1::uuid""",
|
||||
source_vm_id,
|
||||
)
|
||||
for att in attachments:
|
||||
new_disk_id = uuid4()
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_disks(id, name, description, provisioned_size, actual_size,
|
||||
format, sparse, storage_domain_id)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8)""",
|
||||
new_disk_id,
|
||||
f"{att['disk_name']}-clone" if att["disk_name"] else f"disk-{new_disk_id.hex[:8]}",
|
||||
att["disk_description"] or "",
|
||||
att["provisioned_size"],
|
||||
att["actual_size"],
|
||||
att["format"],
|
||||
att["sparse"],
|
||||
att["storage_domain_id"],
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, active, bootable, interface)
|
||||
VALUES($1,$2::uuid,$3,$4,$5,$6)""",
|
||||
uuid4(),
|
||||
target_vm_id,
|
||||
new_disk_id,
|
||||
bool(att["active"]),
|
||||
bool(att["bootable"]),
|
||||
att["interface"],
|
||||
)
|
||||
nics = await conn.fetch("SELECT * FROM ov_nics WHERE vm_id=$1::uuid", source_vm_id)
|
||||
for nic in nics:
|
||||
new_nic_id = uuid4()
|
||||
mac_suffix = ":".join(f"{(new_nic_id.int >> (8 * i)) & 0xFF:02x}" for i in range(3))
|
||||
mac = (nic["mac_address"] or "00:1a:4a:00:00:00")[:9] + mac_suffix
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_nics(id, vm_id, name, interface, linked, plugged, mac_address, vnic_profile_id)
|
||||
VALUES($1,$2::uuid,$3,$4,$5,$6,$7,$8)""",
|
||||
new_nic_id,
|
||||
target_vm_id,
|
||||
nic["name"],
|
||||
nic["interface"],
|
||||
bool(nic["linked"]),
|
||||
bool(nic["plugged"]),
|
||||
mac,
|
||||
nic["vnic_profile_id"],
|
||||
)
|
||||
|
||||
|
||||
async def _seed_template_from_vm(
|
||||
conn: Connection, *, template_id: str, vm_id: str
|
||||
) -> None:
|
||||
"""Materialize template nested nics/diskattachments from a source VM."""
|
||||
|
||||
import json as _json
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
|
||||
nics = await conn.fetch("SELECT * FROM ov_nics WHERE vm_id=$1::uuid ORDER BY name", vm_id)
|
||||
for nic in nics:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(
|
||||
id, collection, name, status, parent_collection, parent_id, data
|
||||
) VALUES($1,'nics',$2,'ok','templates',$3::uuid,$4::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
stable_id("nested", "templates", template_id, "nics", nic["name"]),
|
||||
nic["name"],
|
||||
template_id,
|
||||
_json.dumps(
|
||||
{
|
||||
"name": nic["name"],
|
||||
"interface": nic["interface"],
|
||||
"vnic_profile": (
|
||||
{"id": str(nic["vnic_profile_id"])} if nic["vnic_profile_id"] else None
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
attachments = await conn.fetch(
|
||||
"""SELECT a.*, d.name AS disk_name, d.provisioned_size, d.format
|
||||
FROM ov_disk_attachments a JOIN ov_disks d ON d.id=a.disk_id
|
||||
WHERE a.vm_id=$1::uuid ORDER BY d.name""",
|
||||
vm_id,
|
||||
)
|
||||
for att in attachments:
|
||||
name = att["disk_name"] or f"disk-{att['disk_id']}"
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(
|
||||
id, collection, name, status, parent_collection, parent_id, data
|
||||
) VALUES($1,'diskattachments',$2,'ok','templates',$3::uuid,$4::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
stable_id("nested", "templates", template_id, "diskattachments", name),
|
||||
name,
|
||||
template_id,
|
||||
_json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"bootable": bool(att["bootable"]),
|
||||
"interface": att["interface"],
|
||||
"disk": {
|
||||
"name": name,
|
||||
"provisioned_size": att["provisioned_size"],
|
||||
"format": att["format"],
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _row_entity(r: Any, collection: str, fields: list[str]) -> dict[str, Any]:
|
||||
item = {"id": str(r["id"]), "href": f"/ovirt-engine/api/{collection}/{r['id']}"}
|
||||
for f in fields:
|
||||
if f in r.keys():
|
||||
item[f] = r[f]
|
||||
return item
|
||||
|
||||
|
||||
def _vnic_profile_entity(r: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(r["id"]),
|
||||
|
||||
+93
-39
@@ -41,6 +41,7 @@ _COLLECTIONS: dict[str, tuple[str, str]] = {
|
||||
"networklabels": ("network_label", "ok"),
|
||||
"cpuprofiles": ("cpu_profile", "ok"),
|
||||
"diskprofiles": ("disk_profile", "ok"),
|
||||
"diskattachments": ("disk_attachment", "ok"),
|
||||
"qoss": ("qos", "ok"),
|
||||
"iscsibonds": ("iscsi_bond", "ok"),
|
||||
"glustervolumes": ("gluster_volume", "ok"),
|
||||
@@ -65,6 +66,7 @@ _COLLECTIONS: dict[str, tuple[str, str]] = {
|
||||
"devices": ("host_device", "ok"),
|
||||
"sshpublickeys": ("ssh_public_key", "ok"),
|
||||
"networkfilterparameters": ("network_filter_parameter", "ok"),
|
||||
"storage": ("host_storage", "ok"),
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +89,10 @@ async def handle_generic(
|
||||
if len(parts) == 1:
|
||||
if method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_api_objects WHERE collection=$1 ORDER BY name", collection
|
||||
"""SELECT * FROM ov_api_objects
|
||||
WHERE collection=$1 AND parent_id IS NULL
|
||||
ORDER BY name""",
|
||||
collection,
|
||||
)
|
||||
items = [generic_entity(collection, element, r) for r in rows]
|
||||
return respond(request, element=element, collection=collection, data=items)
|
||||
@@ -111,7 +116,10 @@ async def handle_generic(
|
||||
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
|
||||
"""SELECT * FROM ov_api_objects
|
||||
WHERE id=$1::uuid AND collection=$2 AND parent_id IS NULL""",
|
||||
oid,
|
||||
collection,
|
||||
)
|
||||
if method == "GET":
|
||||
if row is None:
|
||||
@@ -134,7 +142,10 @@ async def handle_generic(
|
||||
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
|
||||
"""DELETE FROM ov_api_objects
|
||||
WHERE id=$1::uuid AND collection=$2 AND parent_id IS NULL""",
|
||||
oid,
|
||||
collection,
|
||||
)
|
||||
return Response(status_code=200)
|
||||
if len(parts) == 3 and method == "POST":
|
||||
@@ -181,20 +192,11 @@ async def handle_subcollection(
|
||||
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":
|
||||
if sub == "permissions" and method == "GET":
|
||||
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 = [
|
||||
{
|
||||
|
||||
def _perm_item(r: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"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"]},
|
||||
@@ -205,30 +207,82 @@ async def handle_subcollection(
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="permission", collection="permissions", data=items)
|
||||
if not rest and method == "GET" and sub == "tags":
|
||||
|
||||
if not rest:
|
||||
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,
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
element="permission",
|
||||
collection="permissions",
|
||||
data=[_perm_item(r) for r in rows],
|
||||
)
|
||||
if len(rest) == 1:
|
||||
r = await conn.fetchrow(
|
||||
"""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.id=$1::uuid AND p.object_type=$2 AND p.object_id=$3::uuid""",
|
||||
rest[0],
|
||||
object_type,
|
||||
parent_id,
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "permission not found", status_code=404)
|
||||
return respond(request, element="permission", data=_perm_item(r))
|
||||
if sub == "tags" and method == "GET":
|
||||
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:
|
||||
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 len(rest) == 1:
|
||||
r = await conn.fetchrow(
|
||||
"""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 AND t.id=$3::uuid""",
|
||||
object_type,
|
||||
parent_id,
|
||||
rest[0],
|
||||
)
|
||||
if r is None:
|
||||
raise OVirtError("NotFound", "tag not found", status_code=404)
|
||||
return respond(
|
||||
request,
|
||||
element="tag",
|
||||
data={
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/tags/{r['id']}",
|
||||
"name": r["name"],
|
||||
"description": r["description"] or "",
|
||||
},
|
||||
)
|
||||
if not rest:
|
||||
if method == "GET":
|
||||
rows = await conn.fetch(
|
||||
|
||||
+17
-4
@@ -11,7 +11,8 @@ from app.ovirt.ids import stable_id
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
MINIMAL_PROFILE = "minimal"
|
||||
DEMO_PROFILE = "demo"
|
||||
# Legacy name kept for imports; sized demos live in demo_datacenter.DEMO_PROFILES.
|
||||
DEMO_PROFILE = "large"
|
||||
|
||||
|
||||
async def clear_ovirt_state(conn: Connection) -> None:
|
||||
@@ -239,6 +240,8 @@ async def seed_ovirt(conn: Connection) -> dict[str, Any]:
|
||||
("instancetypes", "Large"),
|
||||
("macpools", "Default"),
|
||||
("schedulingpolicies", "evenly_distributed"),
|
||||
("schedulingpolicies", "power_saving"),
|
||||
("schedulingpolicies", "vm_evenly_distributed"),
|
||||
("schedulingpolicyunits", "EvenlyDistributed"),
|
||||
("clusterlevels", "4.5"),
|
||||
("icons", "default"),
|
||||
@@ -255,7 +258,8 @@ async def seed_ovirt(conn: Connection) -> dict[str, Any]:
|
||||
):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,'ok',$4::jsonb)""",
|
||||
VALUES($1,$2,$3,'ok',$4::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
stable_id("obj", collection, name),
|
||||
collection,
|
||||
name,
|
||||
@@ -330,10 +334,19 @@ async def seed_ovirt(conn: Connection) -> dict[str, Any]:
|
||||
|
||||
|
||||
async def ovirt_demo_summary(conn: Connection) -> dict[str, Any]:
|
||||
from app.ovirt.demo_datacenter import CLUSTER_SIZES, DEMO_PROFILES
|
||||
|
||||
profile = await conn.fetchval("SELECT value FROM ov_demo_meta WHERE key='profile'")
|
||||
active = profile or MINIMAL_PROFILE
|
||||
size = CLUSTER_SIZES.get(active) or (
|
||||
CLUSTER_SIZES["large"] if active == "demo" else None
|
||||
)
|
||||
return {
|
||||
"profile": profile or MINIMAL_PROFILE,
|
||||
"loaded": profile == DEMO_PROFILE,
|
||||
"profile": active,
|
||||
"loaded": active in DEMO_PROFILES,
|
||||
"size": size.name if size else None,
|
||||
"size_hosts": size.hosts if size else None,
|
||||
"size_vms": size.vms if size else None,
|
||||
"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,
|
||||
|
||||
+14
-6
@@ -1,4 +1,4 @@
|
||||
"""CLI: python -m app.ovirt.seed_cli [--profile minimal|demo]."""
|
||||
"""CLI: python -m app.ovirt.seed_cli [--profile minimal|small|large|big|demo]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.ovirt.demo_datacenter import seed_ovirt_demo
|
||||
from app.ovirt.demo_datacenter import DEMO_PROFILES, normalize_cluster_size, seed_ovirt_demo
|
||||
from app.ovirt.seed import seed_ovirt
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ async def _run(profile: str) -> dict:
|
||||
)
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
if profile == "demo":
|
||||
result = await seed_ovirt_demo(conn)
|
||||
else:
|
||||
if profile == "minimal":
|
||||
result = await seed_ovirt(conn)
|
||||
elif profile in DEMO_PROFILES or profile in {"small", "large", "big"}:
|
||||
result = await seed_ovirt_demo(conn, size=normalize_cluster_size(profile))
|
||||
else:
|
||||
raise SystemExit(
|
||||
f"unknown profile {profile!r}; expected minimal|small|large|big|demo"
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
await conn.close()
|
||||
@@ -31,7 +35,11 @@ async def _run(profile: str) -> dict:
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Seed oVirt Engine simulator")
|
||||
parser.add_argument("--profile", default=os.environ.get("SEED_PROFILE", "minimal"))
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
default=os.environ.get("SEED_PROFILE", "minimal"),
|
||||
help="minimal | small | large | big | demo (demo→large)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(_run(args.profile))
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
@@ -127,6 +127,19 @@ async def seed_nested_for_inventory(
|
||||
data={"description": "Gluster feature"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="clusters",
|
||||
parent_id=cluster_id,
|
||||
collection="glustervolumes",
|
||||
name="gv0",
|
||||
data={
|
||||
"volume_type": "distribute",
|
||||
"replica_count": 1,
|
||||
"status": "up",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for host_id in host_ids:
|
||||
perm("host", host_id, f"host-{host_id}")
|
||||
@@ -184,6 +197,24 @@ async def seed_nested_for_inventory(
|
||||
data={"event_name": "before_vm_start"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="storage",
|
||||
name="local-data",
|
||||
data={"type": "data", "path": "/var/lib/ovirt/storage", "status": "up"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="katelloerrata",
|
||||
name="RHSA-2024:0001",
|
||||
data={"title": "Important: kernel security update"},
|
||||
)
|
||||
)
|
||||
if tag_ids:
|
||||
tag_rows.append(
|
||||
(
|
||||
@@ -196,6 +227,15 @@ async def seed_nested_for_inventory(
|
||||
|
||||
for net_id in network_ids:
|
||||
perm("network", net_id, f"net-{net_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="networks",
|
||||
parent_id=net_id,
|
||||
collection="networklabels",
|
||||
name="ovirtmgmt",
|
||||
data={"description": "Management network label"},
|
||||
)
|
||||
)
|
||||
|
||||
for sd_id in storage_domain_ids:
|
||||
perm("storage_domain", sd_id, f"sd-{sd_id}")
|
||||
@@ -265,6 +305,24 @@ async def seed_nested_for_inventory(
|
||||
data={"file": {"id": "rhel-9.iso"}},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="graphicsconsoles",
|
||||
name="spice",
|
||||
data={"protocol": "spice"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="watchdogs",
|
||||
name="i6300esb",
|
||||
data={"model": "i6300esb", "action": "reset"},
|
||||
)
|
||||
)
|
||||
|
||||
for disk_id in disk_ids:
|
||||
perm("disk", disk_id, f"disk-{disk_id}")
|
||||
@@ -376,9 +434,43 @@ async def seed_nested_for_inventory(
|
||||
name="pci_0000_00_02_0",
|
||||
data={"capability": "pci"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="mediateddevices",
|
||||
name="mdev0",
|
||||
data={"spec_params": {"mdev_type": "nvidia-11"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="affinitylabels",
|
||||
name="label-a",
|
||||
data={"description": "VM affinity label"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="katelloerrata",
|
||||
name="RHSA-2024:0001",
|
||||
data={"title": "Important: qemu-kvm security update"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
for user_id in user_ids:
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="users",
|
||||
parent_id=user_id,
|
||||
collection="sshpublickeys",
|
||||
name="lab-key",
|
||||
data={
|
||||
"content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILabSeedKey admin@lab",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Scheduling policy children + role permits
|
||||
for sp_name in ("evenly_distributed", "power_saving", "vm_evenly_distributed"):
|
||||
sp_id = await conn.fetchval(
|
||||
|
||||
+300
-161
@@ -584,6 +584,124 @@
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.demo-size-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.demo-size-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-size-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(165deg, color-mix(in srgb, var(--brand-accent) 8%, var(--surface-raised)) 0%, var(--surface-raised) 55%);
|
||||
min-height: 168px;
|
||||
}
|
||||
|
||||
.demo-size-card.is-active {
|
||||
border-color: color-mix(in srgb, var(--brand-accent) 55%, var(--border));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--brand-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.demo-size-card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-size-card-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.demo-size-card-chip {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-accent);
|
||||
background: color-mix(in srgb, var(--brand-accent) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-size-card-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.demo-size-card-metrics div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.demo-size-card-metrics dt {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.demo-size-card-metrics dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.demo-size-card-note {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.demo-size-card .btn-demo-load {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.demo-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.demo-toolbar .btn {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-demo-load {
|
||||
background: var(--brand-accent);
|
||||
border-color: var(--brand-accent);
|
||||
@@ -3420,34 +3538,9 @@
|
||||
<span class="value" id="stat-hosts">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="help-section-title">oVirt Engine API pack</h4>
|
||||
<div class="help-stat-grid">
|
||||
<div class="help-stat-card">
|
||||
<span class="label">Series</span>
|
||||
<span class="value" id="stat-os-series">—</span>
|
||||
</div>
|
||||
<div class="help-stat-card">
|
||||
<span class="label">Operations</span>
|
||||
<span class="value" id="stat-os-ops">—</span>
|
||||
</div>
|
||||
<div class="help-stat-card">
|
||||
<span class="label">Services</span>
|
||||
<span class="value" id="stat-os-services">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="field-label" for="os-series-select">Activate series</label>
|
||||
<div class="help-demo-actions" style="margin-top:0.5rem;gap:0.5rem;display:flex;flex-wrap:wrap;align-items:center;">
|
||||
<select id="os-series-select" class="input" style="min-width:10rem;"></select>
|
||||
<button class="btn btn-sm" id="btn-os-series-activate" type="button">Apply pack</button>
|
||||
</div>
|
||||
<label class="field-label" for="os-mv-service" style="margin-top:0.75rem;">Microversion override</label>
|
||||
<div class="help-demo-actions" style="margin-top:0.5rem;gap:0.5rem;display:flex;flex-wrap:wrap;align-items:center;">
|
||||
<select id="os-mv-service" class="input" style="min-width:8rem;"></select>
|
||||
<input id="os-mv-version" class="input" placeholder="e.g. 2.79" style="width:6rem;" />
|
||||
<button class="btn btn-sm" id="btn-os-mv-set" type="button">Set</button>
|
||||
<button class="btn btn-sm" id="btn-os-mv-clear" type="button">Default</button>
|
||||
</div>
|
||||
<p class="help-demo-note" id="os-pack-note" style="margin-top:0.75rem;">Surface-complete contract packs drive schema routes for every API-ref operation.</p>
|
||||
<p class="help-demo-note" style="margin-top:0.75rem;">
|
||||
Engine series packs are switched in <strong>API catalog</strong> → <strong>Apply as runtime</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -3866,16 +3959,6 @@
|
||||
statStorage: document.getElementById("stat-storage"),
|
||||
statHosts: document.getElementById("stat-hosts"),
|
||||
statImpl: document.getElementById("stat-impl"),
|
||||
statOsSeries: document.getElementById("stat-os-series"),
|
||||
statOsOps: document.getElementById("stat-os-ops"),
|
||||
statOsServices: document.getElementById("stat-os-services"),
|
||||
osSeriesSelect: document.getElementById("os-series-select"),
|
||||
btnOsSeriesActivate: document.getElementById("btn-os-series-activate"),
|
||||
osMvService: document.getElementById("os-mv-service"),
|
||||
osMvVersion: document.getElementById("os-mv-version"),
|
||||
btnOsMvSet: document.getElementById("btn-os-mv-set"),
|
||||
btnOsMvClear: document.getElementById("btn-os-mv-clear"),
|
||||
osPackNote: document.getElementById("os-pack-note"),
|
||||
};
|
||||
|
||||
function setText(el, value) {
|
||||
@@ -4848,6 +4931,15 @@
|
||||
if (hasStoredBody) {
|
||||
els.body.value = isEmptyRequestBody(storedBody) ? "" : storedBody;
|
||||
updateBodyHighlight();
|
||||
} else if (
|
||||
method.body_example
|
||||
&& typeof method.body_example === "object"
|
||||
&& !Array.isArray(method.body_example)
|
||||
&& Object.keys(method.body_example).length > 0
|
||||
) {
|
||||
// Prefer full Engine-shaped body_example over PARAM field stubs.
|
||||
els.body.value = JSON.stringify(method.body_example, null, 2);
|
||||
updateBodyHighlight();
|
||||
} else if (canBuildBody) {
|
||||
syncBodyFromFields({ syncVisibility: false });
|
||||
} else {
|
||||
@@ -5198,11 +5290,45 @@
|
||||
|
||||
function buildDemoDataHtml(data) {
|
||||
const loaded = Boolean(data?.loaded);
|
||||
const profile = data?.profile ?? "minimal";
|
||||
const badgeLabel = loaded ? String(profile) : "Minimal";
|
||||
const sizes = [
|
||||
{
|
||||
id: "small",
|
||||
label: "Small",
|
||||
chip: "lab",
|
||||
hosts: 3,
|
||||
vms: 50,
|
||||
dc: 1,
|
||||
clusters: 1,
|
||||
note: "1 DC · 2 networks · 2 storage · light tags/events",
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
label: "Large",
|
||||
chip: "default",
|
||||
hosts: 10,
|
||||
vms: 1000,
|
||||
dc: 2,
|
||||
clusters: 2,
|
||||
note: "2 DC · 6 networks · 6 storage · fuller inventory",
|
||||
},
|
||||
{
|
||||
id: "big",
|
||||
label: "Big",
|
||||
chip: "heavy",
|
||||
hosts: 30,
|
||||
vms: 2000,
|
||||
dc: 3,
|
||||
clusters: 6,
|
||||
note: "3 DC · 9 networks · 12 storage · heavy data",
|
||||
},
|
||||
];
|
||||
const stats = [
|
||||
["Profile", data?.profile ?? "—", loaded ? "ok" : ""],
|
||||
["Profile", profile, loaded ? "ok" : ""],
|
||||
["VMs", data?.vms ?? "—", ""],
|
||||
["Hosts", data?.hosts ?? "—", ""],
|
||||
["Data Centers", data?.datacenters ?? "—", ""],
|
||||
["DC", data?.datacenters ?? "—", ""],
|
||||
["Clusters", data?.clusters ?? "—", ""],
|
||||
["Networks", data?.networks ?? "—", ""],
|
||||
["Disks", data?.disks ?? "—", ""],
|
||||
@@ -5216,11 +5342,10 @@
|
||||
<div class="help-demo-panel">
|
||||
<div class="help-report-head help-demo-head">
|
||||
<h3>oVirt demo datacenter</h3>
|
||||
<p>Load a synthetic full oVirt inventory into PostgreSQL for client and UI exploration.</p>
|
||||
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${loaded ? "Loaded" : "Minimal"}</span>
|
||||
<p>Pick a cluster size — inventory (DC, hosts, VMs, storage, networks, tags, events, jobs) scales together.</p>
|
||||
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${escapeHtml(badgeLabel)}</span>
|
||||
</div>
|
||||
<p class="help-demo-note">
|
||||
~1000 VMs · 3 Data Centers · 6 Clusters · 24 Hosts · Storage Domains · Networks · Templates · Users/Roles.
|
||||
Password for all users: <code>secret</code>. Loading replaces current oVirt state and invalidates active tokens.
|
||||
</p>
|
||||
<div class="help-stat-grid">
|
||||
@@ -5231,8 +5356,29 @@
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="help-demo-actions">
|
||||
<button class="btn btn-sm btn-demo-load" id="btn-demo-load" type="button">Load demo datacenter</button>
|
||||
<div class="demo-size-grid" role="group" aria-label="Cluster sizes">
|
||||
${sizes.map((s) => {
|
||||
const active = profile === s.id;
|
||||
return `
|
||||
<article class="demo-size-card ${active ? "is-active" : ""}" data-size-card="${s.id}">
|
||||
<div class="demo-size-card-head">
|
||||
<h4 class="demo-size-card-title">${escapeHtml(s.label)}</h4>
|
||||
<span class="demo-size-card-chip">${escapeHtml(s.chip)}</span>
|
||||
</div>
|
||||
<dl class="demo-size-card-metrics">
|
||||
<div><dt>Hosts</dt><dd>${s.hosts}</dd></div>
|
||||
<div><dt>VMs</dt><dd>${s.vms}</dd></div>
|
||||
<div><dt>DC</dt><dd>${s.dc}</dd></div>
|
||||
<div><dt>Clusters</dt><dd>${s.clusters}</dd></div>
|
||||
</dl>
|
||||
<p class="demo-size-card-note">${escapeHtml(s.note)}</p>
|
||||
<button class="btn btn-sm btn-demo-load" data-demo-size="${s.id}" type="button">
|
||||
Load ${escapeHtml(s.label.toLowerCase())}
|
||||
</button>
|
||||
</article>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<div class="demo-toolbar">
|
||||
<button class="btn btn-sm btn-demo-unload" id="btn-demo-unload" type="button">Reset to minimal</button>
|
||||
<button class="btn btn-sm" id="btn-demo-refresh" type="button">Refresh stats</button>
|
||||
</div>
|
||||
@@ -5271,12 +5417,12 @@
|
||||
renderDemoState(await res.json());
|
||||
}
|
||||
|
||||
async function loadDemoData() {
|
||||
const loadBtn = document.getElementById("btn-demo-load");
|
||||
if (loadBtn) loadBtn.disabled = true;
|
||||
async function loadDemoData(size = "large") {
|
||||
const loadBtns = document.querySelectorAll("[data-demo-size]");
|
||||
loadBtns.forEach((btn) => { btn.disabled = true; });
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/ui/api/demo/load", { method: "POST" });
|
||||
const res = await fetch(`/ui/api/demo/load?size=${encodeURIComponent(size)}`, { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const detail = Array.isArray(body.detail)
|
||||
@@ -5293,20 +5439,24 @@
|
||||
persistAuth();
|
||||
}
|
||||
await refreshOverview();
|
||||
toast("oVirt demo datacenter loaded (~1000 VMs)", "ok");
|
||||
const seed = body.seed || body.summary || {};
|
||||
toast(
|
||||
`Cluster ${seed.profile || size} loaded (${seed.hosts ?? "?"} hosts · ${seed.vms ?? "?"} VMs)`,
|
||||
"ok",
|
||||
);
|
||||
toast("Sign in again (admin@internal / secret) — tokens were reset with the seed", "warn");
|
||||
} finally {
|
||||
if (loadBtn) loadBtn.disabled = false;
|
||||
loadBtns.forEach((btn) => { btn.disabled = false; });
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadDemoData() {
|
||||
const confirmed = await showConfirm({
|
||||
title: "Remove demo data?",
|
||||
message: "This wipes simulator DB state (demo data and anything created via the API), then reloads a minimal cluster.",
|
||||
confirmLabel: "Remove demo data",
|
||||
cancelLabel: "Keep demo data",
|
||||
title: "Reset to minimal?",
|
||||
message: "This wipes current simulator DB state (sized demo and anything created via the API), then loads the minimal cluster (1 host · 1 VM).",
|
||||
confirmLabel: "Reset to minimal",
|
||||
cancelLabel: "Keep current data",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
@@ -5327,7 +5477,7 @@
|
||||
persistAuth();
|
||||
}
|
||||
await refreshOverview();
|
||||
toast("Demo data removed — sign in again if needed", "info");
|
||||
toast("Minimal cluster loaded — sign in again if needed", "info");
|
||||
} finally {
|
||||
if (unloadBtn) unloadBtn.disabled = false;
|
||||
setLoading(false);
|
||||
@@ -5704,14 +5854,18 @@
|
||||
if (!els.dataPanel || els.dataPanel.dataset.demoBound) return;
|
||||
els.dataPanel.dataset.demoBound = "1";
|
||||
els.dataPanel.addEventListener("click", (event) => {
|
||||
const target = event.target.closest("#btn-demo-load, #btn-demo-unload, #btn-demo-refresh");
|
||||
if (!target) return;
|
||||
if (target.id === "btn-demo-load") {
|
||||
loadDemoData().catch((error) => {
|
||||
const loadBtn = event.target.closest("[data-demo-size]");
|
||||
if (loadBtn) {
|
||||
const size = loadBtn.getAttribute("data-demo-size") || "large";
|
||||
loadDemoData(size).catch((error) => {
|
||||
showError(String(error));
|
||||
toast("Failed to load demo data", "error");
|
||||
});
|
||||
} else if (target.id === "btn-demo-unload") {
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest("#btn-demo-unload, #btn-demo-refresh");
|
||||
if (!target) return;
|
||||
if (target.id === "btn-demo-unload") {
|
||||
unloadDemoData().catch((error) => {
|
||||
showError(String(error));
|
||||
toast("Failed to remove demo data", "error");
|
||||
@@ -6421,9 +6575,11 @@
|
||||
try {
|
||||
const parsed = JSON.parse(body || "{}");
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const { inner } = unwrapBodyExample(parsed);
|
||||
for (const field of bodyFields) {
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, field.name)) {
|
||||
state.bodyValues[field.name] = String(parsed[field.name] ?? "");
|
||||
const value = getByPath(inner, field.name);
|
||||
if (value !== undefined && value !== null) {
|
||||
state.bodyValues[field.name] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6496,31 +6652,95 @@
|
||||
return target === "cluster" ? buildClusterUrl(apiPath) : buildEmulatorUrl(apiPath);
|
||||
}
|
||||
|
||||
function deepCloneJson(value) {
|
||||
if (value == null) return value;
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function getByPath(root, path) {
|
||||
if (!path) return root;
|
||||
const parts = String(path).split(".");
|
||||
let cur = root;
|
||||
for (const part of parts) {
|
||||
if (cur == null || typeof cur !== "object") return undefined;
|
||||
cur = cur[part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function setByPath(root, path, value) {
|
||||
const parts = String(path).split(".");
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const part = parts[i];
|
||||
const next = parts[i + 1];
|
||||
const wantArray = /^\d+$/.test(next);
|
||||
if (cur[part] == null || typeof cur[part] !== "object") {
|
||||
cur[part] = wantArray ? [] : {};
|
||||
}
|
||||
cur = cur[part];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function deleteByPath(root, path) {
|
||||
const parts = String(path).split(".");
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
if (cur == null || typeof cur !== "object") return;
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
if (cur && typeof cur === "object") delete cur[parts[parts.length - 1]];
|
||||
}
|
||||
|
||||
function unwrapBodyExample(example) {
|
||||
if (!example || typeof example !== "object" || Array.isArray(example)) {
|
||||
return { wrapKey: null, inner: {} };
|
||||
}
|
||||
const keys = Object.keys(example);
|
||||
if (
|
||||
keys.length === 1
|
||||
&& example[keys[0]]
|
||||
&& typeof example[keys[0]] === "object"
|
||||
&& !Array.isArray(example[keys[0]])
|
||||
) {
|
||||
return { wrapKey: keys[0], inner: example[keys[0]] };
|
||||
}
|
||||
return { wrapKey: null, inner: example };
|
||||
}
|
||||
|
||||
function syncBodyFromFields({ syncVisibility = true } = {}) {
|
||||
if (!state.method) return;
|
||||
const inputs = [...(els.bodyFields?.querySelectorAll("input[data-field]") || [])];
|
||||
const body = {};
|
||||
const example = state.method.body_example;
|
||||
const { wrapKey, inner } = unwrapBodyExample(example);
|
||||
const root = deepCloneJson(inner) || {};
|
||||
let touched = false;
|
||||
inputs.forEach((input) => {
|
||||
const name = input.dataset.field;
|
||||
if (!name) return;
|
||||
const raw = input.value.trim();
|
||||
if (!raw) return;
|
||||
if (raw === "true" || raw === "false") body[name] = raw === "true";
|
||||
else if (/^-?\d+$/.test(raw)) body[name] = Number(raw);
|
||||
else if (/^-?\d+\.\d+$/.test(raw)) body[name] = Number(raw);
|
||||
else body[name] = raw;
|
||||
});
|
||||
// If PARAM fields are empty, fall back to method body_example so the editor
|
||||
// updates when switching verbs / endpoints.
|
||||
if (!Object.keys(body).length) {
|
||||
const example = state.method.body_example;
|
||||
if (example && typeof example === "object" && !Array.isArray(example)) {
|
||||
for (const [key, value] of Object.entries(example)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
body[key] = value;
|
||||
}
|
||||
const existing = getByPath(root, name);
|
||||
if (!raw && existing === undefined) return;
|
||||
touched = true;
|
||||
if (!raw) {
|
||||
deleteByPath(root, name);
|
||||
return;
|
||||
}
|
||||
let value = raw;
|
||||
if (raw === "true" || raw === "false") value = raw === "true";
|
||||
else if (/^-?\d+$/.test(raw)) value = Number(raw);
|
||||
else if (/^-?\d+\.\d+$/.test(raw)) value = Number(raw);
|
||||
setByPath(root, name, value);
|
||||
});
|
||||
// Empty PARAM inputs → keep full body_example; edits merge into the Engine root wrap.
|
||||
if (!touched && example && typeof example === "object" && !Array.isArray(example)) {
|
||||
els.body.value = Object.keys(example).length ? JSON.stringify(example, null, 2) : "";
|
||||
updateBodyHighlight();
|
||||
if (syncVisibility) updateBodyPaneVisibility(state.method);
|
||||
return;
|
||||
}
|
||||
// Keep empty object out of the editor — do not show `{}`.
|
||||
const body = wrapKey ? { [wrapKey]: root } : root;
|
||||
els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : "";
|
||||
updateBodyHighlight();
|
||||
if (syncVisibility) {
|
||||
@@ -6748,7 +6968,6 @@
|
||||
await loadVersions();
|
||||
await loadCatalog({ major, preserveSelection: true, silent: true });
|
||||
await refreshOverview();
|
||||
await refreshoVirtPack();
|
||||
toast(`Runtime switched to ${state.runtimeVersion || seriesLabel(major)}`, "ok");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -6770,84 +6989,6 @@
|
||||
applyMethodDetails(payload);
|
||||
}
|
||||
|
||||
async function refreshoVirtPack() {
|
||||
try {
|
||||
const res = await fetch("/ui/api/ovirt/contracts");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const active = data.active || {};
|
||||
setText(els.statOsSeries, active.series || "—");
|
||||
setText(els.statOsOps, String(active.operation_count ?? data.schema_ops_mounted ?? "—"));
|
||||
setText(els.statOsServices, String(active.service_count ?? "—"));
|
||||
if (els.osSeriesSelect) {
|
||||
const available = data.available || [];
|
||||
els.osSeriesSelect.innerHTML = available.map((s) =>
|
||||
`<option value="${escapeHtml(s.series)}" ${s.series === active.series ? "selected" : ""}>${escapeHtml(s.series)} (${s.operation_count} ops)</option>`
|
||||
).join("");
|
||||
}
|
||||
if (els.osMvService) {
|
||||
const services = (active.services || []).filter((s) => s.max_microversion);
|
||||
els.osMvService.innerHTML = services.map((s) =>
|
||||
`<option value="${escapeHtml(s.name)}">${escapeHtml(s.name)} ${escapeHtml(s.active_microversion || s.default_microversion || "")}</option>`
|
||||
).join("");
|
||||
}
|
||||
if (els.osPackNote) {
|
||||
els.osPackNote.textContent = `Pack ${active.series || "—"} · ${active.operation_count || 0} operations · checksum ${(active.checksum || "").slice(0, 12)}…`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function bindoVirtPackActions() {
|
||||
els.btnOsSeriesActivate?.addEventListener("click", async () => {
|
||||
const series = els.osSeriesSelect?.value;
|
||||
if (!series) return;
|
||||
const res = await fetch("/ui/api/ovirt/contracts/activate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ series }),
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast(payload.detail || `Activate failed: ${res.status}`, "error");
|
||||
return;
|
||||
}
|
||||
toast(`oVirt pack: ${payload.series} (${payload.operation_count} ops)`, "ok");
|
||||
await refreshoVirtPack();
|
||||
});
|
||||
els.btnOsMvSet?.addEventListener("click", async () => {
|
||||
const service = els.osMvService?.value;
|
||||
const version = els.osMvVersion?.value?.trim();
|
||||
if (!service || !version) {
|
||||
toast("Pick a service and microversion", "warn");
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/ui/api/ovirt/microversions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service, version }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(`Microversion set failed: ${res.status}`, "error");
|
||||
return;
|
||||
}
|
||||
toast(`${service} → ${version}`, "ok");
|
||||
await refreshoVirtPack();
|
||||
});
|
||||
els.btnOsMvClear?.addEventListener("click", async () => {
|
||||
const service = els.osMvService?.value;
|
||||
if (!service) return;
|
||||
await fetch("/ui/api/ovirt/microversions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service, version: "default" }),
|
||||
});
|
||||
toast(`${service} microversion reset`, "ok");
|
||||
await refreshoVirtPack();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOverview() {
|
||||
try {
|
||||
setText(
|
||||
@@ -6856,7 +6997,6 @@
|
||||
);
|
||||
await refreshClusterStats();
|
||||
await refreshCatalogCoverage();
|
||||
await refreshoVirtPack();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
@@ -7142,7 +7282,6 @@
|
||||
bindCatalogOptions();
|
||||
bindHelpNav();
|
||||
bindDataPanelActions();
|
||||
bindoVirtPackActions();
|
||||
bindModal();
|
||||
bindTooltips();
|
||||
bindEndpointTree();
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Engine-shaped JSON request-body examples for the Web console.
|
||||
|
||||
Bodies follow the oVirt Engine REST convention: a single root element wrapping
|
||||
the resource (or ``action``). Field shapes match what this simulator accepts and
|
||||
what the public Engine API model documents for common create/update/action calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
|
||||
# Minimal-seed stable IDs (see app.ovirt.seed) — usable with `make seed`.
|
||||
_DC = str(stable_id("dc", "Default"))
|
||||
_CLUSTER = str(stable_id("cluster", "Default"))
|
||||
_HOST = str(stable_id("host", "host01"))
|
||||
_TEMPLATE = str(stable_id("template", "Blank"))
|
||||
_SD = str(stable_id("sd", "data1"))
|
||||
_NET = str(stable_id("net", "ovirtmgmt"))
|
||||
_VNIC = str(stable_id("vnic", "ovirtmgmt"))
|
||||
_VM = str(stable_id("vm", "lab-vm-01"))
|
||||
_DISK = str(stable_id("disk", "lab-vm-01"))
|
||||
_USER = str(stable_id("user", "admin"))
|
||||
_ROLE = str(stable_id("role", "SuperUser"))
|
||||
_DOMAIN = str(stable_id("domain", "internal"))
|
||||
|
||||
|
||||
def _ref(collection: str, object_id: str, *, name: str | None = None) -> dict[str, Any]:
|
||||
entity: dict[str, Any] = {
|
||||
"id": object_id,
|
||||
"href": f"/ovirt-engine/api/{collection}/{object_id}",
|
||||
}
|
||||
if name is not None:
|
||||
entity["name"] = name
|
||||
return entity
|
||||
|
||||
|
||||
def _wrap(element: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {element: payload}
|
||||
|
||||
|
||||
def _entity_bodies() -> dict[str, dict[str, Any]]:
|
||||
"""Map contract ``element`` → inner payload (without root wrapper)."""
|
||||
|
||||
return {
|
||||
"vm": {
|
||||
"name": "example-vm",
|
||||
"description": "Example virtual machine",
|
||||
"type": "server",
|
||||
"memory": 1073741824,
|
||||
"cpu": {"topology": {"sockets": 1, "cores": 1, "threads": 1}},
|
||||
"os": {"type": "other"},
|
||||
"cluster": _ref("clusters", _CLUSTER, name="Default"),
|
||||
"template": _ref("templates", _TEMPLATE, name="Blank"),
|
||||
},
|
||||
"host": {
|
||||
"name": "host-02",
|
||||
"address": "192.168.1.11",
|
||||
"comment": "Example host",
|
||||
"cluster": _ref("clusters", _CLUSTER, name="Default"),
|
||||
},
|
||||
"disk": {
|
||||
"name": "example-disk",
|
||||
"description": "Example virtual disk",
|
||||
"provisioned_size": 10737418240,
|
||||
"format": "cow",
|
||||
"sparse": True,
|
||||
"storage_domains": {
|
||||
"storage_domain": [_ref("storagedomains", _SD, name="data1")],
|
||||
},
|
||||
},
|
||||
# Inline disk create — seed disk is already attached to lab-vm-01.
|
||||
"disk_attachment": {
|
||||
"interface": "virtio_scsi",
|
||||
"bootable": False,
|
||||
"active": True,
|
||||
"disk": {
|
||||
"name": "example-attached-disk",
|
||||
"provisioned_size": 10737418240,
|
||||
"format": "cow",
|
||||
"sparse": True,
|
||||
"storage_domains": {
|
||||
"storage_domain": [_ref("storagedomains", _SD, name="data1")],
|
||||
},
|
||||
},
|
||||
},
|
||||
"nic": {
|
||||
"name": "nic1",
|
||||
"interface": "virtio",
|
||||
"linked": True,
|
||||
"plugged": True,
|
||||
"vnic_profile": _ref("vnicprofiles", _VNIC, name="ovirtmgmt"),
|
||||
},
|
||||
"network": {
|
||||
"name": "vlan100",
|
||||
"description": "Example VLAN network",
|
||||
"stp": False,
|
||||
"data_center": _ref("datacenters", _DC, name="Default"),
|
||||
"vlan": {"id": 100},
|
||||
},
|
||||
"vnic_profile": {
|
||||
"name": "example-profile",
|
||||
"pass_through": {"mode": "disabled"},
|
||||
"network": _ref("networks", _NET, name="ovirtmgmt"),
|
||||
},
|
||||
"data_center": {
|
||||
"name": "example-dc",
|
||||
"description": "Example data center",
|
||||
"local": False,
|
||||
"version": {"major": 4, "minor": 5},
|
||||
},
|
||||
"cluster": {
|
||||
"name": "example-cluster",
|
||||
"description": "Example cluster",
|
||||
"data_center": _ref("datacenters", _DC, name="Default"),
|
||||
"cpu": {"type": "Intel Conroe Family"},
|
||||
},
|
||||
"storage_domain": {
|
||||
"name": "example-sd",
|
||||
"type": "data",
|
||||
"storage": {
|
||||
"type": "nfs",
|
||||
"address": "nfs.lab.local",
|
||||
"path": "/export/example",
|
||||
},
|
||||
"host": _ref("hosts", _HOST, name="host01"),
|
||||
},
|
||||
"storage_connection": {
|
||||
"type": "nfs",
|
||||
"address": "nfs.lab.local",
|
||||
"path": "/export/example",
|
||||
},
|
||||
"template": {
|
||||
"name": "example-template",
|
||||
"description": "Example template",
|
||||
"vm": _ref("vms", _VM, name="lab-vm-01"),
|
||||
"cluster": _ref("clusters", _CLUSTER, name="Default"),
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "example-snapshot",
|
||||
"persist_memorystate": False,
|
||||
},
|
||||
"tag": {"name": "example-tag", "description": "Example tag"},
|
||||
"bookmark": {"name": "example-bookmark", "value": "Vms: status=up"},
|
||||
"affinity_group": {
|
||||
"name": "example-affinity",
|
||||
"description": "Example affinity group",
|
||||
"enforcing": False,
|
||||
"hosts_rule": {"enabled": True, "positive": True},
|
||||
"vms_rule": {"enabled": True, "positive": True},
|
||||
},
|
||||
"affinity_label": {"name": "example-label"},
|
||||
"permission": {
|
||||
"role": _ref("roles", _ROLE, name="SuperUser"),
|
||||
"user": _ref("users", _USER, name="admin"),
|
||||
},
|
||||
"user": {
|
||||
"user_name": "example@internal",
|
||||
"name": "example",
|
||||
"domain": _ref("domains", _DOMAIN, name="internal"),
|
||||
"password": "secret",
|
||||
},
|
||||
"group": {"name": "example-group", "domain": _ref("domains", _DOMAIN, name="internal")},
|
||||
"role": {"name": "ExampleRole", "administrative": False},
|
||||
"quota": {
|
||||
"name": "example-quota",
|
||||
"description": "Example quota",
|
||||
"data_center": _ref("datacenters", _DC, name="Default"),
|
||||
},
|
||||
"vm_pool": {
|
||||
"name": "example-pool",
|
||||
"description": "Example VM pool",
|
||||
"size": 1,
|
||||
"cluster": _ref("clusters", _CLUSTER, name="Default"),
|
||||
"template": _ref("templates", _TEMPLATE, name="Blank"),
|
||||
},
|
||||
"mac_pool": {
|
||||
"name": "example-mac-pool",
|
||||
"allow_duplicates": False,
|
||||
"ranges": {
|
||||
"range": [{"from": "00:1A:4A:16:01:00", "to": "00:1A:4A:16:01:FF"}],
|
||||
},
|
||||
},
|
||||
"cdrom": {"file": {"id": ""}},
|
||||
"graphics_console": {"protocol": "spice"},
|
||||
"host_nic": {
|
||||
"name": "eth1",
|
||||
"boot_protocol": "none",
|
||||
"network": _ref("networks", _NET, name="ovirtmgmt"),
|
||||
},
|
||||
"scheduling_policy": {"name": "example-policy", "description": "Example policy"},
|
||||
"instance_type": {
|
||||
"name": "example-instancetype",
|
||||
"memory": 1073741824,
|
||||
"cpu": {"topology": {"sockets": 1, "cores": 1, "threads": 1}},
|
||||
},
|
||||
"image_transfer": {
|
||||
"disk": _ref("disks", _DISK),
|
||||
"direction": "upload",
|
||||
"format": "raw",
|
||||
},
|
||||
"event": {
|
||||
"description": "Example event",
|
||||
"severity": 1,
|
||||
"origin": "ovirt-api-simulator",
|
||||
},
|
||||
"job": {"description": "Example job"},
|
||||
"step": {"description": "Example step", "type": "VALIDATING"},
|
||||
"icon": {"media_type": "image/png", "data": ""},
|
||||
"file": {"name": "example.iso"},
|
||||
"cluster_level": {"id": "4.5"},
|
||||
"operating_system": {"name": "other"},
|
||||
"domain": {"name": "example.local"},
|
||||
"external_host_provider": {
|
||||
"name": "example-foreman",
|
||||
"url": "https://foreman.example.local",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
},
|
||||
"openstack_image_provider": {
|
||||
"name": "example-glance",
|
||||
"url": "https://glance.example.local:9292",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"authentication_url": "https://keystone.example.local:5000/v3",
|
||||
"tenant_name": "admin",
|
||||
},
|
||||
"openstack_network_provider": {
|
||||
"name": "example-neutron",
|
||||
"url": "https://neutron.example.local:9696",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"authentication_url": "https://keystone.example.local:5000/v3",
|
||||
"tenant_name": "admin",
|
||||
"plugin_type": "open_vswitch",
|
||||
"type": "external",
|
||||
},
|
||||
"openstack_volume_provider": {
|
||||
"name": "example-cinder",
|
||||
"url": "https://cinder.example.local:8776/v3",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"authentication_url": "https://keystone.example.local:5000/v3",
|
||||
"tenant_name": "admin",
|
||||
},
|
||||
"network_filter": {"name": "example-filter"},
|
||||
"engine_option": {"name": "ExampleOption", "value": "true"},
|
||||
"katello_erratum": {"id": "example-erratum"},
|
||||
"statistic": {"name": "example.stat", "type": "GAUGE", "unit": "NONE"},
|
||||
"scheduling_policy_unit": {"name": "example-unit", "type": "filter"},
|
||||
}
|
||||
|
||||
|
||||
def _action_name(path: str) -> str:
|
||||
return path.rstrip("/").rsplit("/", 1)[-1].lower()
|
||||
|
||||
|
||||
def _action_body(path: str) -> dict[str, Any]:
|
||||
"""Real Engine actions use a root ``action`` element."""
|
||||
|
||||
name = _action_name(path)
|
||||
action: dict[str, Any] = {}
|
||||
if name == "clone":
|
||||
action["vm"] = {"name": "example-vm-clone"}
|
||||
elif name == "migrate":
|
||||
action["host"] = _ref("hosts", _HOST, name="host01")
|
||||
elif name in {"move", "copy"}:
|
||||
action["storage_domain"] = _ref("storagedomains", _SD, name="data1")
|
||||
elif name == "export":
|
||||
action["storage_domain"] = _ref("storagedomains", _SD, name="data1")
|
||||
action["exclusive"] = False
|
||||
elif name == "import":
|
||||
action["cluster"] = _ref("clusters", _CLUSTER, name="Default")
|
||||
action["storage_domain"] = _ref("storagedomains", _SD, name="data1")
|
||||
elif name == "attach":
|
||||
action["disk"] = _ref("disks", _DISK)
|
||||
elif name == "detach":
|
||||
action["detach_only"] = True
|
||||
elif name in {"start", "stop", "shutdown", "reboot", "suspend", "activate", "deactivate"}:
|
||||
action["async"] = True
|
||||
elif name == "ticket":
|
||||
action["ticket"] = {"value": ""}
|
||||
elif name == "preview_snapshot":
|
||||
action["restore_memory"] = False
|
||||
return {"action": action}
|
||||
|
||||
|
||||
def _generic_entity(element: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": f"example-{element.replace('_', '-')}",
|
||||
"description": f"Example {element.replace('_', ' ')}",
|
||||
}
|
||||
|
||||
|
||||
def body_example_for(
|
||||
*,
|
||||
method: str,
|
||||
kind: str,
|
||||
element: str,
|
||||
path: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a full JSON request body example, or ``None`` when no body is used."""
|
||||
|
||||
method_u = method.upper()
|
||||
kind_l = (kind or "").lower()
|
||||
element_l = (element or "").strip()
|
||||
if method_u not in {"POST", "PUT"}:
|
||||
return None
|
||||
if kind_l == "action":
|
||||
return _action_body(path)
|
||||
if kind_l not in {"collection", "item"}:
|
||||
return None
|
||||
if not element_l:
|
||||
return None
|
||||
bodies = _entity_bodies()
|
||||
inner = dict(bodies.get(element_l) or _generic_entity(element_l))
|
||||
if method_u == "PUT":
|
||||
# Partial update: avoid renaming path-param seed entities via console Try-it.
|
||||
inner.pop("name", None)
|
||||
if "description" in inner or element_l not in {"cdrom", "graphics_console", "permission"}:
|
||||
inner["description"] = f"Updated {element_l.replace('_', ' ')}"
|
||||
if element_l == "vm" and "memory" in inner:
|
||||
inner["memory"] = 2147483648
|
||||
if element_l == "disk" and "provisioned_size" in inner:
|
||||
inner["provisioned_size"] = 21474836480
|
||||
return _wrap(element_l, inner)
|
||||
+97
-26
@@ -11,30 +11,42 @@ from app.ovirt.contract_loader import (
|
||||
load_series_pack,
|
||||
series_for_major,
|
||||
)
|
||||
from app.ovirt.ids import stable_id
|
||||
from app.web.ovirt_body_examples import body_example_for
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
||||
|
||||
# Prefer minimal-seed UUIDs so console Try-it requests resolve against `make seed`.
|
||||
_PATH_PARAM_EXAMPLES: dict[str, object] = {
|
||||
"vm": "vm-001",
|
||||
"vmId": "00000000-0000-0000-0000-000000000001",
|
||||
"host": "host-01",
|
||||
"hostId": "00000000-0000-0000-0000-000000000011",
|
||||
"vm": "lab-vm-01",
|
||||
"vm_id": str(stable_id("vm", "lab-vm-01")),
|
||||
"vmId": str(stable_id("vm", "lab-vm-01")),
|
||||
"host": "host01",
|
||||
"host_id": str(stable_id("host", "host01")),
|
||||
"hostId": str(stable_id("host", "host01")),
|
||||
"cluster": "Default",
|
||||
"clusterId": "00000000-0000-0000-0000-000000000021",
|
||||
"cluster_id": str(stable_id("cluster", "Default")),
|
||||
"clusterId": str(stable_id("cluster", "Default")),
|
||||
"datacenter_id": str(stable_id("dc", "Default")),
|
||||
"dataCenter": "Default",
|
||||
"dataCenterId": "00000000-0000-0000-0000-000000000031",
|
||||
"disk": "disk-001",
|
||||
"diskId": "00000000-0000-0000-0000-000000000041",
|
||||
"dataCenterId": str(stable_id("dc", "Default")),
|
||||
"disk": "lab-vm-01-disk",
|
||||
"disk_id": str(stable_id("disk", "lab-vm-01")),
|
||||
"diskId": str(stable_id("disk", "lab-vm-01")),
|
||||
"network": "ovirtmgmt",
|
||||
"networkId": "00000000-0000-0000-0000-000000000051",
|
||||
"storageDomain": "data",
|
||||
"storageDomainId": "00000000-0000-0000-0000-000000000061",
|
||||
"network_id": str(stable_id("net", "ovirtmgmt")),
|
||||
"networkId": str(stable_id("net", "ovirtmgmt")),
|
||||
"storagedomain_id": str(stable_id("sd", "data1")),
|
||||
"storageDomain": "data1",
|
||||
"storageDomainId": str(stable_id("sd", "data1")),
|
||||
"template": "Blank",
|
||||
"templateId": "00000000-0000-0000-0000-000000000071",
|
||||
"template_id": str(stable_id("template", "Blank")),
|
||||
"templateId": str(stable_id("template", "Blank")),
|
||||
"user": "admin@internal",
|
||||
"userId": "00000000-0000-0000-0000-000000000081",
|
||||
"user_id": str(stable_id("user", "admin")),
|
||||
"userId": str(stable_id("user", "admin")),
|
||||
"jobId": "00000000-0000-0000-0000-000000000091",
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"id": str(stable_id("vm", "lab-vm-01")),
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +55,65 @@ def path_param_example(name: str) -> object | None:
|
||||
|
||||
return _PATH_PARAM_EXAMPLES.get(name)
|
||||
|
||||
|
||||
def _body_fields_from_example(
|
||||
body_example: dict[str, Any] | None, *, element: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""PARAM inputs derived from body_example, including nested scalar paths."""
|
||||
|
||||
if not isinstance(body_example, dict) or not body_example:
|
||||
return []
|
||||
inner: Any = body_example
|
||||
if (
|
||||
element
|
||||
and element in body_example
|
||||
and isinstance(body_example[element], dict)
|
||||
):
|
||||
inner = body_example[element]
|
||||
elif len(body_example) == 1:
|
||||
only = next(iter(body_example.values()))
|
||||
if isinstance(only, dict):
|
||||
inner = only
|
||||
if not isinstance(inner, dict):
|
||||
return []
|
||||
|
||||
fields: list[dict[str, Any]] = []
|
||||
|
||||
def _leaf_type(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return "integer"
|
||||
if isinstance(value, float):
|
||||
return "number"
|
||||
return "string"
|
||||
|
||||
def _walk(prefix: str, value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
path = f"{prefix}.{key}" if prefix else str(key)
|
||||
_walk(path, child)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
path = f"{prefix}.{index}" if prefix else str(index)
|
||||
_walk(path, child)
|
||||
return
|
||||
desc = f"{element}.{prefix}" if element else prefix
|
||||
fields.append(
|
||||
{
|
||||
"name": prefix,
|
||||
"type": _leaf_type(value),
|
||||
"description": desc,
|
||||
"optional": True,
|
||||
"enum": [],
|
||||
"example": value,
|
||||
}
|
||||
)
|
||||
|
||||
_walk("", inner)
|
||||
return fields
|
||||
|
||||
_SERIES_LABELS = {
|
||||
"3.0": "Engine 3.0",
|
||||
"3.1": "Engine 3.1",
|
||||
@@ -146,18 +217,16 @@ def ovirt_method_payload(
|
||||
}
|
||||
for name in path_params
|
||||
]
|
||||
body_fields: list[dict[str, Any]] = []
|
||||
if op.method in {"POST", "PUT"} and op.kind in {"collection", "item", "action"}:
|
||||
body_fields.append(
|
||||
{
|
||||
"name": op.element,
|
||||
"type": "object",
|
||||
"description": f"{op.element} payload (XML or JSON)",
|
||||
"optional": op.kind == "action",
|
||||
"enum": [],
|
||||
"example": {op.element: {"name": "example"}},
|
||||
}
|
||||
)
|
||||
# Full Engine-shaped JSON lives in body_example (root-wrapped entity / action).
|
||||
# PARAM drawer flattens nested scalars from that example (dotted paths).
|
||||
body_example = body_example_for(
|
||||
method=op.method,
|
||||
kind=op.kind,
|
||||
element=op.element,
|
||||
path=op.path,
|
||||
)
|
||||
# Scalar fields for the PARAMS drawer; full nested JSON stays in body_example.
|
||||
body_fields = _body_fields_from_example(body_example, element=op.element or "")
|
||||
query_fields = []
|
||||
if op.search:
|
||||
query_fields.extend(
|
||||
@@ -200,6 +269,7 @@ def ovirt_method_payload(
|
||||
"path_fields": path_fields,
|
||||
"query_fields": query_fields,
|
||||
"body_fields": body_fields,
|
||||
"body_example": body_example,
|
||||
"runtime_version": runtime_version,
|
||||
}
|
||||
return {
|
||||
@@ -214,6 +284,7 @@ def ovirt_method_payload(
|
||||
"path_fields": [],
|
||||
"query_fields": [],
|
||||
"body_fields": [],
|
||||
"body_example": None,
|
||||
"runtime_version": runtime_version,
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.dependencies import get_database
|
||||
from app.ovirt.demo_datacenter import seed_ovirt_demo
|
||||
from app.ovirt.demo_datacenter import CLUSTER_SIZES, normalize_cluster_size, seed_ovirt_demo
|
||||
from app.ovirt.seed import clear_ovirt_state, ovirt_demo_summary, seed_ovirt
|
||||
from app.web.assets import console_html
|
||||
|
||||
@@ -175,13 +175,29 @@ async def ui_ovirt_contracts_activate(request: Request) -> JSONResponse:
|
||||
|
||||
@router.post("/ui/api/demo/load", include_in_schema=False)
|
||||
async def ui_demo_load(request: Request) -> JSONResponse:
|
||||
"""Load synthetic oVirt datacenter (~1000 VMs + full inventory)."""
|
||||
"""Load a sized demo cluster: small (3h/50vm), large (10h/1000vm), big (30h/2000vm)."""
|
||||
|
||||
size_raw = request.query_params.get("size") or request.query_params.get("profile")
|
||||
if size_raw is None:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if isinstance(body, dict):
|
||||
size_raw = body.get("size") or body.get("profile")
|
||||
try:
|
||||
size = normalize_cluster_size(str(size_raw) if size_raw else "large")
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{error}; sizes: {', '.join(sorted(CLUSTER_SIZES))}",
|
||||
) from error
|
||||
|
||||
pool = _database_pool(request)
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.transaction():
|
||||
result = await seed_ovirt_demo(connection)
|
||||
result = await seed_ovirt_demo(connection, size=size)
|
||||
summary = await ovirt_demo_summary(connection)
|
||||
except Exception as error:
|
||||
raise HTTPException(
|
||||
|
||||
Reference in New Issue
Block a user