Add OpenStack request-body schemas and nested console PARAM sync.

This commit is contained in:
2026-07-18 08:47:38 +03:00
parent cbd0adca91
commit ae297258b1
46 changed files with 42717 additions and 40135 deletions
+97 -6
View File
@@ -13,13 +13,19 @@ PUSH_LATEST ?= 1
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/ovirt-api-simulator
LAB ?= ./pulumi-tests
# Remotes used by `make push` (GitHub + antropoff).
GIT_REMOTES ?= origin antropoff
# Preferred host ports for `make up-local` (override / .env stay gitignored).
LOCAL_ENGINE_PORT ?= 6443
LOCAL_UI_PORT ?= 6080
.PHONY: help install format lint typecheck test test-unit test-integration test-contract \
test-surface test-versions coverage up down restart logs seed seed-demo smoke clean ci shell \
test-surface test-versions coverage up down restart logs seed seed-small seed-large seed-big seed-demo \
audit-seed-dump smoke clean ci shell \
helm-template generate-packs \
release release-build release-up release-down release-seed \
test-pulumi-smoke test-pulumi pulumi-tests \
test-smoke-all test-all clean-test-resources
test-smoke-all test-all clean-test-resources push up-local down-local
help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -72,6 +78,52 @@ up: ## Start PostgreSQL, simulator, and Engine gateway
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --wait
# Writes gitignored docker-compose.override.yml (+ syncs ports into .env) when
# default 443/5000 are busy (common on macOS). Compose auto-merges the override.
up-local: ## Start stack with local port override (not committed)
@set -e; \
test -f .env || cp .env.example .env; \
pick_port() { \
preferred="$$1"; shift; \
for p in "$$preferred" "$$@"; do \
if ! lsof -nP -iTCP:"$$p" -sTCP:LISTEN >/dev/null 2>&1; then \
echo "$$p"; return 0; \
fi; \
done; \
echo "No free port among: $$preferred $$*" >&2; exit 1; \
}; \
engine_port=$$(pick_port "$(LOCAL_ENGINE_PORT)" 6443 7443 8443 16443); \
ui_port=$$(pick_port "$(LOCAL_UI_PORT)" 6080 7080 8080 16080); \
printf '%s\n' \
'# Generated by `make up-local` — gitignored, do not commit.' \
'services:' \
' api-gateway:' \
' ports:' \
" - \"$${engine_port}:443\"" \
" - \"$${ui_port}:5000\"" \
> docker-compose.override.yml; \
if grep -q '^OVIRT_ENGINE_PORT=' .env; then \
sed -i.bak -e "s/^OVIRT_ENGINE_PORT=.*/OVIRT_ENGINE_PORT=$${engine_port}/" \
-e "s/^OVIRT_UI_PORT=.*/OVIRT_UI_PORT=$${ui_port}/" .env && rm -f .env.bak; \
else \
printf '\nOVIRT_ENGINE_PORT=%s\nOVIRT_UI_PORT=%s\n' "$$engine_port" "$$ui_port" >> .env; \
fi; \
echo "Local ports → Engine https://127.0.0.1:$${engine_port} UI http://127.0.0.1:$${ui_port}"; \
echo "Wrote docker-compose.override.yml (gitignored)"; \
$(COMPOSE) up -d --build --wait; \
echo "up-local ok"
down-local: ## Stop stack and remove gitignored local port override
@$(COMPOSE) down
@rm -f docker-compose.override.yml
@if [ -f .env ]; then \
if grep -q '^OVIRT_ENGINE_PORT=' .env; then \
sed -i.bak -e 's/^OVIRT_ENGINE_PORT=.*/OVIRT_ENGINE_PORT=443/' \
-e 's/^OVIRT_UI_PORT=.*/OVIRT_UI_PORT=5000/' .env && rm -f .env.bak; \
fi; \
fi
@echo "down-local ok (removed docker-compose.override.yml)"
down: ## Stop local services
$(COMPOSE) down
@@ -85,12 +137,27 @@ logs: ## Follow logs
seed: ## Seed minimal lab
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile minimal
seed-demo: ## Seed demo datacenter (~1000 VMs)
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile demo
seed-small: ## Seed small cluster (3 hosts · 50 VMs)
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile small
seed-large: ## Seed large cluster (10 hosts · 1000 VMs)
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile large
seed-big: ## Seed big cluster (30 hosts · 2000 VMs)
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile big
seed-demo: seed-large ## Alias: large cluster (legacy name)
audit-seed-dump: ## Contract dump audit (collections+entities) vs live Engine
@set -a; [ -f .env ] && . ./.env; set +a; \
python3 tools/audit_seed_dump.py \
"https://127.0.0.1:$${OVIRT_ENGINE_PORT:-443}" \
"$${OVIRT_SERIES:-4.5}"
smoke: ## Quick Engine auth + list VMs smoke
@curl -skf -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
https://127.0.0.1:$${OVIRT_ENGINE_PORT:-443}/ovirt-engine/api/vms >/dev/null
@set -a; [ -f .env ] && . ./.env; set +a; \
curl -skf -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
"https://127.0.0.1:$${OVIRT_ENGINE_PORT:-443}/ovirt-engine/api/vms" >/dev/null
@echo "smoke ok"
helm-template: ## Render Helm chart
@@ -152,3 +219,27 @@ test-all: ## Client lab full suite (Pulumi contract coverage)
clean-test-resources: ## Cleanup lab-created resources
$(MAKE) -C $(LAB) clean-test-resources
# --- Git: stage, commit (prompt), push to both remotes ---
push: ## git add . → ask for commit message → push to origin and antropoff
@set -e; \
git add .; \
if git diff --cached --quiet; then \
echo "Nothing staged to commit."; \
else \
if [ -n "$(MSG)" ]; then \
msg="$(MSG)"; \
else \
printf "Commit message: "; \
IFS= read -r msg </dev/tty; \
fi; \
if [ -z "$$msg" ]; then \
echo "Empty commit message; aborting." >&2; \
exit 1; \
fi; \
git commit -m "$$msg"; \
fi; \
for remote in $(GIT_REMOTES); do \
echo "→ pushing HEAD to $$remote"; \
git push -u "$$remote" HEAD; \
done
+8 -4
View File
@@ -18,7 +18,7 @@ Semantic handlers persist mutations; long-running work is tracked as Engine jobs
```bash
cp .env.example .env
make up
make seed # or: make seed-demo (~1000 VMs)
make seed # or: make seed-small|seed-large|seed-big
```
| Surface | URL |
@@ -73,7 +73,7 @@ Or: `make release-up && make release-seed PROFILE=minimal`
- XML + JSON representations (`Accept` / `Content-Type`)
- Series packs **3.03.6, 4.34.5, master** with operation deltas
- Stateful PostgreSQL inventory + async jobs
- Seed profiles: `minimal` and `demo` (~1000 VMs)
- Seed profiles: `minimal`, `small` (3h/50vm), `large` (10h/1000vm), `big` (30h/2000vm)
- Web UI with oVirt branding (`#0076B6` / charcoal `#1D2226`)
- Docker Compose + Helm
- API tests + [`pulumi-tests/`](pulumi-tests/README.md) (Pulumi contract coverage)
@@ -82,14 +82,18 @@ Or: `make release-up && make release-seed PROFILE=minimal`
```bash
make up
make up-local # local ports via gitignored docker-compose.override.yml
make down-local # stop stack + remove local override
make seed
make seed-demo
make seed-large
make test-unit
make test-integration
make test-pulumi-smoke # Pulumi smoke
make test-pulumi # all series × all contract ops + HTML report
make pulumi-tests # alias for test-pulumi
make test-all # alias for test-pulumi
make push # git add . → prompt commit → push origin + antropoff
# make push MSG="your message" # non-interactive commit message
```
Docker Hub release (requires `docker login` as the Hub owner; see
@@ -111,7 +115,7 @@ make release-up && make release-seed # run the published stack locally
| [Authentication](docs/authentication.md) | Basic, OAuth2, sessions |
| [API versions](docs/api-versions.md) | Series packs and Version header |
| [Configuration](docs/configuration.md) | Environment and Compose |
| [Seed profiles](docs/seed-profiles.md) | `minimal` / `demo` |
| [Seed profiles](docs/seed-profiles.md) | `minimal` / `small` / `large` / `big` |
| [Kubernetes / Helm](docs/kubernetes.md) | Cluster install |
| [Full index](docs/README.md) | All guides |
+8 -4
View File
@@ -20,7 +20,7 @@ oVirt Engine. Семантические обработчики сохраняю
```bash
cp .env.example .env
make up
make seed # или: make seed-demo (~1000 ВМ)
make seed # или: make seed-small|seed-large|seed-big
```
| Поверхность | URL |
@@ -76,7 +76,7 @@ curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4'
- Представления XML + JSON (`Accept` / `Content-Type`)
- Series packs **3.03.6, 4.34.5, master** с дельтами операций
- Stateful-инвентарь в PostgreSQL + асинхронные jobs
- Профили seed: `minimal` и `demo` (~1000 ВМ)
- Профили seed: `minimal`, `small` (3h/50vm), `large` (10h/1000vm), `big` (30h/2000vm)
- Web UI с брендингом oVirt (`#0076B6` / charcoal `#1D2226`)
- Docker Compose + Helm
- API-тесты + [`pulumi-tests/`](pulumi-tests/README.ru.md) (покрытие контрактов Pulumi)
@@ -85,14 +85,18 @@ curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4'
```bash
make up
make up-local # локальные порты через gitignored docker-compose.override.yml
make down-local # остановить стек + удалить local override
make seed
make seed-demo
make seed-large
make test-unit
make test-integration
make test-pulumi-smoke # Pulumi smoke
make test-pulumi # все series × все contract ops + HTML-отчёт
make pulumi-tests # alias для test-pulumi
make test-all # alias для test-pulumi
make push # git add . → запрос commit → push origin + antropoff
# make push MSG="сообщение" # сообщение без интерактива
```
Публикация в Docker Hub (нужен `docker login` владельца Hub; см.
@@ -114,7 +118,7 @@ make release-up && make release-seed # запустить опубликова
| [Аутентификация](docs/ru/authentication.md) | Basic, OAuth2, сессии |
| [Версии API](docs/ru/api-versions.md) | Series packs и заголовок Version |
| [Конфигурация](docs/ru/configuration.md) | Окружение и Compose |
| [Профили seed](docs/ru/seed-profiles.md) | `minimal` / `demo` |
| [Профили seed](docs/ru/seed-profiles.md) | `minimal` / `small` / `large` / `big` |
| [Kubernetes / Helm](docs/ru/kubernetes.md) | Установка в кластер |
| [Полный индекс](docs/ru/README.md) | Все руководства |
+2 -2
View File
@@ -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.
+266 -88
View File
@@ -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}"),
)
for bname, bvalue in spec.bookmarks:
await conn.execute(
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'UpVMs','Vms: status=up')",
stable_id("bm", "UpVMs"),
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,$2,$3)",
stable_id("bm", bname),
bname,
bvalue,
)
for i in range(50):
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",
]
+411 -35
View File
@@ -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"):
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 cluster_id FROM ov_vms WHERE id=$1::uuid", vm["id"]
"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,7 +1882,12 @@ 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)
@@ -2034,13 +2214,16 @@ 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":
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", parts[1]
"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"],
@@ -2048,6 +2231,25 @@ async def _handle_jobs(
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"]),
+74 -20
View File
@@ -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,11 +207,41 @@ 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"))
if not rest:
rows = await conn.fetch(
"""SELECT t.*
FROM ov_tag_assignments a
@@ -229,6 +261,28 @@ async def handle_subcollection(
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
View File
@@ -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
View File
@@ -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))
+92
View File
@@ -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
View File
@@ -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;
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);
});
// 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;
// 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();
+327
View File
@@ -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)
+96 -25
View File
@@ -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
View File
@@ -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(
+3
View File
@@ -66,6 +66,9 @@ curl -skf https://127.0.0.1/health/ready
curl -sf http://127.0.0.1:5000/health/live
```
Open the console at [http://127.0.0.1:5000/](http://127.0.0.1:5000/) — see
[Web UI](web-ui.md) for screenshots of Try-it, catalog, and data drawers.
`/health/ready` returns HTTP 503 until PostgreSQL is reachable **and** the
latest packaged migration is applied.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+1 -1
View File
@@ -46,7 +46,7 @@ make seed-demo
OVIRT_SERIES=4.4 make restart
```
**Hot-swap** (in-memory, no rebuild) — Web UI Environment → Apply pack, or:
**Hot-swap** (in-memory, no rebuild) — Web UI **API catalog****Apply as runtime**, or:
```bash
curl -s http://127.0.0.1:5000/ui/api/ovirt/contracts/activate \
+3
View File
@@ -66,6 +66,9 @@ curl -skf https://127.0.0.1/health/ready
curl -sf http://127.0.0.1:5000/health/live
```
Консоль: [http://127.0.0.1:5000/](http://127.0.0.1:5000/) — скриншоты Try-it,
каталога и ящиков Data см. в [Web UI](web-ui.md).
`/health/ready` возвращает HTTP 503, пока PostgreSQL недоступен **или** не
применена последняя упакованная миграция.
+1 -1
View File
@@ -46,7 +46,7 @@ make seed-demo
OVIRT_SERIES=4.4 make restart
```
**Hot-swap** (in-memory, без пересборки) — Web UI Environment → Apply pack, или:
**Hot-swap** (in-memory, без пересборки) — Web UI **API catalog****Apply as runtime**, или:
```bash
curl -s http://127.0.0.1:5000/ui/api/ovirt/contracts/activate \
+20 -20
View File
@@ -4,38 +4,38 @@
| Профиль | Как загрузить | Содержимое |
|---|---|---|
| `minimal` | Startup симулятора (если БД ещё не `demo`) / `make seed` / `python -m app.ovirt.seed_cli --profile minimal` / Helm seed Job | 1 datacenter, 1 cluster, 1 host, Blank template, 4 пользователя, небольшой sample инвентаря |
| `demo` | `make seed-demo` / ящик Data в UI / Helm `seed.profile=demo` / `--profile demo` | ~1000 ВМ, multi-host DC, сети, storage domains, диски, nested samples |
| `minimal` | Старт (если БД не sized-demo) / `make seed` / `--profile minimal` | 1 DC, 1 cluster, 1 host, Blank, 4 пользователя |
| `small` | `make seed-small` / DATA → **Load small** / `--profile small` | **3 host · 50 ВМ** · 1 DC · 1 cluster · 2 сети · 2 SD |
| `large` | `make seed-large` / DATA → **Load large** / `--profile large` | **10 host · 1000 ВМ** · 2 DC · 2 cluster · пропорциональный инвентарь |
| `big` | `make seed-big` / DATA → **Load big** / `--profile big` | **30 host · 2000 ВМ** · 3 DC · 6 cluster · больше tags/events/jobs |
| `demo` | `make seed-demo` (alias) / `--profile demo` | То же, что **`large`** (старое имя) |
В Compose lifespan FastAPI загружает **`minimal`**, если БД пуста или не
помечена как `demo`. Для большого профиля — `make seed-demo` (или ящик Data в
UI). Helm дополнительно может запускать seed Job (`seed.enabled`).
Sized-demo масштабируют DC, clusters, hosts, VMs, сети, storage domains,
templates, tags, events, jobs и nested samples вместе. Lifespan сохраняет
`small` / `large` / `big` / `demo` при рестарте.
Пароль для всех пользователей: **`secret`**. Домен: **`internal`**.
Principals: `admin@internal`, `ops@internal`, `developer@internal`,
`demo@internal`.
Пароль всех пользователей: **`secret`**. Домен: **`internal`**.
## CLI
```bash
make seed
make seed-demo
# эквивалент
docker compose run --rm --entrypoint python simulator \
-m app.ovirt.seed_cli --profile demo
make seed-small
make seed-large # или: make seed-demo
make seed-big
```
## UI
Ящик **DATA****Load small** / **Load large** / **Load big**, либо
**Reset to minimal**.
API: `POST /ui/api/demo/load?size=small|large|big`
## Helm
```yaml
seed:
enabled: true
profile: demo # или minimal
profile: large # minimal | small | large | big | demo
```
## Поведение
Оба профиля **очищают** (truncate) лабораторные таблицы oVirt и загружают данные
заново. `demo` — для плотности и nested GET; `minimal` — для быстрого CI.
+57 -15
View File
@@ -2,32 +2,74 @@
# Web UI
URL консоли (Compose по умолчанию): [http://127.0.0.1:5000/](http://127.0.0.1:5000/)
Интерактивная консоль: обзор операций контракта Engine, лабораторные токены,
Try-it запросы и управление seed-данными.
UX ящиков совпадает с другими лабораторными симуляторами этого семейства:
URL по умолчанию (Compose): [http://127.0.0.1:5000/](http://127.0.0.1:5000/)
(При `make up-local` — локальный UI-порт из `.env`, например `6080` / `7080`.)
![Консоль](../images/web-ui/console.png)
## Рабочая область
| Область | Назначение |
|---|---|
| Auth | Выдача лабораторных токенов / показ principal |
| API catalog | Обзор операций контракта активного series |
| Coverage | Сводка покрытия pack / handlers |
| Help | Краткие заметки оператора |
| Data | Reseed `minimal` / `demo` |
| Environment | Активный series, runtime-подсказки, hot-swap **Apply pack** |
| Выбор endpoint | Поиск по путям контракта (series pack Engine) |
| Вкладки методов | `GET` / `POST` / `PUT` / `DELETE` для выбранного пути |
| Request body | JSON-пример в форме Engine для create/update/action |
| Params | Плоские path/body-поля для быстрых правок |
| Response | Статус + JSON-дерево последнего ответа |
| History | Недавние Try-it запросы (повтор / восстановление) |
![Ящик Endpoints](../images/web-ui/endpoints.png)
![POST /vms с телом в форме Engine](../images/web-ui/request-body.png)
![Параметры запроса](../images/web-ui/request-params.png)
Тела запросов соответствуют соглашению Engine (root-wrapper сущности или
`action`). В примерах — вложенные ссылки (`cluster`, `template`, CPU topology,
storage domains) под seed-инвентарь лаборатории, а не однополевой stub.
![History](../images/web-ui/history.png)
## Ящики (drawers)
| Ящик | Назначение |
|---|---|
| Authentication | SSO password grant / вставка Bearer-токена |
| API catalog | Обзор series packs; **Apply as runtime** — hot-swap |
| Help → Compatibility | Сводка declared / implemented / verified |
| Data | Reseed `minimal` / `small` / `large` / `big` |
| Environment | Runtime series + обзор инвентаря datacenter |
![Authentication](../images/web-ui/authentication.png)
Лабораторные учётки: `admin@internal`, `ops@internal`, `developer@internal`,
`demo@internal` / `secret`. Scope: `ovirt-app-api`.
![API catalog](../images/web-ui/api-catalog.png)
![Help / compatibility](../images/web-ui/help-compatibility.png)
![Пресеты Data](../images/web-ui/data.png)
![Environment](../images/web-ui/environment.png)
## Hot-swap series
Из Environment (или UI API):
Из **API catalog****Apply as runtime** (или UI API):
- `POST /ui/api/ovirt/contracts/activate` с `{"series":"4.4"}`
- `POST /ui/api/contract/apply?major=N`
Перемонтирует in-memory контрактные маршруты без пересборки образа. Рестарт
процесса возвращает cold-start значение `OVIRT_SERIES`. См.
[Версии API](api-versions.md).
процесса возвращает cold-start `OVIRT_SERIES`. См. [Версии API](api-versions.md).
Брендинг: oVirt blue `#0076B6` и charcoal `#1D2226`.
## Заметки
UI обращается к тому же процессу симулятора, что и Engine API; отличается только
опубликованный listener ([ports.md](ports.md)). Схема OpenAPI:
[http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs) (также на порту Engine).
- Брендинг: oVirt blue `#0076B6` и charcoal `#1D2226`.
- UI ходит в тот же процесс симулятора, что и Engine API; отличается только
опубликованный listener ([порты](ports.md)).
- OpenAPI: [http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs)
(также на HTTPS-порту Engine).
+23 -11
View File
@@ -4,12 +4,16 @@
| Profile | How to load | Contents |
|---|---|---|
| `minimal` | Simulator startup (if DB is not already `demo`) / `make seed` / `python -m app.ovirt.seed_cli --profile minimal` / Helm seed Job | 1 datacenter, 1 cluster, 1 host, Blank template, 4 users, small inventory sample |
| `demo` | `make seed-demo` / UI Data drawer / Helm `seed.profile=demo` / `--profile demo` | ~1000 VMs, multi-host DC, networks, storage domains, disks, nested samples |
| `minimal` | Startup (if DB is not a sized demo) / `make seed` / `--profile minimal` | 1 DC, 1 cluster, 1 host, Blank template, 4 users |
| `small` | `make seed-small` / DATA → **Load small** / `--profile small` | **3 hosts · 50 VMs** · 1 DC · 1 cluster · 2 networks · 2 SD |
| `large` | `make seed-large` / DATA → **Load large** / `--profile large` | **10 hosts · 1000 VMs** · 2 DC · 2 clusters · proportional inventory |
| `big` | `make seed-big` / DATA → **Load big** / `--profile big` | **30 hosts · 2000 VMs** · 3 DC · 6 clusters · denser tags/events/jobs |
| `demo` | `make seed-demo` (alias) / `--profile demo` | Same as **`large`** (legacy name) |
On Compose, the FastAPI lifespan loads **`minimal`** automatically when the DB
is empty or not marked as `demo`. Use `make seed-demo` (or the UI Data drawer)
for the large profile. Helm can also run a seed Job (`seed.enabled`).
Sized demos scale datacenters, clusters, hosts, VMs, networks, storage domains,
templates, tags, events, jobs, and nested samples together. On Compose, lifespan
keeps any of `small` / `large` / `big` / `demo` across restarts (does not wipe to
minimal).
Password for all users: **`secret`**. Domain: **`internal`**.
@@ -20,22 +24,30 @@ Principals: `admin@internal`, `ops@internal`, `developer@internal`,
```bash
make seed
make seed-demo
make seed-small
make seed-large # or: make seed-demo
make seed-big
# equivalent
docker compose run --rm --entrypoint python simulator \
-m app.ovirt.seed_cli --profile demo
-m app.ovirt.seed_cli --profile large
```
## UI
Open the **DATA** drawer → **Load small** / **Load large** / **Load big**, or
**Reset to minimal**.
API: `POST /ui/api/demo/load?size=small|large|big`
## Helm
```yaml
seed:
enabled: true
profile: demo # or minimal
profile: large # minimal | small | large | big | demo
```
## Behaviour
Both profiles **truncate** oVirt lab tables then reload. Prefer `demo` for
density and nested GET probes; `minimal` for fast CI.
All profiles **truncate** oVirt lab tables then reload. Prefer `large`/`big` for
density; `minimal` / `small` for fast CI and light labs.
+56 -13
View File
@@ -2,22 +2,63 @@
# Web UI
Console URL (Compose default): [http://127.0.0.1:5000/](http://127.0.0.1:5000/)
Interactive console for browsing Engine contract operations, issuing lab tokens,
sending Try-it requests, and managing seed data.
Drawer UX matches the other laboratory simulators in this family:
Compose default URL: [http://127.0.0.1:5000/](http://127.0.0.1:5000/)
(With `make up-local`, use the local UI port from your `.env`, e.g. `6080` / `7080`.)
![Console](images/web-ui/console.png)
## Workspace
| Area | Purpose |
|---|---|
| Auth | Issue lab tokens / show principal |
| API catalog | Browse contract operations for the active series |
| Coverage | Pack / handler coverage summary |
| Help | Short operator notes |
| Data | Reseed `minimal` / `demo` |
| Environment | Active series, runtime hints, **Apply pack** hot-swap |
| Endpoint picker | Searchable catalog of contract paths (Engine series pack) |
| Method tabs | `GET` / `POST` / `PUT` / `DELETE` for the selected path |
| Request body | Engine-shaped JSON sample for create/update/action |
| Params | Flattened path + body fields for quick edits |
| Response | Status + JSON tree for the last call |
| History | Recent Try-it requests (replay / restore) |
![Endpoints drawer](images/web-ui/endpoints.png)
![POST /vms with Engine-shaped body](images/web-ui/request-body.png)
![Request parameters](images/web-ui/request-params.png)
Request bodies follow the real Engine convention (root-wrapped entity or
`action`). Samples include nested refs (`cluster`, `template`, CPU topology,
storage domains) aligned with the seeded lab inventory — not a one-field stub.
![History](images/web-ui/history.png)
## Drawers
| Drawer | Purpose |
|---|---|
| Authentication | Engine SSO password grant / paste Bearer token |
| API catalog | Browse series packs; **Apply as runtime** hot-swap |
| Help → Compatibility | Declared / implemented / verified surface summary |
| Data | Reseed `minimal` / `small` / `large` / `big` |
| Environment | Runtime series + datacenter inventory overview |
![Authentication](images/web-ui/authentication.png)
Default lab principals: `admin@internal`, `ops@internal`, `developer@internal`,
`demo@internal` / `secret`. Scope: `ovirt-app-api`.
![API catalog](images/web-ui/api-catalog.png)
![Help / compatibility](images/web-ui/help-compatibility.png)
![Data presets](images/web-ui/data.png)
![Environment](images/web-ui/environment.png)
## Series hot-swap
From Environment (or UI API):
From **API catalog****Apply as runtime** (or UI API):
- `POST /ui/api/ovirt/contracts/activate` with `{"series":"4.4"}`
- `POST /ui/api/contract/apply?major=N`
@@ -26,8 +67,10 @@ This remounts the in-memory contract routes without rebuilding the image. A
process restart restores the cold-start `OVIRT_SERIES` value. See
[API versions](api-versions.md).
Branding uses oVirt blue `#0076B6` and charcoal `#1D2226`.
## Notes
The UI talks to the same simulator process as the Engine API; only the published
listener differs ([ports.md](ports.md)). OpenAPI schema:
[http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs) (also on Engine port).
- Branding uses oVirt blue `#0076B6` and charcoal `#1D2226`.
- The UI talks to the same simulator process as the Engine API; only the
published listener differs ([ports.md](ports.md)).
- OpenAPI schema: [http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs)
(also available on the Engine HTTPS port).
+24 -9
View File
@@ -2,10 +2,10 @@
# oVirt Pulumi contract-coverage lab
Pulumi Automation API suite that exercises **every operation** declared in
`contracts/ovirt/<series>/api.json` across **all Engine series packs**, plus a
**synthetic HEAD** request for each GET path (contracts omit HEAD; the Engine
accepts it).
**100% coverage** here means the **HTTP contract matrix**: every operation
declared in `contracts/ovirt/<series>/api.json` for **all Engine series packs**,
plus a **synthetic HEAD** for each GET path (contracts omit HEAD; the Engine
accepts it). It is **not** a count of Pulumi provider resources.
| Series | Ops (approx.) |
|--------|--------------:|
@@ -16,10 +16,22 @@ make test-pulumi-smoke # 3.6 + 4.5 sample (fast)
make pulumi-tests # full matrix (alias: make test-pulumi)
```
Layer B (optional): provider lifecycle smoke only — do not treat it as API
parity.
Reports (written under `reports/`):
- `pulumi-contract-coverage.html` — human-readable summary (includes methods histogram)
- `pulumi-contract-coverage.json` — machine-readable results
- `pulumi-contract-coverage.html` — human-readable summary (method histogram
includes GET/PUT/POST/DELETE/HEAD)
- `pulumi-contract-coverage.json` — machine-readable results + `coverage`
(`probed/declared`, `critical`)
Pass line example:
```text
COVERAGE 9150/9150 (critical=0)
METHODS {"DELETE":1146,"GET":2314,"HEAD":2314,"POST":2230,"PUT":1146}
```
Optional filters:
@@ -29,11 +41,14 @@ OVIRT_METHODS_FILTER=GET make pulumi-tests
SMOKE_ONLY=1 make test-pulumi-smoke
```
All suites run **only in Docker**. Pass criteria:
All suites run **only in Docker** (lab compose seeds **minimal** inventory).
Pass criteria:
- The Engine route is reachable and returns a handled status (`200`/`201`/`202`/`204`,
`400`/`403`/`404`/`405`/`409`/`415`/`422`/`501`) — **not** `401` (the suite
re-authenticates after each series unload) and not a transport/`5xx` failure.
- For `200`/`201`/`202` the response body must be non-empty (HEAD exempt).
- Full runs must exercise **GET, POST, PUT, DELETE, and HEAD**; any failures or
missing methods fail the suite.
- Successful **collection** GETs must return a **non-empty** list with `id`
(empty `[]` is a seed/`ov_api_objects` gap — fix data, do not skip).
- Full runs must exercise **GET, POST, PUT, DELETE, and HEAD**; any failures,
missing methods, or `probed != declared` fail the suite (`critical=0` required).
+22 -7
View File
@@ -2,10 +2,10 @@
# Лаборатория Pulumi: покрытие контрактов oVirt
Suite на Pulumi Automation API, который вызывает **каждую операцию** из
**100% coverage** здесь — это **HTTP contract matrix**: каждая операция из
`contracts/ovirt/<series>/api.json` для **всех series packs** Engine, плюс
**синтетический HEAD** для каждого GET-пути (в contracts нет HEAD; Engine его
принимает).
принимает). Это **не** число ресурсов Pulumi provider.
| Series | Операций (примерно) |
|--------|--------------------:|
@@ -16,10 +16,22 @@ make test-pulumi-smoke # выборка 3.6 + 4.5 (быстро)
make pulumi-tests # полная матрица (alias: make test-pulumi)
```
Layer B (опционально): только smoke lifecycle провайдера — не выдавать за
полноту API.
Отчёты (в `reports/`):
- `pulumi-contract-coverage.html` — сводка для человека (включая гистограмму методов)
- `pulumi-contract-coverage.json` — машиночитаемый результат
- `pulumi-contract-coverage.html` — сводка (гистограмма методов:
GET/PUT/POST/DELETE/HEAD)
- `pulumi-contract-coverage.json` — результат + `coverage`
(`probed/declared`, `critical`)
Пример pass-строки:
```text
COVERAGE 9150/9150 (critical=0)
METHODS {"DELETE":1146,"GET":2314,"HEAD":2314,"POST":2230,"PUT":1146}
```
Фильтры:
@@ -29,11 +41,14 @@ OVIRT_METHODS_FILTER=GET make pulumi-tests
SMOKE_ONLY=1 make test-pulumi-smoke
```
Все suites — **только в Docker**. Критерии pass:
Все suites — **только в Docker** (lab compose сидит **minimal** seed).
Критерии pass:
- Маршрут Engine достижим и возвращает обработанный статус (`200`/`201`/`202`/`204`,
`400`/`403`/`404`/`405`/`409`/`415`/`422`/`501`) — **не** `401` (после unload
каждой series suite заново логинится) и не транспортную / `5xx` ошибку.
- Для `200`/`201`/`202` тело ответа должно быть непустым (HEAD исключён).
- Полный прогон должен покрыть **GET, POST, PUT, DELETE и HEAD**; любые failures
или отсутствующие методы валят suite.
- Успешные **collection** GET должны возвращать **непустой** список с `id`
(пустой `[]` — дыра seed/`ov_api_objects`, чинить данные, не skip).
- Полный прогон должен покрыть **GET, POST, PUT, DELETE и HEAD**; любые failures,
отсутствующие методы или `probed != declared` валят suite (`critical=0`).
+1 -1
View File
@@ -51,7 +51,7 @@ services:
depends_on:
simulator:
condition: service_healthy
entrypoint: ["python", "-m", "app.ovirt.seed_cli", "--profile", "demo"]
entrypoint: ["python", "-m", "app.ovirt.seed_cli", "--profile", "minimal"]
api-gateway:
image: nginx:1.28.0-alpine
+231 -12
View File
@@ -42,7 +42,7 @@ def _value_empty(value: Any) -> bool:
return value is None or value == "" or value == [] or value == {}
def _payload_nonempty(response: Any, *, method: str) -> tuple[bool, str]:
def _payload_nonempty(response: Any, *, method: str, kind: str = "") -> tuple[bool, str]:
"""Require non-empty response data for successful body-bearing statuses."""
# HEAD has no body; DELETE often returns 200 with an empty body (Engine-style).
if method in {"HEAD", "DELETE"}:
@@ -61,10 +61,17 @@ def _payload_nonempty(response: Any, *, method: str) -> tuple[bool, str]:
return False, "null JSON body"
if isinstance(data, (list, dict)) and len(data) == 0:
return False, "empty JSON body"
# Declared collection GETs must return durable lab samples (not []).
if method == "GET" and kind == "collection" and isinstance(data, dict):
for value in data.values():
if isinstance(value, list):
if len(value) == 0:
return False, "empty collection list"
first = value[0]
if isinstance(first, dict) and not first.get("id"):
return False, "collection item missing id"
break
if isinstance(data, dict) and all(_value_empty(v) for v in data.values()):
# Allow Engine empty collections: {"vms": []} has structure but no rows.
if len(data) == 1 and isinstance(next(iter(data.values())), list):
return True, ""
return False, "JSON body has only empty fields"
return True, ""
@@ -146,7 +153,47 @@ def synthesize_head_ops(ops: list[dict[str, Any]]) -> list[dict[str, Any]]:
class Inventory:
"""Cache of collection → first entity id for path placeholder expansion."""
"""Cache of collection → entity ids for path placeholder expansion.
PUT/DELETE resolve to *disposable* entities created for the op so the
minimal seed (lab-vm-01, Default DC/cluster, ) is not wiped before later
collection GETs run in contract order.
"""
_ELEMENT = {
"vms": "vm",
"hosts": "host",
"clusters": "cluster",
"datacenters": "data_center",
"networks": "network",
"disks": "disk",
"templates": "template",
"storagedomains": "storage_domain",
"storageconnections": "storage_connection",
"vnicprofiles": "vnic_profile",
"users": "user",
"groups": "group",
"roles": "role",
"tags": "tag",
"bookmarks": "bookmark",
"affinitylabels": "affinity_label",
"instancetypes": "instance_type",
"macpools": "mac_pool",
"schedulingpolicies": "scheduling_policy",
"vmpools": "vm_pool",
"permissions": "permission",
"domains": "domain",
"icons": "icon",
"jobs": "job",
"events": "event",
"nics": "nic",
"snapshots": "snapshot",
"diskattachments": "disk_attachment",
"cdroms": "cdrom",
"graphicsconsoles": "graphics_console",
"quotas": "quota",
"affinitygroups": "affinity_group",
}
def __init__(self, client: OVirtClient, version: str) -> None:
self.client = client
@@ -154,8 +201,12 @@ class Inventory:
self._ids: dict[str, str] = {}
self._listed: set[str] = set()
def id_for(self, collection: str) -> str | None:
def id_for(self, collection: str, *, method: str = "GET") -> str | None:
collection = collection.strip("/")
if method in {"DELETE", "PUT"}:
created = self.create_disposable(collection)
if created:
return created
if collection in self._ids:
return self._ids[collection]
if collection in self._listed:
@@ -172,7 +223,6 @@ class Inventory:
body = r.json()
except Exception:
return None
# Engine collections are usually { "<singular_or_plural>": [ {...}, ... ] }
for value in body.values() if isinstance(body, dict) else []:
if isinstance(value, list) and value:
first = value[0]
@@ -184,6 +234,129 @@ class Inventory:
return self._ids[collection]
return None
def create_disposable(self, collection: str) -> str | None:
"""POST a throwaway entity and return its id (best-effort)."""
element = self._ELEMENT.get(collection) or collection.rstrip("s") or "object"
name = f"pulumi-{uuid4().hex[:8]}"
payload: dict[str, Any] = {
"name": name,
"description": "pulumi disposable",
}
if collection == "clusters":
dc = self.id_for("datacenters", method="GET")
if dc:
payload["data_center"] = {"id": dc}
elif collection == "hosts":
cl = self.id_for("clusters", method="GET")
if cl:
payload["cluster"] = {"id": cl}
payload["address"] = "127.0.0.1"
elif collection == "networks":
dc = self.id_for("datacenters", method="GET")
if dc:
payload["data_center"] = {"id": dc}
elif collection == "vms":
cl = self.id_for("clusters", method="GET")
if cl:
payload["cluster"] = {"id": cl}
tpl = self.id_for("templates", method="GET")
if tpl:
payload["template"] = {"id": tpl}
elif collection == "disks":
sd = self.id_for("storagedomains", method="GET")
if sd:
payload["storage_domains"] = {"storage_domain": [{"id": sd}]}
payload["provisioned_size"] = 1073741824
elif collection == "vnicprofiles":
net = self.id_for("networks", method="GET")
if net:
payload["network"] = {"id": net}
elif collection == "templates":
cl = self.id_for("clusters", method="GET")
if cl:
payload["cluster"] = {"id": cl}
elif collection == "storageconnections":
payload = {
"type": "nfs",
"address": "nfs.pulumi.local",
"path": f"/export/{name}",
}
elif collection == "storagedomains":
payload["type"] = "data"
payload["storage"] = {
"type": "nfs",
"address": "nfs.pulumi.local",
"path": f"/export/{name}",
}
elif collection == "users":
payload = {
"user_name": f"{name}@internal",
"name": name,
"password": "secret",
}
domain = self.id_for("domains", method="GET")
if domain:
payload["domain"] = {"id": domain}
elif collection == "bookmarks":
payload["value"] = "Vms:"
path = f"/ovirt-engine/api/{collection}"
return self._post_for_id(path, element, payload)
def create_disposable_at(self, collection_path: str, collection: str) -> str | None:
"""POST under an already-resolved collection path (nested resources)."""
element = self._ELEMENT.get(collection) or collection.rstrip("s") or "object"
name = f"pulumi-{uuid4().hex[:8]}"
payload: dict[str, Any] = {"name": name, "description": "pulumi disposable"}
if collection == "snapshots":
payload = {"description": name}
elif collection == "diskattachments":
payload = {
"interface": "virtio_scsi",
"bootable": False,
"active": True,
"disk": {
"name": f"{name}-disk",
"provisioned_size": 1073741824,
"format": "cow",
},
}
elif collection == "cdroms":
payload = {"file": {"id": ""}}
elif collection == "graphicsconsoles":
payload = {"protocol": "spice"}
elif collection == "nics":
payload = {"name": name, "interface": "virtio"}
return self._post_for_id(collection_path, element, payload)
def _post_for_id(
self, path: str, element: str, payload: dict[str, Any]
) -> str | None:
try:
r = self.client.request(
"POST",
path,
headers=self.client.headers(version=self.version),
json={element: payload},
)
except Exception:
return None
if r.status_code not in {200, 201, 202}:
return None
try:
body = r.json()
except Exception:
return None
entity = body.get(element) if isinstance(body, dict) else None
if isinstance(entity, dict) and entity.get("id"):
return str(entity["id"])
for value in body.values() if isinstance(body, dict) else []:
if isinstance(value, dict) and value.get("id"):
return str(value["id"])
return None
def _collection_before_param(parts: list[str], index: int) -> str | None:
# /ovirt-engine/api/vms/{id}/nics/{id} → for first {id} use vms, for second use nics
@@ -195,10 +368,11 @@ def _collection_before_param(parts: list[str], index: int) -> str | None:
return prev
def resolve_path(template: str, inventory: Inventory) -> tuple[str, bool]:
def resolve_path(template: str, inventory: Inventory, *, method: str = "GET") -> tuple[str, bool]:
"""Return resolved path and whether every placeholder was satisfied from inventory."""
parts = template.strip("/").split("/")
param_indices = [i for i, part in enumerate(parts) if _PATH_PARAM.fullmatch(part)]
resolved: list[str] = []
complete = True
for i, part in enumerate(parts):
@@ -207,7 +381,29 @@ def resolve_path(template: str, inventory: Inventory) -> tuple[str, bool]:
resolved.append(part)
continue
collection = _collection_before_param(parts, i)
entity_id = inventory.id_for(collection) if collection else None
is_leaf = bool(param_indices) and i == param_indices[-1]
entity_id = None
if collection:
if method in {"DELETE", "PUT"} and is_leaf:
# Only the leaf id is disposable — parents keep seed inventory.
# Never fall back to seed ids for DELETE (would wipe minimal lab).
if collection in Inventory._ELEMENT and collection not in {
"nics",
"snapshots",
"diskattachments",
"cdroms",
"graphicsconsoles",
"quotas",
"affinitygroups",
}:
entity_id = inventory.create_disposable(collection)
else:
parent_path = "/" + "/".join(resolved)
entity_id = inventory.create_disposable_at(parent_path, collection)
if not entity_id and method == "PUT":
entity_id = inventory.id_for(collection, method="GET")
else:
entity_id = inventory.id_for(collection, method="GET")
if entity_id:
resolved.append(entity_id)
else:
@@ -242,7 +438,7 @@ def execute_operation(
expected = int(op.get("create_status") or op.get("status_code") or 200) if method == "POST" else int(
op.get("status_code") or 200
)
path, _complete = resolve_path(template, inventory)
path, _complete = resolve_path(template, inventory, method=method)
body = _minimal_body(op)
started = time.perf_counter()
try:
@@ -254,7 +450,9 @@ def execute_operation(
ok = response.status_code in _PASS_STATUSES
detail = ""
if ok:
body_ok, body_detail = _payload_nonempty(response, method=method)
body_ok, body_detail = _payload_nonempty(
response, method=method, kind=kind
)
if not body_ok:
ok = False
detail = body_detail
@@ -400,11 +598,32 @@ def run_coverage(cfg: SuiteConfig) -> CoverageReport:
def report_to_dict(report: CoverageReport) -> dict[str, Any]:
totals = report.totals
declared = totals["total"]
probed = totals["passed"] + totals["failed"] + totals["skipped"]
critical = totals["failed"]
series_coverage = []
for s in report.series:
series_coverage.append(
{
"series": s.series,
"declared": s.total,
"probed": s.passed + s.failed + s.skipped,
"critical": s.failed,
}
)
return {
"generated_at": report.generated_at,
"engine_url": report.engine_url,
"totals": report.totals,
"totals": totals,
"methods": report.methods,
"coverage": {
"declared": declared,
"probed": probed,
"critical": critical,
"line": f"{probed}/{declared}",
"series": series_coverage,
},
"series": [asdict(s) for s in report.series],
"results": [asdict(r) for r in report.results],
}
+3 -3
View File
@@ -121,10 +121,10 @@ def render_html(payload: dict[str, Any]) -> str:
· Engine {html.escape(str(payload.get('engine_url')))}
</div>
<div class="cards">
<div class="card"><div class="label">Total</div><div class="value">{totals.get('total', 0)}</div></div>
<div class="card"><div class="label">Total / Declared</div><div class="value">{totals.get('total', 0)}</div></div>
<div class="card ok"><div class="label">Passed</div><div class="value">{totals.get('passed', 0)}</div></div>
<div class="card bad"><div class="label">Failed</div><div class="value">{totals.get('failed', 0)}</div></div>
<div class="card"><div class="label">Skipped</div><div class="value">{totals.get('skipped', 0)}</div></div>
<div class="card bad"><div class="label">Critical</div><div class="value">{(payload.get('coverage') or {}).get('critical', totals.get('failed', 0))}</div></div>
<div class="card"><div class="label">Coverage</div><div class="value">{html.escape(str((payload.get('coverage') or {}).get('line', f"{totals.get('total', 0)}/{totals.get('total', 0)}")))}</div></div>
</div>
<h2>By HTTP method</h2>
+9 -1
View File
@@ -52,6 +52,7 @@ def main() -> int:
"passed": totals["passed"],
"failed": totals["failed"],
"skipped": totals["skipped"],
"coverage": payload.get("coverage"),
"methods": methods,
"series": series_names,
"report_json": str(json_path),
@@ -86,7 +87,12 @@ def main() -> int:
failed = int(outputs.get("failed") or totals["failed"])
total = int(outputs.get("total") or totals["total"])
passed = int(outputs.get("passed") or totals["passed"])
coverage = payload.get("coverage") or {}
declared = int(coverage.get("declared") or total)
probed = int(coverage.get("probed") or (passed + failed + int(totals.get("skipped") or 0)))
critical = int(coverage.get("critical") or failed)
print(f"SUMMARY total={total} passed={passed} failed={failed}", flush=True)
print(f"COVERAGE {probed}/{declared} (critical={critical})", flush=True)
print(f"METHODS {json.dumps(methods, sort_keys=True)}", flush=True)
print(f"HTML report: {html_path}", flush=True)
@@ -94,7 +100,9 @@ def main() -> int:
missing_methods = sorted(_FULL_RUN_METHODS - set(methods)) if full_run else []
if missing_methods:
print(f"MISSING METHODS on full run: {', '.join(missing_methods)}", flush=True)
if failed or missing_methods:
if probed != declared:
print(f"COVERAGE MISMATCH probed={probed} declared={declared}", flush=True)
if failed or missing_methods or critical or probed != declared:
return 1
return 0
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,80 @@
"passed": 9150,
"failed": 0,
"skipped": 0,
"coverage": {
"declared": 9150,
"probed": 9150,
"critical": 0,
"line": "9150/9150",
"series": [
{
"series": "3.0",
"declared": 622,
"probed": 622,
"critical": 0
},
{
"series": "3.1",
"declared": 664,
"probed": 664,
"critical": 0
},
{
"series": "3.2",
"declared": 672,
"probed": 672,
"critical": 0
},
{
"series": "3.3",
"declared": 770,
"probed": 770,
"critical": 0
},
{
"series": "3.4",
"declared": 800,
"probed": 800,
"critical": 0
},
{
"series": "3.5",
"declared": 858,
"probed": 858,
"critical": 0
},
{
"series": "3.6",
"declared": 918,
"probed": 918,
"critical": 0
},
{
"series": "4.3",
"declared": 948,
"probed": 948,
"critical": 0
},
{
"series": "4.4",
"declared": 966,
"probed": 966,
"critical": 0
},
{
"series": "4.5",
"declared": 966,
"probed": 966,
"critical": 0
},
{
"series": "master",
"declared": 966,
"probed": 966,
"critical": 0
}
]
},
"methods": {
"DELETE": 1146,
"GET": 2314,
+31 -8
View File
@@ -9,18 +9,41 @@ import requests
def discover_base_url() -> str:
for candidate in (
os.environ.get("OVIRT_TEST_URL"),
"https://api-gateway",
"""Prefer OVIRT_TEST_URL / OVIRT_ENGINE_PORT; require Engine SSO path (not a foreign :443)."""
port = (os.environ.get("OVIRT_ENGINE_PORT") or "").strip()
candidates: list[str] = []
if os.environ.get("OVIRT_TEST_URL"):
candidates.append(os.environ["OVIRT_TEST_URL"].rstrip("/"))
candidates.append("https://api-gateway")
if port and port != "443":
candidates.append(f"https://127.0.0.1:{port}")
candidates.extend(
[
"https://127.0.0.1:7443",
"https://127.0.0.1:6443",
"https://127.0.0.1",
"https://127.0.0.1:9443",
):
if not candidate:
]
)
seen: set[str] = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
try:
r = requests.get(f"{candidate.rstrip('/')}/health/live", timeout=3, verify=False)
if r.status_code == 200:
return candidate.rstrip("/")
live = requests.get(f"{candidate}/health/live", timeout=3, verify=False)
if live.status_code != 200:
continue
# Distinguish this lab from other listeners on :443.
probe = requests.get(
f"{candidate}/ovirt-engine/api/",
headers={"Accept": "application/json"},
timeout=3,
verify=False,
)
if probe.status_code in {200, 401}:
return candidate
except Exception:
continue
pytest.skip("no running oVirt simulator gateway")
+282
View File
@@ -0,0 +1,282 @@
"""Nested inventory + affinity/quota realism after minimal seed."""
from __future__ import annotations
import uuid
import pytest
import requests
from app.ovirt.ids import stable_id
from .conftest import auth_headers, collection_items, oauth_token
pytestmark = pytest.mark.integration
@pytest.fixture(scope="module")
def session_ctx():
from .conftest import discover_base_url
base = discover_base_url()
token = oauth_token(base)
return base, auth_headers(token, version="4")
def _ids() -> dict[str, str]:
return {
"vm": str(stable_id("vm", "lab-vm-01")),
"dc": str(stable_id("dc", "Default")),
"cluster": str(stable_id("cluster", "Default")),
"host": str(stable_id("host", "host01")),
"nic": str(stable_id("nic", "lab-vm-01")),
"snap": str(stable_id("snap", "lab-vm-01", "1")),
"user": str(stable_id("user", "admin")),
"net": str(stable_id("net", "ovirtmgmt")),
}
def test_nested_seeded_collections_non_empty(session_ctx) -> None:
base, headers = session_ctx
ids = _ids()
probes = [
(f"/ovirt-engine/api/datacenters/{ids['dc']}/quotas", "quota"),
(f"/ovirt-engine/api/clusters/{ids['cluster']}/affinitygroups", "affinity_group"),
(f"/ovirt-engine/api/vms/{ids['vm']}/nics", "nic"),
(f"/ovirt-engine/api/vms/{ids['vm']}/snapshots", "snapshot"),
(f"/ovirt-engine/api/vms/{ids['vm']}/diskattachments", "disk_attachment"),
(f"/ovirt-engine/api/vms/{ids['vm']}/graphicsconsoles", "graphics_console"),
(f"/ovirt-engine/api/vms/{ids['vm']}/mediateddevices", "vm_mediated_device"),
(f"/ovirt-engine/api/vms/{ids['vm']}/affinitylabels", "affinity_label"),
(f"/ovirt-engine/api/hosts/{ids['host']}/nics", "nic"),
(f"/ovirt-engine/api/hosts/{ids['host']}/storage", "host_storage"),
(f"/ovirt-engine/api/clusters/{ids['cluster']}/glustervolumes", "gluster_volume"),
(f"/ovirt-engine/api/networks/{ids['net']}/networklabels", "network_label"),
(f"/ovirt-engine/api/users/{ids['user']}/sshpublickeys", "ssh_public_key"),
("/ovirt-engine/api/affinitygroups", "affinity_group"),
("/ovirt-engine/api/quotas", "quota"),
]
for path, element in probes:
r = requests.get(f"{base}{path}", headers=headers, verify=False, timeout=60)
assert r.status_code == 200, f"{path}: {r.status_code} {r.text[:200]}"
items = collection_items(r.json(), element)
assert len(items) >= 1, f"{path}: expected non-empty {element}"
assert items[0].get("id") and items[0].get("href")
def test_job_steps_and_event_entity_href(session_ctx) -> None:
base, headers = session_ctx
jobs = requests.get(f"{base}/ovirt-engine/api/jobs", headers=headers, verify=False, timeout=60)
assert jobs.status_code == 200
job = collection_items(jobs.json(), "job")[0]
steps = requests.get(
f"{base}/ovirt-engine/api/jobs/{job['id']}/steps",
headers=headers,
verify=False,
timeout=60,
)
assert steps.status_code == 200
step = collection_items(steps.json(), "step")[0]
assert step.get("href") == f"/ovirt-engine/api/jobs/{job['id']}/steps/{step['id']}"
one = requests.get(
f"{base}/ovirt-engine/api/jobs/{job['id']}/steps/{step['id']}",
headers=headers,
verify=False,
timeout=60,
)
assert one.status_code == 200
assert one.json()["step"]["href"]
events = requests.get(
f"{base}/ovirt-engine/api/events", headers=headers, verify=False, timeout=60
)
assert events.status_code == 200
ev = collection_items(events.json(), "event")[0]
one_ev = requests.get(
f"{base}/ovirt-engine/api/events/{ev['id']}",
headers=headers,
verify=False,
timeout=60,
)
assert one_ev.status_code == 200
assert one_ev.json()["event"].get("href")
def test_vm_tag_and_permission_get_by_id(session_ctx) -> None:
base, headers = session_ctx
ids = _ids()
tags = requests.get(
f"{base}/ovirt-engine/api/vms/{ids['vm']}/tags",
headers=headers,
verify=False,
timeout=60,
)
assert tags.status_code == 200
tag = collection_items(tags.json(), "tag")[0]
one_tag = requests.get(
f"{base}/ovirt-engine/api/vms/{ids['vm']}/tags/{tag['id']}",
headers=headers,
verify=False,
timeout=60,
)
assert one_tag.status_code == 200, one_tag.text
assert one_tag.json()["tag"]["id"] == tag["id"]
perms = requests.get(
f"{base}/ovirt-engine/api/vms/{ids['vm']}/permissions",
headers=headers,
verify=False,
timeout=60,
)
assert perms.status_code == 200
perm = collection_items(perms.json(), "permission")[0]
one_perm = requests.get(
f"{base}/ovirt-engine/api/vms/{ids['vm']}/permissions/{perm['id']}",
headers=headers,
verify=False,
timeout=60,
)
assert one_perm.status_code == 200, one_perm.text
assert one_perm.json()["permission"]["id"] == perm["id"]
assert one_perm.json()["permission"].get("href")
def test_nic_and_snapshot_get_by_id(session_ctx) -> None:
base, headers = session_ctx
ids = _ids()
nic = requests.get(
f"{base}/ovirt-engine/api/vms/{ids['vm']}/nics/{ids['nic']}",
headers=headers,
verify=False,
timeout=60,
)
assert nic.status_code == 200, nic.text
assert nic.json()["nic"]["id"] == ids["nic"]
snap = requests.get(
f"{base}/ovirt-engine/api/vms/{ids['vm']}/snapshots/{ids['snap']}",
headers=headers,
verify=False,
timeout=60,
)
assert snap.status_code == 200, snap.text
assert snap.json()["snapshot"]["id"] == ids["snap"]
def test_affinity_group_create_returns_entity(session_ctx) -> None:
base, headers = session_ctx
cluster = str(stable_id("cluster", "Default"))
name = f"ag-{uuid.uuid4().hex[:8]}"
r = requests.post(
f"{base}/ovirt-engine/api/clusters/{cluster}/affinitygroups",
headers=headers,
json={"affinity_group": {"name": name, "enforcing": False}},
verify=False,
timeout=60,
)
assert r.status_code == 201, r.text
body = r.json()["affinity_group"]
assert body["name"] == name
assert body["id"] and "/affinitygroups/" in body["href"]
listing = requests.get(
f"{base}/ovirt-engine/api/clusters/{cluster}/affinitygroups",
headers=headers,
verify=False,
timeout=60,
)
names = [i["name"] for i in collection_items(listing.json(), "affinity_group")]
assert name in names
def test_quota_create_and_read_after_write(session_ctx) -> None:
base, headers = session_ctx
dc = str(stable_id("dc", "Default"))
name = f"quota-{uuid.uuid4().hex[:8]}"
created = requests.post(
f"{base}/ovirt-engine/api/datacenters/{dc}/quotas",
headers=headers,
json={"quota": {"name": name, "description": "lab"}},
verify=False,
timeout=60,
)
assert created.status_code == 201, created.text
qid = created.json()["quota"]["id"]
detail = requests.get(
f"{base}/ovirt-engine/api/datacenters/{dc}/quotas/{qid}",
headers=headers,
verify=False,
timeout=60,
)
assert detail.status_code == 200
assert detail.json()["quota"]["name"] == name
def test_template_create_from_vm_copies_nested(session_ctx) -> None:
base, headers = session_ctx
vm = str(stable_id("vm", "lab-vm-01"))
name = f"tpl-{uuid.uuid4().hex[:8]}"
created = requests.post(
f"{base}/ovirt-engine/api/templates",
headers=headers,
json={"template": {"name": name, "vm": {"id": vm}}},
verify=False,
timeout=60,
)
assert created.status_code == 201, created.text
tid = created.json()["template"]["id"]
nics = requests.get(
f"{base}/ovirt-engine/api/templates/{tid}/nics",
headers=headers,
verify=False,
timeout=60,
)
assert nics.status_code == 200
assert len(collection_items(nics.json(), "nic")) >= 1
das = requests.get(
f"{base}/ovirt-engine/api/templates/{tid}/diskattachments",
headers=headers,
verify=False,
timeout=60,
)
assert das.status_code == 200
assert len(collection_items(das.json(), "disk_attachment")) >= 1
def test_vm_clone_copies_nics_and_disks(session_ctx) -> None:
base, headers = session_ctx
vm = str(stable_id("vm", "lab-vm-01"))
clone_name = f"clone-{uuid.uuid4().hex[:8]}"
action = requests.post(
f"{base}/ovirt-engine/api/vms/{vm}/clone",
headers=headers,
json={"action": {"vm": {"name": clone_name}}},
verify=False,
timeout=60,
)
assert action.status_code == 200, action.text
assert "job" in action.json().get("action", {})
listing = requests.get(
f"{base}/ovirt-engine/api/vms",
headers=headers,
verify=False,
timeout=60,
)
vms = {v["name"]: v for v in collection_items(listing.json(), "vm")}
assert clone_name in vms
clone_id = vms[clone_name]["id"]
nics = requests.get(
f"{base}/ovirt-engine/api/vms/{clone_id}/nics",
headers=headers,
verify=False,
timeout=60,
)
assert len(collection_items(nics.json(), "nic")) >= 1
das = requests.get(
f"{base}/ovirt-engine/api/vms/{clone_id}/diskattachments",
headers=headers,
verify=False,
timeout=60,
)
assert len(collection_items(das.json(), "disk_attachment")) >= 1
+33
View File
@@ -0,0 +1,33 @@
"""Cluster size specs for demo seed profiles."""
from __future__ import annotations
import pytest
from app.ovirt.demo_datacenter import CLUSTER_SIZES, normalize_cluster_size
@pytest.mark.parametrize(
("name", "hosts", "vms"),
[
("small", 3, 50),
("large", 10, 1000),
("big", 30, 2000),
],
)
def test_cluster_size_targets(name: str, hosts: int, vms: int) -> None:
spec = CLUSTER_SIZES[name]
assert spec.hosts == hosts
assert spec.vms == vms
topology = spec.datacenters * spec.clusters_per_dc * spec.hosts_per_cluster
assert topology == hosts
def test_demo_alias_maps_to_large() -> None:
assert normalize_cluster_size("demo") == "large"
assert normalize_cluster_size("LARGE") == "large"
def test_unknown_size_raises() -> None:
with pytest.raises(ValueError):
normalize_cluster_size("huge")
+104
View File
@@ -0,0 +1,104 @@
"""Console body examples use minimal-seed IDs and Engine-shaped roots."""
from __future__ import annotations
from app.ovirt.ids import stable_id
from app.web.ovirt_body_examples import body_example_for
from app.web.ovirt_catalog import path_param_example
def test_path_params_use_minimal_seed_ids() -> None:
assert path_param_example("vmId") == str(stable_id("vm", "lab-vm-01"))
assert path_param_example("hostId") == str(stable_id("host", "host01"))
assert path_param_example("clusterId") == str(stable_id("cluster", "Default"))
assert path_param_example("storageDomainId") == str(stable_id("sd", "data1"))
def test_post_vm_body_is_root_wrapped_with_cluster_ref() -> None:
body = body_example_for(method="POST", kind="collection", element="vm", path="/vms")
assert body is not None
assert "vm" in body
vm = body["vm"]
assert vm["cluster"]["id"] == str(stable_id("cluster", "Default"))
assert vm["template"]["name"] == "Blank"
def test_disk_attachment_creates_disk_inline() -> None:
body = body_example_for(
method="POST",
kind="collection",
element="disk_attachment",
path="/vms/{vm_id}/diskattachments",
)
assert body is not None
disk = body["disk_attachment"]["disk"]
assert "id" not in disk
assert disk["name"] == "example-attached-disk"
assert disk["provisioned_size"] == 10737418240
def test_put_does_not_rename_entity() -> None:
body = body_example_for(method="PUT", kind="item", element="vm", path="/vms/{vm_id}")
assert body is not None
assert "name" not in body["vm"]
assert body["vm"]["description"].startswith("Updated")
def test_action_start_uses_action_root() -> None:
body = body_example_for(
method="POST", kind="action", element="action", path="/vms/{vm_id}/start"
)
assert body == {"action": {"async": True}}
def test_body_fields_derived_from_example_for_params_drawer() -> None:
from app.web.ovirt_catalog import _body_fields_from_example, ovirt_method_payload
fields = _body_fields_from_example(
{"bookmark": {"name": "example-bookmark", "value": "Vms: status=up"}},
element="bookmark",
)
names = {f["name"] for f in fields}
assert names == {"name", "value"}
payload = ovirt_method_payload(
major=45,
path="/ovirt-engine/api/bookmarks",
verb="POST",
runtime_version="ovirt-4.5",
)
assert payload["body_example"] is not None
assert "bookmark" in payload["body_example"]
field_names = {f["name"] for f in payload["body_fields"]}
assert "name" in field_names
assert "value" in field_names
def test_body_fields_include_nested_vm_example_paths() -> None:
from app.web.ovirt_catalog import _body_fields_from_example, ovirt_method_payload
from app.web.ovirt_body_examples import body_example_for
example = body_example_for(
method="POST",
kind="collection",
element="vm",
path="/ovirt-engine/api/vms",
)
fields = _body_fields_from_example(example, element="vm")
names = {f["name"] for f in fields}
assert "name" in names
assert "memory" in names
assert "cpu.topology.sockets" in names
assert "os.type" in names
assert "cluster.id" in names
assert "template.name" in names
payload = ovirt_method_payload(
major=45,
path="/ovirt-engine/api/vms",
verb="POST",
runtime_version="ovirt-4.5",
)
nested = {f["name"] for f in payload["body_fields"]}
assert "cluster.id" in nested
assert "cpu.topology.cores" in nested
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Contract-driven dump audit for minimal/demo seed against live Engine.
Probes every GET collection and entity-by-id from contracts/ovirt/<series>/api.json.
Nested `{id}` is resolved from the parent nested collection (not a global map).
Exit 0 only at 100% dump.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ENGINE = sys.argv[1] if len(sys.argv) > 1 else "https://127.0.0.1:7443"
SERIES = sys.argv[2] if len(sys.argv) > 2 else "4.5"
CONTRACT = ROOT / "contracts" / "ovirt" / SERIES / "api.json"
CURL = [
"/usr/bin/curl",
"-sk",
"-u",
"admin@internal:secret",
"-H",
"Accept: application/json",
"-H",
"Version: 4",
]
PARAM_RE = re.compile(r"\{([^}]+)\}")
PLURAL_MAP = {
"datacenter": "datacenters",
"cluster": "clusters",
"host": "hosts",
"vm": "vms",
"network": "networks",
"storagedomain": "storagedomains",
"template": "templates",
"disk": "disks",
"user": "users",
"role": "roles",
"job": "jobs",
"group": "groups",
"domain": "domains",
"tag": "tags",
"vnicprofile": "vnicprofiles",
"schedulingpolicy": "schedulingpolicies",
"bookmark": "bookmarks",
"event": "events",
"icon": "icons",
"instancetype": "instancetypes",
"macpool": "macpools",
"networkfilter": "networkfilters",
"operatingsystem": "operatingsystems",
"permission": "permissions",
"storageconnection": "storageconnections",
"vmpool": "vmpools",
"affinitylabel": "affinitylabels",
"clusterlevel": "clusterlevels",
"externalhostprovider": "externalhostproviders",
"katelloerratum": "katelloerrata",
"openstackimageprovider": "openstackimageproviders",
"openstacknetworkprovider": "openstacknetworkproviders",
"openstackvolumeprovider": "openstackvolumeproviders",
"imagetransfer": "imagetransfers",
"option": "options",
"schedulingpolicyunit": "schedulingpolicyunits",
}
def get(path: str) -> tuple[int, object]:
url = f"{ENGINE}{path}" if path.startswith("/") else f"{ENGINE}/{path}"
out = subprocess.check_output(CURL + ["-o", "/tmp/_dump_body.json", "-w", "%{http_code}", url])
code = int(out.decode().strip())
raw = Path("/tmp/_dump_body.json").read_bytes()
try:
payload = json.loads(raw) if raw.strip() else None
except Exception:
payload = raw.decode(errors="replace")[:200]
return code, payload
def items(payload: object) -> list[dict]:
if not isinstance(payload, dict):
return []
for value in payload.values():
if isinstance(value, list):
return [x for x in value if isinstance(x, dict)]
if isinstance(value, dict) and value.get("id"):
return [value]
return []
def first_id(payload: object) -> str | None:
arr = items(payload)
return arr[0].get("id") if arr else None
def skip_versioned(path: str) -> bool:
return "/api/v4/" in path or path.rstrip("/").endswith("/api/v4")
def is_collection_get(op: dict) -> bool:
if op.get("method") != "GET" or skip_versioned(op["path"]):
return False
last = op["path"].rstrip("/").split("/")[-1]
return not last.startswith("{")
def is_entity_get(op: dict) -> bool:
if op.get("method") != "GET" or skip_versioned(op["path"]):
return False
last = op["path"].rstrip("/").split("/")[-1]
return last.startswith("{") and last.endswith("}")
def param_to_collection(param: str) -> str:
if param.endswith("_id"):
base = param[: -len("_id")]
return PLURAL_MAP.get(base, base + "s")
return PLURAL_MAP.get(param, param)
def resolve_parents(path: str, inventory: dict[str, str]) -> str | None:
"""Replace all {foo_id} parent params; leave {id} untouched."""
out = path
for p in PARAM_RE.findall(path):
if p == "id":
continue
coll = param_to_collection(p)
if coll not in inventory:
return None
out = out.replace("{" + p + "}", inventory[coll])
return out
def resolve_entity(path: str, inventory: dict[str, str], collection_key: str | None) -> str | None:
"""Resolve entity path including nested {id} via live nested collection fetch."""
partial = resolve_parents(path, inventory)
if partial is None:
return None
if "{id}" not in partial:
return partial
# Nested: /…/parent/{pid}/sub/{id} → fetch …/sub for an id
# Top-level: /…/collection/{id}
if partial.count("/") >= 5 and not partial.rstrip("/").endswith("/{id}"):
# shouldn't happen
pass
parent_coll_path = partial.rsplit("/{id}", 1)[0]
# For top-level /api/bookmarks/{id}, parent_coll_path is the collection URL
code, payload = get(parent_coll_path)
if code != 200:
return None
fid = first_id(payload)
if not fid:
# top-level may need inventory by collection_key
key = collection_key or parent_coll_path.rstrip("/").split("/")[-1]
if key == "imageTransfers":
key = "imagetransfers"
fid = inventory.get(key)
if not fid:
return None
return partial.replace("{id}", fid)
def good_entity(payload: object) -> bool:
arr = items(payload)
return len(arr) == 1 and bool(arr[0].get("id")) and bool(arr[0].get("href"))
def good_collection(payload: object) -> bool:
arr = items(payload)
return len(arr) >= 1 and all(i.get("id") and i.get("href") for i in arr[:5])
def main() -> int:
ops = json.loads(CONTRACT.read_text())["operations"]
collections = [o for o in ops if is_collection_get(o)]
entities = [o for o in ops if is_entity_get(o)]
inventory: dict[str, str] = {}
for op in collections:
if "{" in op["path"] or op["path"].rstrip("/").endswith("/api"):
continue
_, payload = get(op["path"])
fid = first_id(payload)
key = op.get("collection_key") or op["path"].rstrip("/").split("/")[-1]
if key == "imageTransfers":
key = "imagetransfers"
if fid:
inventory[key] = fid
inventory[key.lower()] = fid
gaps: list[str] = []
ok = 0
probed = 0
for op in collections:
template = op["path"]
resolved = resolve_parents(template, inventory)
probed += 1
if resolved is None:
gaps.append(f"UNRESOLVED {template}")
continue
code, payload = get(resolved)
if template.rstrip("/").endswith("/api"):
good = code == 200 and isinstance(payload, dict) and bool(payload)
else:
good = code == 200 and good_collection(payload)
if good:
ok += 1
else:
n = len(items(payload)) if isinstance(payload, dict) else 0
gaps.append(f"GET {resolved} -> {code} count={n}")
entity_ok = 0
entity_probed = 0
for op in entities:
template = op["path"]
entity_probed += 1
probed += 1
resolved = resolve_entity(template, inventory, op.get("collection_key"))
if resolved is None:
gaps.append(f"UNRESOLVED {template}")
continue
code, payload = get(resolved)
if code == 200 and good_entity(payload):
entity_ok += 1
ok += 1
else:
gaps.append(f"GET {resolved} -> {code}")
_, vms = get("/ovirt-engine/api/vms")
_, hosts = get("/ovirt-engine/api/hosts")
_, dcs = get("/ovirt-engine/api/datacenters")
print(f"ENGINE={ENGINE} SERIES={SERIES}")
print(
f"DUMP {ok}/{probed} contract GETs "
f"(collections+entities; entities {entity_ok}/{entity_probed})"
)
print(
f"DENSITY vms={len(items(vms))} hosts={len(items(hosts))} "
f"datacenters={len(items(dcs))} inventory_keys={len(inventory)}"
)
if gaps:
print(f"GAPS ({len(gaps)}):")
for g in gaps:
print(" ", g)
else:
print("GAPS: none")
return 0 if not gaps and ok == probed else 1
if __name__ == "__main__":
raise SystemExit(main())