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 COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/ovirt-api-simulator HELM_CHART ?= ./helm/ovirt-api-simulator
LAB ?= ./pulumi-tests 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 \ .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 \ helm-template generate-packs \
release release-build release-up release-down release-seed \ release release-build release-up release-down release-seed \
test-pulumi-smoke test-pulumi pulumi-tests \ 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 help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @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 @test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --wait $(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 down: ## Stop local services
$(COMPOSE) down $(COMPOSE) down
@@ -85,12 +137,27 @@ logs: ## Follow logs
seed: ## Seed minimal lab seed: ## Seed minimal lab
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile minimal $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile minimal
seed-demo: ## Seed demo datacenter (~1000 VMs) seed-small: ## Seed small cluster (3 hosts · 50 VMs)
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile demo $(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 smoke: ## Quick Engine auth + list VMs smoke
@curl -skf -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \ @set -a; [ -f .env ] && . ./.env; set +a; \
https://127.0.0.1:$${OVIRT_ENGINE_PORT:-443}/ovirt-engine/api/vms >/dev/null 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" @echo "smoke ok"
helm-template: ## Render Helm chart 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 clean-test-resources: ## Cleanup lab-created resources
$(MAKE) -C $(LAB) clean-test-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 ```bash
cp .env.example .env cp .env.example .env
make up make up
make seed # or: make seed-demo (~1000 VMs) make seed # or: make seed-small|seed-large|seed-big
``` ```
| Surface | URL | | Surface | URL |
@@ -73,7 +73,7 @@ Or: `make release-up && make release-seed PROFILE=minimal`
- XML + JSON representations (`Accept` / `Content-Type`) - XML + JSON representations (`Accept` / `Content-Type`)
- Series packs **3.03.6, 4.34.5, master** with operation deltas - Series packs **3.03.6, 4.34.5, master** with operation deltas
- Stateful PostgreSQL inventory + async jobs - 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`) - Web UI with oVirt branding (`#0076B6` / charcoal `#1D2226`)
- Docker Compose + Helm - Docker Compose + Helm
- API tests + [`pulumi-tests/`](pulumi-tests/README.md) (Pulumi contract coverage) - 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 ```bash
make up 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
make seed-demo make seed-large
make test-unit make test-unit
make test-integration make test-integration
make test-pulumi-smoke # Pulumi smoke make test-pulumi-smoke # Pulumi smoke
make test-pulumi # all series × all contract ops + HTML report make test-pulumi # all series × all contract ops + HTML report
make pulumi-tests # alias for test-pulumi make pulumi-tests # alias for test-pulumi
make test-all # 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 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 | | [Authentication](docs/authentication.md) | Basic, OAuth2, sessions |
| [API versions](docs/api-versions.md) | Series packs and Version header | | [API versions](docs/api-versions.md) | Series packs and Version header |
| [Configuration](docs/configuration.md) | Environment and Compose | | [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 | | [Kubernetes / Helm](docs/kubernetes.md) | Cluster install |
| [Full index](docs/README.md) | All guides | | [Full index](docs/README.md) | All guides |
+8 -4
View File
@@ -20,7 +20,7 @@ oVirt Engine. Семантические обработчики сохраняю
```bash ```bash
cp .env.example .env cp .env.example .env
make up make up
make seed # или: make seed-demo (~1000 ВМ) make seed # или: make seed-small|seed-large|seed-big
``` ```
| Поверхность | URL | | Поверхность | URL |
@@ -76,7 +76,7 @@ curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4'
- Представления XML + JSON (`Accept` / `Content-Type`) - Представления XML + JSON (`Accept` / `Content-Type`)
- Series packs **3.03.6, 4.34.5, master** с дельтами операций - Series packs **3.03.6, 4.34.5, master** с дельтами операций
- Stateful-инвентарь в PostgreSQL + асинхронные jobs - Stateful-инвентарь в PostgreSQL + асинхронные jobs
- Профили seed: `minimal` и `demo` (~1000 ВМ) - Профили seed: `minimal`, `small` (3h/50vm), `large` (10h/1000vm), `big` (30h/2000vm)
- Web UI с брендингом oVirt (`#0076B6` / charcoal `#1D2226`) - Web UI с брендингом oVirt (`#0076B6` / charcoal `#1D2226`)
- Docker Compose + Helm - Docker Compose + Helm
- API-тесты + [`pulumi-tests/`](pulumi-tests/README.ru.md) (покрытие контрактов Pulumi) - 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 ```bash
make up make up
make up-local # локальные порты через gitignored docker-compose.override.yml
make down-local # остановить стек + удалить local override
make seed make seed
make seed-demo make seed-large
make test-unit make test-unit
make test-integration make test-integration
make test-pulumi-smoke # Pulumi smoke make test-pulumi-smoke # Pulumi smoke
make test-pulumi # все series × все contract ops + HTML-отчёт make test-pulumi # все series × все contract ops + HTML-отчёт
make pulumi-tests # alias для test-pulumi make pulumi-tests # alias для test-pulumi
make test-all # 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; см. Публикация в Docker Hub (нужен `docker login` владельца Hub; см.
@@ -114,7 +118,7 @@ make release-up && make release-seed # запустить опубликова
| [Аутентификация](docs/ru/authentication.md) | Basic, OAuth2, сессии | | [Аутентификация](docs/ru/authentication.md) | Basic, OAuth2, сессии |
| [Версии API](docs/ru/api-versions.md) | Series packs и заголовок Version | | [Версии API](docs/ru/api-versions.md) | Series packs и заголовок Version |
| [Конфигурация](docs/ru/configuration.md) | Окружение и Compose | | [Конфигурация](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) | Установка в кластер | | [Kubernetes / Helm](docs/ru/kubernetes.md) | Установка в кластер |
| [Полный индекс](docs/ru/README.md) | Все руководства | | [Полный индекс](docs/ru/README.md) | Все руководства |
+2 -2
View File
@@ -38,7 +38,7 @@ def create_lifespan(
await database.connect() await database.connect()
app.state.database = database app.state.database = database
if isinstance(database, AsyncpgDatabase): 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.seed import seed_ovirt
from app.ovirt.settings import seed_engine_options from app.ovirt.settings import seed_engine_options
@@ -49,7 +49,7 @@ def create_lifespan(
) )
except Exception: except Exception:
profile = None profile = None
if profile != DEMO_PROFILE: if profile not in DEMO_PROFILES:
await seed_ovirt(connection) await seed_ovirt(connection)
else: else:
# Keep Engine options current without wiping demo inventory. # Keep Engine options current without wiping demo inventory.
+259 -81
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 from __future__ import annotations
import json import json
from dataclasses import dataclass
from typing import Any from typing import Any
from asyncpg import Connection from asyncpg import Connection
from app.ovirt.ids import stable_id 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 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]: CLUSTER_SIZES: dict[str, ClusterSizeSpec] = {
"""Replace state with a multi-DC demo inventory including ~1000 VMs.""" "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) await clear_ovirt_state(conn)
@@ -39,7 +213,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
admin, admin,
) )
users = {} users: dict[str, Any] = {}
for uname, role in ( for uname, role in (
("admin", role_super), ("admin", role_super),
("ops", role_cluster), ("ops", role_cluster),
@@ -70,7 +244,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
stable_id("group", "engine-admins"), stable_id("group", "engine-admins"),
domain_id, domain_id,
) )
for gname in ("developers", "operators", "readers"): for gname in spec.groups:
await conn.execute( await conn.execute(
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,$3)", "INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,$3)",
stable_id("group", gname), stable_id("group", gname),
@@ -78,20 +252,14 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
gname, gname,
) )
# 3 datacenters, multiple clusters/hosts/storage/networks dc_specs = list(_DC_NAMES[: spec.datacenters])
dc_specs = [
("dc-prod", "Production", False, 4, 5),
("dc-stage", "Staging", False, 4, 4),
("dc-edge", "Edge", True, 4, 3),
]
clusters: list[tuple[Any, Any, str]] = [] clusters: list[tuple[Any, Any, str]] = []
hosts: list[Any] = [] hosts: list[Any] = []
networks: list[Any] = [] networks: list[Any] = []
profiles: list[Any] = [] profiles: list[Any] = []
storage_domains: list[Any] = [] storage_domains: list[Any] = []
storage_types = ["nfs", "iscsi", "fcp", "localfs"] for dc_idx, (dc_key, dc_name, local, maj, minor) in enumerate(dc_specs):
for dc_key, dc_name, local, maj, minor in dc_specs:
dc_id = stable_id("dc", dc_key) dc_id = stable_id("dc", dc_key)
await conn.execute( await conn.execute(
"""INSERT INTO ov_datacenters(id, name, description, local, status, version_major, version_minor) """INSERT INTO ov_datacenters(id, name, description, local, status, version_major, version_minor)
@@ -109,7 +277,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
stable_id("quota", dc_key), stable_id("quota", dc_key),
dc_id, dc_id,
) )
for ci in range(2): for ci in range(spec.clusters_per_dc):
cname = f"{dc_key}-cluster-{ci + 1}" cname = f"{dc_key}-cluster-{ci + 1}"
cid = stable_id("cluster", cname) cid = stable_id("cluster", cname)
clusters.append((cid, dc_id, cname)) clusters.append((cid, dc_id, cname))
@@ -129,7 +297,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
stable_id("ag", cname), stable_id("ag", cname),
cid, cid,
) )
for hi in range(4): for hi in range(spec.hosts_per_cluster):
hname = f"{cname}-host-{hi + 1:02d}" hname = f"{cname}-host-{hi + 1:02d}"
hid = stable_id("host", hname) hid = stable_id("host", hname)
hosts.append(hid) hosts.append(hid)
@@ -139,19 +307,27 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
hid, hid,
cid, cid,
hname, 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, (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) nid = stable_id("net", dc_key, nname)
networks.append(nid) 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( await conn.execute(
"""INSERT INTO ov_networks(id, datacenter_id, name, description, vlan_id) """INSERT INTO ov_networks(id, datacenter_id, name, description, vlan_id)
VALUES($1,$2,$3,$4,$5)""", VALUES($1,$2,$3,$4,$5)""",
nid, nid,
dc_id, 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}", f"{nname} in {dc_name}",
vlan, vlan,
) )
@@ -163,8 +339,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
nid, nid,
nname, nname,
) )
# storage domains
for si, stype in enumerate(storage_types): for si, stype in enumerate(_STORAGE_TYPES[: spec.storage_per_dc]):
sname = f"{dc_key}-{stype}-{si + 1}" sname = f"{dc_key}-{stype}-{si + 1}"
sid = stable_id("sd", sname) sid = stable_id("sd", sname)
storage_domains.append(sid) storage_domains.append(sid)
@@ -201,12 +377,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
clusters[0][0], clusters[0][0],
1024**3, 1024**3,
) )
for tname, mem, cores in ( for tname in spec.templates:
("rhel8-base", 4 * 1024**3, 2), mem, cores = _TEMPLATE_SPECS.get(tname, (2 * 1024**3, 2))
("rhel9-base", 4 * 1024**3, 2),
("win2022-base", 8 * 1024**3, 4),
("ubuntu2204-base", 2 * 1024**3, 2),
):
await conn.execute( await conn.execute(
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores) """INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
VALUES($1,$2,$3,$4,'ok',$5,1,$6)""", VALUES($1,$2,$3,$4,'ok',$5,1,$6)""",
@@ -218,11 +390,9 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
cores, cores,
) )
# ~1000 VMs spread across clusters
statuses = ["up", "up", "up", "down", "down", "suspended", "powering_up"] 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_profile = profiles[0]
default_sd = storage_domains[0]
vm_rows = [] vm_rows = []
disk_rows = [] disk_rows = []
@@ -230,8 +400,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
nic_rows = [] nic_rows = []
snap_rows = [] snap_rows = []
for i in range(DEMO_VM_COUNT): for i in range(spec.vms):
cluster_id, _dc, cname = clusters[i % len(clusters)] cluster_id, _dc, _cname = clusters[i % len(clusters)]
host_id = hosts[i % len(hosts)] if i % 3 != 0 else None host_id = hosts[i % len(hosts)] if i % 3 != 0 else None
status = statuses[i % len(statuses)] status = statuses[i % len(statuses)]
if status == "down": if status == "down":
@@ -273,11 +443,8 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
) )
) )
if i % 7 == 0: if i % 7 == 0:
snap_rows.append( snap_rows.append((stable_id("snap", name, "1"), vm_id, f"snapshot-{name}", "ok"))
(stable_id("snap", name, "1"), vm_id, f"snapshot-{name}", "ok")
)
# Batch insert VMs
await conn.executemany( await conn.executemany(
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status, """INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type, host_id) 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, snap_rows,
) )
# Tags, bookmarks, events, jobs, surface objects tag_step = max(1, spec.vms // 10)
for tname in ("production", "web", "database", "batch", "gpu"): for tname in spec.tags:
tid = stable_id("tag", tname) tid = stable_id("tag", tname)
await conn.execute( await conn.execute(
"INSERT INTO ov_tags(id, name, description) VALUES($1,$2,$3)", "INSERT INTO ov_tags(id, name, description) VALUES($1,$2,$3)",
@@ -315,7 +482,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
tname, tname,
f"Tag {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( await conn.execute(
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id) """INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""", VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
@@ -324,11 +491,14 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
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( await conn.execute(
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'UpVMs','Vms: status=up')", "INSERT INTO ov_bookmarks(id, name, value) VALUES($1,$2,$3)",
stable_id("bm", "UpVMs"), stable_id("bm", bname),
bname,
bvalue,
) )
for i in range(50): for i in range(spec.events):
await conn.execute( await conn.execute(
"""INSERT INTO ov_events(code, severity, description, user_id) """INSERT INTO ov_events(code, severity, description, user_id)
VALUES($1,$2,$3,$4)""", VALUES($1,$2,$3,$4)""",
@@ -337,7 +507,7 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
f"Demo event {i}", f"Demo event {i}",
users["admin"], users["admin"],
) )
for i in range(20): for i in range(spec.jobs):
jid = stable_id("job", str(i)) jid = stable_id("job", str(i))
await conn.execute( await conn.execute(
"""INSERT INTO ov_jobs(id, description, status, owner_id) """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}", f"Step for job {i}",
) )
for collection, names in ( surface: list[tuple[str, tuple[str, ...]]] = [
("instancetypes", ["Tiny", "Small", "Medium", "Large", "XLarge"]), ("instancetypes", spec.instancetypes),
("macpools", ["Default", "Secondary"]), ("macpools", spec.macpools),
("schedulingpolicies", ["evenly_distributed", "power_saving", "vm_evenly_distributed"]), (
("schedulingpolicyunits", ["EvenlyDistributed", "PowerSaving", "VmEvenlyDistributed"]), "schedulingpolicies",
("clusterlevels", ["4.3", "4.4", "4.5"]), ("evenly_distributed", "power_saving", "vm_evenly_distributed"),
("icons", ["default", "custom"]), ),
("operatingsystems", ["rhel_8x64", "rhel_9x64", "windows_2022", "ubuntu_22_04"]), (
("networkfilters", ["vdsm-no-mac-spoofing"]), "schedulingpolicyunits",
("vmpools", ["web-pool", "batch-pool"]), ("EvenlyDistributed", "PowerSaving", "VmEvenlyDistributed"),
("affinitylabels", ["label-a", "label-b"]), ),
("katelloerrata", ["RHSA-2024:0001", "RHBA-2024:0002"]), ("clusterlevels", ("4.3", "4.4", "4.5")),
("externalhostproviders", ["foreman-lab"]), ("icons", spec.icons),
("openstacknetworkproviders", ["ovn-provider"]), ("operatingsystems", spec.operatingsystems),
("openstackimageproviders", ["glance-lab"]), ("networkfilters", ("vdsm-no-mac-spoofing",)),
("openstackvolumeproviders", ["cinder-lab"]), ("vmpools", spec.vmpools),
("imagetransfers", ["transfer-1"]), ("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: for name in names:
await conn.execute( await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data) """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 from app.ovirt.seed_nested import seed_nested_for_inventory
dc_ids = [stable_id("dc", key) for key, *_ in dc_specs] 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 spec.templates]]
template_ids = [ tag_ids = [stable_id("tag", t) for t in spec.tags]
blank_id,
*[
stable_id("template", n)
for n in ("rhel8-base", "rhel9-base", "win2022-base", "ubuntu2204-base")
],
]
tag_ids = [stable_id("tag", t) for t in ("production", "web", "database", "batch", "gpu")]
await seed_nested_for_inventory( await seed_nested_for_inventory(
conn, conn,
admin_user_id=users["admin"], admin_user_id=users["admin"],
role_user_id=role_user, role_user_id=role_user,
datacenter_ids=dc_ids, datacenter_ids=dc_ids,
cluster_ids=cluster_ids, cluster_ids=[c[0] for c in clusters],
host_ids=list(hosts), host_ids=list(hosts),
network_ids=list(networks), network_ids=list(networks),
storage_domain_ids=list(storage_domains), storage_domain_ids=list(storage_domains),
template_ids=template_ids, template_ids=template_ids,
vm_ids=[stable_id("vm", 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, DEMO_VM_COUNT + 1)], disk_ids=[stable_id("disk", f"vm-{i:04d}") for i in range(1, spec.vms + 1)],
tag_ids=tag_ids, tag_ids=tag_ids,
user_ids=list(users.values()), user_ids=list(users.values()),
group_ids=[ group_ids=[
stable_id("group", n) stable_id("group", n)
for n in ("engine-admins", "developers", "operators", "readers") for n in ("engine-admins", *spec.groups)
], ],
) )
await conn.execute( 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 { return {
"profile": DEMO_PROFILE, "profile": spec.name,
"vms": DEMO_VM_COUNT, "vms": spec.vms,
"hosts": len(hosts), "hosts": len(hosts),
"datacenters": len(dc_specs), "datacenters": len(dc_specs),
"clusters": len(clusters), "clusters": len(clusters),
@@ -432,5 +602,13 @@ async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
} }
# Re-export for web routes __all__ = [
__all__ = ["DEMO_PROFILE", "DEMO_VM_COUNT", "clear_ovirt_state", "seed_ovirt", "seed_ovirt_demo"] "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", "DataError",
"InvalidTextRepresentationError", "InvalidTextRepresentationError",
} or "invalid input syntax for type uuid" in detail: } 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 OVirtError("BadRequest", detail or name, status_code=400) from exc
raise raise
@@ -354,6 +366,12 @@ async def _dispatch(
payload, 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 # Fall through to schema/generic object store
from app.ovirt.schema_engine import handle_generic from app.ovirt.schema_engine import handle_generic
@@ -383,13 +401,39 @@ async def _handle_vms(
body = unwrap_entity(payload, "vm") body = unwrap_entity(payload, "vm")
name = str(body.get("name") or f"vm-{uuid4().hex[:8]}") name = str(body.get("name") or f"vm-{uuid4().hex[:8]}")
cluster = body.get("cluster") or {} 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: if not cluster_id:
cluster_id = await conn.fetchval("SELECT id FROM ov_clusters ORDER BY name LIMIT 1") cluster_id = await conn.fetchval("SELECT id FROM ov_clusters ORDER BY name LIMIT 1")
if not cluster_id: if not cluster_id:
raise OVirtError("BadRequest", "cluster is required", status_code=400) raise OVirtError("BadRequest", "cluster is required", status_code=400)
template = body.get("template") or {} 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: if not template_id:
template_id = await conn.fetchval( template_id = await conn.fetchval(
"SELECT id FROM ov_templates WHERE name='Blank' LIMIT 1" "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) raise OVirtError("NotFound", f"VM {vm_id} not found", status_code=404)
if action == "clone": if action == "clone":
body = unwrap_entity(payload, "vm") if payload else {} 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() new_id = uuid4()
await conn.execute( await conn.execute(
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status, """INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
@@ -535,6 +592,7 @@ async def _handle_vms(
row["os_type"], row["os_type"],
row["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( return await respond_action(
request, conn, description=f"Clone VM {row['name']}", owner_id=user_id 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") body = unwrap_entity(payload, "disk_attachment")
disk = body.get("disk") or {} disk = body.get("disk") or {}
disk_id = disk.get("id") if isinstance(disk, dict) else None 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( size = int(
(disk or {}).get("provisioned_size") (disk or {}).get("provisioned_size")
or await option_int(conn, OPT_DEFAULT_DISK_SIZE) or await option_int(conn, OPT_DEFAULT_DISK_SIZE)
@@ -695,6 +771,13 @@ async def _vm_nics(
) )
if len(parts) >= 4: if len(parts) >= 4:
nic_id = parts[3] 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": if len(parts) == 4 and method == "DELETE":
await conn.execute( await conn.execute(
"DELETE FROM ov_nics WHERE id=$1::uuid AND vm_id=$2::uuid", nic_id, vm_id "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, profile_id,
body.get("name"), 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( r = await conn.fetchrow(
"SELECT * FROM ov_nics WHERE id=$1::uuid AND vm_id=$2::uuid", nic_id, vm_id "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)) 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"}: if len(parts) == 5 and method == "POST" and parts[4] in {"activate", "deactivate"}:
plugged = parts[4] == "activate" plugged = parts[4] == "activate"
@@ -763,6 +855,13 @@ async def _vm_snapshots(
return respond( return respond(
request, element="snapshot", data=snapshot_entity(row, vm_id=vm_id), status_code=201 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": if len(parts) == 4 and method == "DELETE":
await conn.execute( await conn.execute(
"DELETE FROM ov_snapshots WHERE id=$1::uuid AND vm_id=$2::uuid", parts[3], vm_id "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] items = [tag_entity(r) for r in rows]
return respond(request, element="tag", collection="tags", data=items) 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": if len(parts) == 3 and method == "POST":
body = unwrap_entity(payload, "tag") body = unwrap_entity(payload, "tag")
tag_id = body.get("id") tag_id = body.get("id")
@@ -1049,16 +1159,25 @@ async def _handle_datacenters(
rows = await conn.fetch( rows = await conn.fetch(
"SELECT * FROM ov_quotas WHERE datacenter_id=$1::uuid ORDER BY name", dc_id "SELECT * FROM ov_quotas WHERE datacenter_id=$1::uuid ORDER BY name", dc_id
) )
items = [ items = [_quota_entity(r, dc_id) for r in rows]
{
"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
]
return respond(request, element="quota", collection="quotas", data=items) 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: if method == "GET" and len(parts) == 4:
r = await conn.fetchrow( r = await conn.fetchrow(
"SELECT * FROM ov_quotas WHERE id=$1::uuid AND datacenter_id=$2::uuid", "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: if r is None:
raise OVirtError("NotFound", "quota not found", status_code=404) raise OVirtError("NotFound", "quota not found", status_code=404)
return respond( return respond(request, element="quota", data=_quota_entity(r, dc_id))
request, if method == "PUT" and len(parts) == 4:
element="quota", body = unwrap_entity(payload, "quota")
data={ await conn.execute(
"id": str(r["id"]), """UPDATE ov_quotas SET name=COALESCE($3,name), description=COALESCE($4,description)
"href": f"/ovirt-engine/api/datacenters/{dc_id}/quotas/{r['id']}", WHERE id=$1::uuid AND datacenter_id=$2::uuid""",
"name": r["name"], parts[3],
"description": r["description"], 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 from app.ovirt.schema_engine import handle_subcollection
if len(parts) >= 3: if len(parts) >= 3:
@@ -1309,8 +1444,6 @@ async def _handle_clusters(
if len(parts) == 2 and method == "DELETE": if len(parts) == 2 and method == "DELETE":
await conn.execute("DELETE FROM ov_clusters WHERE id=$1::uuid", cluster_id) await conn.execute("DELETE FROM ov_clusters WHERE id=$1::uuid", cluster_id)
return Response(status_code=200) 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 len(parts) >= 3 and parts[2] == "affinitygroups":
if method == "GET" and len(parts) == 3: if method == "GET" and len(parts) == 3:
rows = await conn.fetch( rows = await conn.fetch(
@@ -1357,6 +1490,30 @@ async def _handle_clusters(
element="affinity_group", element="affinity_group",
data=_affinity_group_entity(r, cluster_id), 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: if method == "DELETE" and len(parts) == 4:
await conn.execute( await conn.execute(
"DELETE FROM ov_affinity_groups WHERE id=$1::uuid AND cluster_id=$2::uuid", "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) return Response(status_code=200)
if len(parts) >= 3 and parts[2] == "networks": if len(parts) >= 3 and parts[2] == "networks":
return await _cluster_networks(request, conn, method, parts, payload) 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 from app.ovirt.schema_engine import handle_subcollection
if len(parts) >= 3: if len(parts) >= 3:
@@ -1694,12 +1854,27 @@ async def _handle_templates(
tid = uuid4() tid = uuid4()
vm = body.get("vm") or {} vm = body.get("vm") or {}
cluster_id = None cluster_id = None
source_vm = None
if isinstance(vm, dict) and vm.get("id"): 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( 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: if not cluster_id:
cluster_id = await conn.fetchval("SELECT id FROM ov_clusters LIMIT 1") 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( await conn.execute(
"""INSERT INTO ov_templates(id, cluster_id, name, description, memory) """INSERT INTO ov_templates(id, cluster_id, name, description, memory)
VALUES($1,$2,$3,$4,$5)""", VALUES($1,$2,$3,$4,$5)""",
@@ -1707,7 +1882,12 @@ async def _handle_templates(
cluster_id, cluster_id,
str(body.get("name") or f"tpl-{tid.hex[:6]}"), str(body.get("name") or f"tpl-{tid.hex[:6]}"),
str(body.get("description") or ""), 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) row = await conn.fetchrow("SELECT * FROM ov_templates WHERE id=$1", tid)
return respond(request, element="template", data=template_entity(row), status_code=201) return respond(request, element="template", data=template_entity(row), status_code=201)
@@ -2034,13 +2214,16 @@ async def _handle_jobs(
if r is None: if r is None:
raise OVirtError("NotFound", "job not found", status_code=404) raise OVirtError("NotFound", "job not found", status_code=404)
return respond(request, element="job", data=job_entity(r)) 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( 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 = [ items = [
{ {
"id": str(r["id"]), "id": str(r["id"]),
"href": f"/ovirt-engine/api/jobs/{job_id}/steps/{r['id']}",
"description": r["description"], "description": r["description"],
"status": r["status"], "status": r["status"],
"type": r["type"], "type": r["type"],
@@ -2048,6 +2231,25 @@ async def _handle_jobs(
for r in rows for r in rows
] ]
return respond(request, element="step", collection="steps", data=items) 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) 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] request: Request, conn: Connection, method: str, parts: list[str]
) -> Response: ) -> Response:
if len(parts) == 1 and method == "GET": 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( rows = await conn.fetch(
"SELECT * FROM ov_events ORDER BY id DESC LIMIT $1", max_r "SELECT * FROM ov_events ORDER BY id DESC LIMIT $1", max_r
) )
@@ -2080,20 +2282,27 @@ async def _handle_events(
element="event", element="event",
data={ data={
"id": str(r["id"]), "id": str(r["id"]),
"href": f"/ovirt-engine/api/events/{r['id']}",
"code": r["code"], "code": r["code"],
"severity": r["severity"], "severity": r["severity"],
"description": r["description"], "description": r["description"],
"time": r["time"].strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}, },
) )
raise OVirtError("NotFound", "events path", status_code=404) raise OVirtError("NotFound", "events path", status_code=404)
def _row_entity(r: Any, collection: str, fields: list[str]) -> dict[str, Any]: def _quota_entity(r: Any, dc_id: str) -> dict[str, Any]:
item = {"id": str(r["id"]), "href": f"/ovirt-engine/api/{collection}/{r['id']}"} return {
for f in fields: "id": str(r["id"]),
if f in r.keys(): "href": f"/ovirt-engine/api/datacenters/{dc_id}/quotas/{r['id']}",
item[f] = r[f] "name": r["name"],
return item "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]: 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 "", "description": r["description"] or "",
"enforcing": bool(r["enforcing"]), "enforcing": bool(r["enforcing"]),
"positive": bool(r["positive"]), "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]: def _vnic_profile_entity(r: Any) -> dict[str, Any]:
return { return {
"id": str(r["id"]), "id": str(r["id"]),
+74 -20
View File
@@ -41,6 +41,7 @@ _COLLECTIONS: dict[str, tuple[str, str]] = {
"networklabels": ("network_label", "ok"), "networklabels": ("network_label", "ok"),
"cpuprofiles": ("cpu_profile", "ok"), "cpuprofiles": ("cpu_profile", "ok"),
"diskprofiles": ("disk_profile", "ok"), "diskprofiles": ("disk_profile", "ok"),
"diskattachments": ("disk_attachment", "ok"),
"qoss": ("qos", "ok"), "qoss": ("qos", "ok"),
"iscsibonds": ("iscsi_bond", "ok"), "iscsibonds": ("iscsi_bond", "ok"),
"glustervolumes": ("gluster_volume", "ok"), "glustervolumes": ("gluster_volume", "ok"),
@@ -65,6 +66,7 @@ _COLLECTIONS: dict[str, tuple[str, str]] = {
"devices": ("host_device", "ok"), "devices": ("host_device", "ok"),
"sshpublickeys": ("ssh_public_key", "ok"), "sshpublickeys": ("ssh_public_key", "ok"),
"networkfilterparameters": ("network_filter_parameter", "ok"), "networkfilterparameters": ("network_filter_parameter", "ok"),
"storage": ("host_storage", "ok"),
} }
@@ -87,7 +89,10 @@ async def handle_generic(
if len(parts) == 1: if len(parts) == 1:
if method == "GET": if method == "GET":
rows = await conn.fetch( 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] items = [generic_entity(collection, element, r) for r in rows]
return respond(request, element=element, collection=collection, data=items) return respond(request, element=element, collection=collection, data=items)
@@ -111,7 +116,10 @@ async def handle_generic(
if len(parts) == 2: if len(parts) == 2:
oid = parts[1] oid = parts[1]
row = await conn.fetchrow( 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 method == "GET":
if row is None: if row is None:
@@ -134,7 +142,10 @@ async def handle_generic(
return respond(request, element=element, data=generic_entity(collection, element, row)) return respond(request, element=element, data=generic_entity(collection, element, row))
if method == "DELETE": if method == "DELETE":
await conn.execute( 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) return Response(status_code=200)
if len(parts) == 3 and method == "POST": if len(parts) == 3 and method == "POST":
@@ -181,20 +192,11 @@ async def handle_subcollection(
element, _catalog_status = _meta(sub) element, _catalog_status = _meta(sub)
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS) default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
collection_key = sub 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")) 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 def _perm_item(r: Any) -> dict[str, Any]:
FROM ov_permissions p return {
JOIN ov_roles r ON r.id = p.role_id
LEFT JOIN ov_users u ON u.id = p.user_id
WHERE p.object_type=$1 AND p.object_id=$2::uuid
ORDER BY r.name""",
object_type,
parent_id,
)
items = [
{
"id": str(r["id"]), "id": str(r["id"]),
"href": f"/ovirt-engine/api/{parent_collection}/{parent_id}/permissions/{r['id']}", "href": f"/ovirt-engine/api/{parent_collection}/{parent_id}/permissions/{r['id']}",
"role": {"id": str(r["role_id"]), "name": r["role_name"]}, "role": {"id": str(r["role_id"]), "name": r["role_name"]},
@@ -205,11 +207,41 @@ async def handle_subcollection(
else {} else {}
), ),
} }
for r in rows
] if not rest:
return respond(request, element="permission", collection="permissions", data=items) rows = await conn.fetch(
if not rest and method == "GET" and sub == "tags": """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")) object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
if not rest:
rows = await conn.fetch( rows = await conn.fetch(
"""SELECT t.* """SELECT t.*
FROM ov_tag_assignments a FROM ov_tag_assignments a
@@ -229,6 +261,28 @@ async def handle_subcollection(
for r in rows for r in rows
] ]
return respond(request, element="tag", collection="tags", data=items) 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 not rest:
if method == "GET": if method == "GET":
rows = await conn.fetch( 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 from app.security.auth import hash_secret
MINIMAL_PROFILE = "minimal" 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: async def clear_ovirt_state(conn: Connection) -> None:
@@ -239,6 +240,8 @@ async def seed_ovirt(conn: Connection) -> dict[str, Any]:
("instancetypes", "Large"), ("instancetypes", "Large"),
("macpools", "Default"), ("macpools", "Default"),
("schedulingpolicies", "evenly_distributed"), ("schedulingpolicies", "evenly_distributed"),
("schedulingpolicies", "power_saving"),
("schedulingpolicies", "vm_evenly_distributed"),
("schedulingpolicyunits", "EvenlyDistributed"), ("schedulingpolicyunits", "EvenlyDistributed"),
("clusterlevels", "4.5"), ("clusterlevels", "4.5"),
("icons", "default"), ("icons", "default"),
@@ -255,7 +258,8 @@ async def seed_ovirt(conn: Connection) -> dict[str, Any]:
): ):
await conn.execute( await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data) """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), stable_id("obj", collection, name),
collection, collection,
name, name,
@@ -330,10 +334,19 @@ async def seed_ovirt(conn: Connection) -> dict[str, Any]:
async def ovirt_demo_summary(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'") 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 { return {
"profile": profile or MINIMAL_PROFILE, "profile": active,
"loaded": profile == DEMO_PROFILE, "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, "vms": await conn.fetchval("SELECT count(*) FROM ov_vms") or 0,
"hosts": await conn.fetchval("SELECT count(*) FROM ov_hosts") or 0, "hosts": await conn.fetchval("SELECT count(*) FROM ov_hosts") or 0,
"datacenters": await conn.fetchval("SELECT count(*) FROM ov_datacenters") 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 from __future__ import annotations
@@ -9,7 +9,7 @@ import os
import asyncpg 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 from app.ovirt.seed import seed_ovirt
@@ -20,10 +20,14 @@ async def _run(profile: str) -> dict:
) )
conn = await asyncpg.connect(dsn) conn = await asyncpg.connect(dsn)
try: try:
if profile == "demo": if profile == "minimal":
result = await seed_ovirt_demo(conn)
else:
result = await seed_ovirt(conn) 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 return result
finally: finally:
await conn.close() await conn.close()
@@ -31,7 +35,11 @@ async def _run(profile: str) -> dict:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Seed oVirt Engine simulator") 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() args = parser.parse_args()
result = asyncio.run(_run(args.profile)) result = asyncio.run(_run(args.profile))
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
+92
View File
@@ -127,6 +127,19 @@ async def seed_nested_for_inventory(
data={"description": "Gluster feature"}, 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: for host_id in host_ids:
perm("host", host_id, f"host-{host_id}") perm("host", host_id, f"host-{host_id}")
@@ -184,6 +197,24 @@ async def seed_nested_for_inventory(
data={"event_name": "before_vm_start"}, 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: if tag_ids:
tag_rows.append( tag_rows.append(
( (
@@ -196,6 +227,15 @@ async def seed_nested_for_inventory(
for net_id in network_ids: for net_id in network_ids:
perm("network", net_id, f"net-{net_id}") 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: for sd_id in storage_domain_ids:
perm("storage_domain", sd_id, f"sd-{sd_id}") 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"}}, 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: for disk_id in disk_ids:
perm("disk", disk_id, f"disk-{disk_id}") perm("disk", disk_id, f"disk-{disk_id}")
@@ -376,9 +434,43 @@ async def seed_nested_for_inventory(
name="pci_0000_00_02_0", name="pci_0000_00_02_0",
data={"capability": "pci"}, 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 # Scheduling policy children + role permits
for sp_name in ("evenly_distributed", "power_saving", "vm_evenly_distributed"): for sp_name in ("evenly_distributed", "power_saving", "vm_evenly_distributed"):
sp_id = await conn.fetchval( sp_id = await conn.fetchval(
+300 -161
View File
@@ -584,6 +584,124 @@
padding-top: 2px; 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 { .btn-demo-load {
background: var(--brand-accent); background: var(--brand-accent);
border-color: var(--brand-accent); border-color: var(--brand-accent);
@@ -3420,34 +3538,9 @@
<span class="value" id="stat-hosts"></span> <span class="value" id="stat-hosts"></span>
</div> </div>
</div> </div>
<h4 class="help-section-title">oVirt Engine API pack</h4> <p class="help-demo-note" style="margin-top:0.75rem;">
<div class="help-stat-grid"> Engine series packs are switched in <strong>API catalog</strong><strong>Apply as runtime</strong>.
<div class="help-stat-card"> </p>
<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>
</div> </div>
</div> </div>
</aside> </aside>
@@ -3866,16 +3959,6 @@
statStorage: document.getElementById("stat-storage"), statStorage: document.getElementById("stat-storage"),
statHosts: document.getElementById("stat-hosts"), statHosts: document.getElementById("stat-hosts"),
statImpl: document.getElementById("stat-impl"), 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) { function setText(el, value) {
@@ -4848,6 +4931,15 @@
if (hasStoredBody) { if (hasStoredBody) {
els.body.value = isEmptyRequestBody(storedBody) ? "" : storedBody; els.body.value = isEmptyRequestBody(storedBody) ? "" : storedBody;
updateBodyHighlight(); 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) { } else if (canBuildBody) {
syncBodyFromFields({ syncVisibility: false }); syncBodyFromFields({ syncVisibility: false });
} else { } else {
@@ -5198,11 +5290,45 @@
function buildDemoDataHtml(data) { function buildDemoDataHtml(data) {
const loaded = Boolean(data?.loaded); 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 = [ const stats = [
["Profile", data?.profile ?? "—", loaded ? "ok" : ""], ["Profile", profile, loaded ? "ok" : ""],
["VMs", data?.vms ?? "—", ""], ["VMs", data?.vms ?? "—", ""],
["Hosts", data?.hosts ?? "—", ""], ["Hosts", data?.hosts ?? "—", ""],
["Data Centers", data?.datacenters ?? "—", ""], ["DC", data?.datacenters ?? "—", ""],
["Clusters", data?.clusters ?? "—", ""], ["Clusters", data?.clusters ?? "—", ""],
["Networks", data?.networks ?? "—", ""], ["Networks", data?.networks ?? "—", ""],
["Disks", data?.disks ?? "—", ""], ["Disks", data?.disks ?? "—", ""],
@@ -5216,11 +5342,10 @@
<div class="help-demo-panel"> <div class="help-demo-panel">
<div class="help-report-head help-demo-head"> <div class="help-report-head help-demo-head">
<h3>oVirt demo datacenter</h3> <h3>oVirt demo datacenter</h3>
<p>Load a synthetic full oVirt inventory into PostgreSQL for client and UI exploration.</p> <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"}">${loaded ? "Loaded" : "Minimal"}</span> <span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${escapeHtml(badgeLabel)}</span>
</div> </div>
<p class="help-demo-note"> <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. Password for all users: <code>secret</code>. Loading replaces current oVirt state and invalidates active tokens.
</p> </p>
<div class="help-stat-grid"> <div class="help-stat-grid">
@@ -5231,8 +5356,29 @@
</div> </div>
`).join("")} `).join("")}
</div> </div>
<div class="help-demo-actions"> <div class="demo-size-grid" role="group" aria-label="Cluster sizes">
<button class="btn btn-sm btn-demo-load" id="btn-demo-load" type="button">Load demo datacenter</button> ${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 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> <button class="btn btn-sm" id="btn-demo-refresh" type="button">Refresh stats</button>
</div> </div>
@@ -5271,12 +5417,12 @@
renderDemoState(await res.json()); renderDemoState(await res.json());
} }
async function loadDemoData() { async function loadDemoData(size = "large") {
const loadBtn = document.getElementById("btn-demo-load"); const loadBtns = document.querySelectorAll("[data-demo-size]");
if (loadBtn) loadBtn.disabled = true; loadBtns.forEach((btn) => { btn.disabled = true; });
setLoading(true); setLoading(true);
try { 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(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const detail = Array.isArray(body.detail) const detail = Array.isArray(body.detail)
@@ -5293,20 +5439,24 @@
persistAuth(); persistAuth();
} }
await refreshOverview(); 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"); toast("Sign in again (admin@internal / secret) — tokens were reset with the seed", "warn");
} finally { } finally {
if (loadBtn) loadBtn.disabled = false; loadBtns.forEach((btn) => { btn.disabled = false; });
setLoading(false); setLoading(false);
} }
} }
async function unloadDemoData() { async function unloadDemoData() {
const confirmed = await showConfirm({ const confirmed = await showConfirm({
title: "Remove demo data?", title: "Reset to minimal?",
message: "This wipes simulator DB state (demo data and anything created via the API), then reloads a minimal cluster.", 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: "Remove demo data", confirmLabel: "Reset to minimal",
cancelLabel: "Keep demo data", cancelLabel: "Keep current data",
tone: "danger", tone: "danger",
}); });
if (!confirmed) return; if (!confirmed) return;
@@ -5327,7 +5477,7 @@
persistAuth(); persistAuth();
} }
await refreshOverview(); await refreshOverview();
toast("Demo data removed — sign in again if needed", "info"); toast("Minimal cluster loaded — sign in again if needed", "info");
} finally { } finally {
if (unloadBtn) unloadBtn.disabled = false; if (unloadBtn) unloadBtn.disabled = false;
setLoading(false); setLoading(false);
@@ -5704,14 +5854,18 @@
if (!els.dataPanel || els.dataPanel.dataset.demoBound) return; if (!els.dataPanel || els.dataPanel.dataset.demoBound) return;
els.dataPanel.dataset.demoBound = "1"; els.dataPanel.dataset.demoBound = "1";
els.dataPanel.addEventListener("click", (event) => { els.dataPanel.addEventListener("click", (event) => {
const target = event.target.closest("#btn-demo-load, #btn-demo-unload, #btn-demo-refresh"); const loadBtn = event.target.closest("[data-demo-size]");
if (!target) return; if (loadBtn) {
if (target.id === "btn-demo-load") { const size = loadBtn.getAttribute("data-demo-size") || "large";
loadDemoData().catch((error) => { loadDemoData(size).catch((error) => {
showError(String(error)); showError(String(error));
toast("Failed to load demo data", "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) => { unloadDemoData().catch((error) => {
showError(String(error)); showError(String(error));
toast("Failed to remove demo data", "error"); toast("Failed to remove demo data", "error");
@@ -6421,9 +6575,11 @@
try { try {
const parsed = JSON.parse(body || "{}"); const parsed = JSON.parse(body || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const { inner } = unwrapBodyExample(parsed);
for (const field of bodyFields) { for (const field of bodyFields) {
if (Object.prototype.hasOwnProperty.call(parsed, field.name)) { const value = getByPath(inner, field.name);
state.bodyValues[field.name] = String(parsed[field.name] ?? ""); if (value !== undefined && value !== null) {
state.bodyValues[field.name] = String(value);
} }
} }
} }
@@ -6496,31 +6652,95 @@
return target === "cluster" ? buildClusterUrl(apiPath) : buildEmulatorUrl(apiPath); 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 } = {}) { function syncBodyFromFields({ syncVisibility = true } = {}) {
if (!state.method) return; if (!state.method) return;
const inputs = [...(els.bodyFields?.querySelectorAll("input[data-field]") || [])]; 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) => { inputs.forEach((input) => {
const name = input.dataset.field; const name = input.dataset.field;
if (!name) return;
const raw = input.value.trim(); const raw = input.value.trim();
if (!raw) return; const existing = getByPath(root, name);
if (raw === "true" || raw === "false") body[name] = raw === "true"; if (!raw && existing === undefined) return;
else if (/^-?\d+$/.test(raw)) body[name] = Number(raw); touched = true;
else if (/^-?\d+\.\d+$/.test(raw)) body[name] = Number(raw); if (!raw) {
else body[name] = 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 // Empty PARAM inputs → keep full body_example; edits merge into the Engine root wrap.
// updates when switching verbs / endpoints. if (!touched && example && typeof example === "object" && !Array.isArray(example)) {
if (!Object.keys(body).length) { els.body.value = Object.keys(example).length ? JSON.stringify(example, null, 2) : "";
const example = state.method.body_example; updateBodyHighlight();
if (example && typeof example === "object" && !Array.isArray(example)) { if (syncVisibility) updateBodyPaneVisibility(state.method);
for (const [key, value] of Object.entries(example)) { return;
if (value === undefined || value === null || value === "") continue;
body[key] = value;
} }
} const body = wrapKey ? { [wrapKey]: root } : root;
}
// Keep empty object out of the editor — do not show `{}`.
els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : ""; els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : "";
updateBodyHighlight(); updateBodyHighlight();
if (syncVisibility) { if (syncVisibility) {
@@ -6748,7 +6968,6 @@
await loadVersions(); await loadVersions();
await loadCatalog({ major, preserveSelection: true, silent: true }); await loadCatalog({ major, preserveSelection: true, silent: true });
await refreshOverview(); await refreshOverview();
await refreshoVirtPack();
toast(`Runtime switched to ${state.runtimeVersion || seriesLabel(major)}`, "ok"); toast(`Runtime switched to ${state.runtimeVersion || seriesLabel(major)}`, "ok");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -6770,84 +6989,6 @@
applyMethodDetails(payload); 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() { async function refreshOverview() {
try { try {
setText( setText(
@@ -6856,7 +6997,6 @@
); );
await refreshClusterStats(); await refreshClusterStats();
await refreshCatalogCoverage(); await refreshCatalogCoverage();
await refreshoVirtPack();
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@@ -7142,7 +7282,6 @@
bindCatalogOptions(); bindCatalogOptions();
bindHelpNav(); bindHelpNav();
bindDataPanelActions(); bindDataPanelActions();
bindoVirtPackActions();
bindModal(); bindModal();
bindTooltips(); bindTooltips();
bindEndpointTree(); 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, load_series_pack,
series_for_major, 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"\{([^{}]+)\}") _PATH_PARAM = re.compile(r"\{([^{}]+)\}")
# Prefer minimal-seed UUIDs so console Try-it requests resolve against `make seed`.
_PATH_PARAM_EXAMPLES: dict[str, object] = { _PATH_PARAM_EXAMPLES: dict[str, object] = {
"vm": "vm-001", "vm": "lab-vm-01",
"vmId": "00000000-0000-0000-0000-000000000001", "vm_id": str(stable_id("vm", "lab-vm-01")),
"host": "host-01", "vmId": str(stable_id("vm", "lab-vm-01")),
"hostId": "00000000-0000-0000-0000-000000000011", "host": "host01",
"host_id": str(stable_id("host", "host01")),
"hostId": str(stable_id("host", "host01")),
"cluster": "Default", "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", "dataCenter": "Default",
"dataCenterId": "00000000-0000-0000-0000-000000000031", "dataCenterId": str(stable_id("dc", "Default")),
"disk": "disk-001", "disk": "lab-vm-01-disk",
"diskId": "00000000-0000-0000-0000-000000000041", "disk_id": str(stable_id("disk", "lab-vm-01")),
"diskId": str(stable_id("disk", "lab-vm-01")),
"network": "ovirtmgmt", "network": "ovirtmgmt",
"networkId": "00000000-0000-0000-0000-000000000051", "network_id": str(stable_id("net", "ovirtmgmt")),
"storageDomain": "data", "networkId": str(stable_id("net", "ovirtmgmt")),
"storageDomainId": "00000000-0000-0000-0000-000000000061", "storagedomain_id": str(stable_id("sd", "data1")),
"storageDomain": "data1",
"storageDomainId": str(stable_id("sd", "data1")),
"template": "Blank", "template": "Blank",
"templateId": "00000000-0000-0000-0000-000000000071", "template_id": str(stable_id("template", "Blank")),
"templateId": str(stable_id("template", "Blank")),
"user": "admin@internal", "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", "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) 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 = { _SERIES_LABELS = {
"3.0": "Engine 3.0", "3.0": "Engine 3.0",
"3.1": "Engine 3.1", "3.1": "Engine 3.1",
@@ -146,18 +217,16 @@ def ovirt_method_payload(
} }
for name in path_params for name in path_params
] ]
body_fields: list[dict[str, Any]] = [] # Full Engine-shaped JSON lives in body_example (root-wrapped entity / action).
if op.method in {"POST", "PUT"} and op.kind in {"collection", "item", "action"}: # PARAM drawer flattens nested scalars from that example (dotted paths).
body_fields.append( body_example = body_example_for(
{ method=op.method,
"name": op.element, kind=op.kind,
"type": "object", element=op.element,
"description": f"{op.element} payload (XML or JSON)", path=op.path,
"optional": op.kind == "action",
"enum": [],
"example": {op.element: {"name": "example"}},
}
) )
# 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 = [] query_fields = []
if op.search: if op.search:
query_fields.extend( query_fields.extend(
@@ -200,6 +269,7 @@ def ovirt_method_payload(
"path_fields": path_fields, "path_fields": path_fields,
"query_fields": query_fields, "query_fields": query_fields,
"body_fields": body_fields, "body_fields": body_fields,
"body_example": body_example,
"runtime_version": runtime_version, "runtime_version": runtime_version,
} }
return { return {
@@ -214,6 +284,7 @@ def ovirt_method_payload(
"path_fields": [], "path_fields": [],
"query_fields": [], "query_fields": [],
"body_fields": [], "body_fields": [],
"body_example": None,
"runtime_version": runtime_version, "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.db.pool import AsyncpgDatabase
from app.dependencies import get_database 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.ovirt.seed import clear_ovirt_state, ovirt_demo_summary, seed_ovirt
from app.web.assets import console_html 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) @router.post("/ui/api/demo/load", include_in_schema=False)
async def ui_demo_load(request: Request) -> JSONResponse: 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) pool = _database_pool(request)
try: try:
async with pool.acquire() as connection: async with pool.acquire() as connection:
async with connection.transaction(): async with connection.transaction():
result = await seed_ovirt_demo(connection) result = await seed_ovirt_demo(connection, size=size)
summary = await ovirt_demo_summary(connection) summary = await ovirt_demo_summary(connection)
except Exception as error: except Exception as error:
raise HTTPException( 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 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 `/health/ready` returns HTTP 503 until PostgreSQL is reachable **and** the
latest packaged migration is applied. 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 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 ```bash
curl -s http://127.0.0.1:5000/ui/api/ovirt/contracts/activate \ 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 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 недоступен **или** не `/health/ready` возвращает HTTP 503, пока PostgreSQL недоступен **или** не
применена последняя упакованная миграция. применена последняя упакованная миграция.
+1 -1
View File
@@ -46,7 +46,7 @@ make seed-demo
OVIRT_SERIES=4.4 make restart 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 ```bash
curl -s http://127.0.0.1:5000/ui/api/ovirt/contracts/activate \ 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 инвентаря | | `minimal` | Старт (если БД не sized-demo) / `make seed` / `--profile minimal` | 1 DC, 1 cluster, 1 host, Blank, 4 пользователя |
| `demo` | `make seed-demo` / ящик Data в UI / Helm `seed.profile=demo` / `--profile demo` | ~1000 ВМ, multi-host DC, сети, storage domains, диски, nested samples | | `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`**, если БД пуста или не Sized-demo масштабируют DC, clusters, hosts, VMs, сети, storage domains,
помечена как `demo`. Для большого профиля — `make seed-demo` (или ящик Data в templates, tags, events, jobs и nested samples вместе. Lifespan сохраняет
UI). Helm дополнительно может запускать seed Job (`seed.enabled`). `small` / `large` / `big` / `demo` при рестарте.
Пароль для всех пользователей: **`secret`**. Домен: **`internal`**. Пароль всех пользователей: **`secret`**. Домен: **`internal`**.
Principals: `admin@internal`, `ops@internal`, `developer@internal`,
`demo@internal`.
## CLI ## CLI
```bash ```bash
make seed make seed
make seed-demo make seed-small
make seed-large # или: make seed-demo
# эквивалент make seed-big
docker compose run --rm --entrypoint python simulator \
-m app.ovirt.seed_cli --profile demo
``` ```
## UI
Ящик **DATA****Load small** / **Load large** / **Load big**, либо
**Reset to minimal**.
API: `POST /ui/api/demo/load?size=small|large|big`
## Helm ## Helm
```yaml ```yaml
seed: seed:
enabled: true 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 # 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 | | Выбор endpoint | Поиск по путям контракта (series pack Engine) |
| API catalog | Обзор операций контракта активного series | | Вкладки методов | `GET` / `POST` / `PUT` / `DELETE` для выбранного пути |
| Coverage | Сводка покрытия pack / handlers | | Request body | JSON-пример в форме Engine для create/update/action |
| Help | Краткие заметки оператора | | Params | Плоские path/body-поля для быстрых правок |
| Data | Reseed `minimal` / `demo` | | Response | Статус + JSON-дерево последнего ответа |
| Environment | Активный series, runtime-подсказки, hot-swap **Apply pack** | | 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 ## 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/ovirt/contracts/activate` с `{"series":"4.4"}`
- `POST /ui/api/contract/apply?major=N` - `POST /ui/api/contract/apply?major=N`
Перемонтирует in-memory контрактные маршруты без пересборки образа. Рестарт Перемонтирует in-memory контрактные маршруты без пересборки образа. Рестарт
процесса возвращает cold-start значение `OVIRT_SERIES`. См. процесса возвращает cold-start `OVIRT_SERIES`. См. [Версии API](api-versions.md).
[Версии API](api-versions.md).
Брендинг: oVirt blue `#0076B6` и charcoal `#1D2226`. ## Заметки
UI обращается к тому же процессу симулятора, что и Engine API; отличается только - Брендинг: oVirt blue `#0076B6` и charcoal `#1D2226`.
опубликованный listener ([ports.md](ports.md)). Схема OpenAPI: - UI ходит в тот же процесс симулятора, что и Engine API; отличается только
[http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs) (также на порту Engine). опубликованный 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 | | 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 | | `minimal` | Startup (if DB is not a sized demo) / `make seed` / `--profile minimal` | 1 DC, 1 cluster, 1 host, Blank template, 4 users |
| `demo` | `make seed-demo` / UI Data drawer / Helm `seed.profile=demo` / `--profile demo` | ~1000 VMs, multi-host DC, networks, storage domains, disks, nested samples | | `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 Sized demos scale datacenters, clusters, hosts, VMs, networks, storage domains,
is empty or not marked as `demo`. Use `make seed-demo` (or the UI Data drawer) templates, tags, events, jobs, and nested samples together. On Compose, lifespan
for the large profile. Helm can also run a seed Job (`seed.enabled`). keeps any of `small` / `large` / `big` / `demo` across restarts (does not wipe to
minimal).
Password for all users: **`secret`**. Domain: **`internal`**. Password for all users: **`secret`**. Domain: **`internal`**.
@@ -20,22 +24,30 @@ Principals: `admin@internal`, `ops@internal`, `developer@internal`,
```bash ```bash
make seed 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 \ 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 ## Helm
```yaml ```yaml
seed: seed:
enabled: true enabled: true
profile: demo # or minimal profile: large # minimal | small | large | big | demo
``` ```
## Behaviour ## Behaviour
Both profiles **truncate** oVirt lab tables then reload. Prefer `demo` for All profiles **truncate** oVirt lab tables then reload. Prefer `large`/`big` for
density and nested GET probes; `minimal` for fast CI. density; `minimal` / `small` for fast CI and light labs.
+56 -13
View File
@@ -2,22 +2,63 @@
# Web UI # 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 | | Area | Purpose |
|---|---| |---|---|
| Auth | Issue lab tokens / show principal | | Endpoint picker | Searchable catalog of contract paths (Engine series pack) |
| API catalog | Browse contract operations for the active series | | Method tabs | `GET` / `POST` / `PUT` / `DELETE` for the selected path |
| Coverage | Pack / handler coverage summary | | Request body | Engine-shaped JSON sample for create/update/action |
| Help | Short operator notes | | Params | Flattened path + body fields for quick edits |
| Data | Reseed `minimal` / `demo` | | Response | Status + JSON tree for the last call |
| Environment | Active series, runtime hints, **Apply pack** hot-swap | | 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 ## 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/ovirt/contracts/activate` with `{"series":"4.4"}`
- `POST /ui/api/contract/apply?major=N` - `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 process restart restores the cold-start `OVIRT_SERIES` value. See
[API versions](api-versions.md). [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 - Branding uses oVirt blue `#0076B6` and charcoal `#1D2226`.
listener differs ([ports.md](ports.md)). OpenAPI schema: - The UI talks to the same simulator process as the Engine API; only the
[http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs) (also on Engine port). 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 # oVirt Pulumi contract-coverage lab
Pulumi Automation API suite that exercises **every operation** declared in **100% coverage** here means the **HTTP contract matrix**: every operation
`contracts/ovirt/<series>/api.json` across **all Engine series packs**, plus a declared in `contracts/ovirt/<series>/api.json` for **all Engine series packs**,
**synthetic HEAD** request for each GET path (contracts omit HEAD; the Engine plus a **synthetic HEAD** for each GET path (contracts omit HEAD; the Engine
accepts it). accepts it). It is **not** a count of Pulumi provider resources.
| Series | Ops (approx.) | | 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) 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/`): Reports (written under `reports/`):
- `pulumi-contract-coverage.html` — human-readable summary (includes methods histogram) - `pulumi-contract-coverage.html` — human-readable summary (method histogram
- `pulumi-contract-coverage.json` — machine-readable results 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: Optional filters:
@@ -29,11 +41,14 @@ OVIRT_METHODS_FILTER=GET make pulumi-tests
SMOKE_ONLY=1 make test-pulumi-smoke 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`, - 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 `400`/`403`/`404`/`405`/`409`/`415`/`422`/`501`) — **not** `401` (the suite
re-authenticates after each series unload) and not a transport/`5xx` failure. 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). - 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 - Successful **collection** GETs must return a **non-empty** list with `id`
missing methods fail the suite. (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 # Лаборатория Pulumi: покрытие контрактов oVirt
Suite на Pulumi Automation API, который вызывает **каждую операцию** из **100% coverage** здесь — это **HTTP contract matrix**: каждая операция из
`contracts/ovirt/<series>/api.json` для **всех series packs** Engine, плюс `contracts/ovirt/<series>/api.json` для **всех series packs** Engine, плюс
**синтетический HEAD** для каждого GET-пути (в contracts нет HEAD; Engine его **синтетический HEAD** для каждого GET-пути (в contracts нет HEAD; Engine его
принимает). принимает). Это **не** число ресурсов Pulumi provider.
| Series | Операций (примерно) | | Series | Операций (примерно) |
|--------|--------------------:| |--------|--------------------:|
@@ -16,10 +16,22 @@ make test-pulumi-smoke # выборка 3.6 + 4.5 (быстро)
make pulumi-tests # полная матрица (alias: make test-pulumi) make pulumi-tests # полная матрица (alias: make test-pulumi)
``` ```
Layer B (опционально): только smoke lifecycle провайдера — не выдавать за
полноту API.
Отчёты (в `reports/`): Отчёты (в `reports/`):
- `pulumi-contract-coverage.html` — сводка для человека (включая гистограмму методов) - `pulumi-contract-coverage.html` — сводка (гистограмма методов:
- `pulumi-contract-coverage.json` — машиночитаемый результат 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 SMOKE_ONLY=1 make test-pulumi-smoke
``` ```
Все suites — **только в Docker**. Критерии pass: Все suites — **только в Docker** (lab compose сидит **minimal** seed).
Критерии pass:
- Маршрут Engine достижим и возвращает обработанный статус (`200`/`201`/`202`/`204`, - Маршрут Engine достижим и возвращает обработанный статус (`200`/`201`/`202`/`204`,
`400`/`403`/`404`/`405`/`409`/`415`/`422`/`501`) — **не** `401` (после unload `400`/`403`/`404`/`405`/`409`/`415`/`422`/`501`) — **не** `401` (после unload
каждой series suite заново логинится) и не транспортную / `5xx` ошибку. каждой series suite заново логинится) и не транспортную / `5xx` ошибку.
- Для `200`/`201`/`202` тело ответа должно быть непустым (HEAD исключён). - Для `200`/`201`/`202` тело ответа должно быть непустым (HEAD исключён).
- Полный прогон должен покрыть **GET, POST, PUT, DELETE и HEAD**; любые failures - Успешные **collection** GET должны возвращать **непустой** список с `id`
или отсутствующие методы валят suite. (пустой `[]` — дыра 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: depends_on:
simulator: simulator:
condition: service_healthy condition: service_healthy
entrypoint: ["python", "-m", "app.ovirt.seed_cli", "--profile", "demo"] entrypoint: ["python", "-m", "app.ovirt.seed_cli", "--profile", "minimal"]
api-gateway: api-gateway:
image: nginx:1.28.0-alpine 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 == {} 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.""" """Require non-empty response data for successful body-bearing statuses."""
# HEAD has no body; DELETE often returns 200 with an empty body (Engine-style). # HEAD has no body; DELETE often returns 200 with an empty body (Engine-style).
if method in {"HEAD", "DELETE"}: if method in {"HEAD", "DELETE"}:
@@ -61,10 +61,17 @@ def _payload_nonempty(response: Any, *, method: str) -> tuple[bool, str]:
return False, "null JSON body" return False, "null JSON body"
if isinstance(data, (list, dict)) and len(data) == 0: if isinstance(data, (list, dict)) and len(data) == 0:
return False, "empty JSON body" 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()): 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 False, "JSON body has only empty fields"
return True, "" return True, ""
@@ -146,7 +153,47 @@ def synthesize_head_ops(ops: list[dict[str, Any]]) -> list[dict[str, Any]]:
class Inventory: 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: def __init__(self, client: OVirtClient, version: str) -> None:
self.client = client self.client = client
@@ -154,8 +201,12 @@ class Inventory:
self._ids: dict[str, str] = {} self._ids: dict[str, str] = {}
self._listed: set[str] = set() 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("/") collection = collection.strip("/")
if method in {"DELETE", "PUT"}:
created = self.create_disposable(collection)
if created:
return created
if collection in self._ids: if collection in self._ids:
return self._ids[collection] return self._ids[collection]
if collection in self._listed: if collection in self._listed:
@@ -172,7 +223,6 @@ class Inventory:
body = r.json() body = r.json()
except Exception: except Exception:
return None return None
# Engine collections are usually { "<singular_or_plural>": [ {...}, ... ] }
for value in body.values() if isinstance(body, dict) else []: for value in body.values() if isinstance(body, dict) else []:
if isinstance(value, list) and value: if isinstance(value, list) and value:
first = value[0] first = value[0]
@@ -184,6 +234,129 @@ class Inventory:
return self._ids[collection] return self._ids[collection]
return None 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: 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 # /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 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.""" """Return resolved path and whether every placeholder was satisfied from inventory."""
parts = template.strip("/").split("/") parts = template.strip("/").split("/")
param_indices = [i for i, part in enumerate(parts) if _PATH_PARAM.fullmatch(part)]
resolved: list[str] = [] resolved: list[str] = []
complete = True complete = True
for i, part in enumerate(parts): for i, part in enumerate(parts):
@@ -207,7 +381,29 @@ def resolve_path(template: str, inventory: Inventory) -> tuple[str, bool]:
resolved.append(part) resolved.append(part)
continue continue
collection = _collection_before_param(parts, i) 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: if entity_id:
resolved.append(entity_id) resolved.append(entity_id)
else: 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( expected = int(op.get("create_status") or op.get("status_code") or 200) if method == "POST" else int(
op.get("status_code") or 200 op.get("status_code") or 200
) )
path, _complete = resolve_path(template, inventory) path, _complete = resolve_path(template, inventory, method=method)
body = _minimal_body(op) body = _minimal_body(op)
started = time.perf_counter() started = time.perf_counter()
try: try:
@@ -254,7 +450,9 @@ def execute_operation(
ok = response.status_code in _PASS_STATUSES ok = response.status_code in _PASS_STATUSES
detail = "" detail = ""
if ok: 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: if not body_ok:
ok = False ok = False
detail = body_detail detail = body_detail
@@ -400,11 +598,32 @@ def run_coverage(cfg: SuiteConfig) -> CoverageReport:
def report_to_dict(report: CoverageReport) -> dict[str, Any]: 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 { return {
"generated_at": report.generated_at, "generated_at": report.generated_at,
"engine_url": report.engine_url, "engine_url": report.engine_url,
"totals": report.totals, "totals": totals,
"methods": report.methods, "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], "series": [asdict(s) for s in report.series],
"results": [asdict(r) for r in report.results], "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')))} · Engine {html.escape(str(payload.get('engine_url')))}
</div> </div>
<div class="cards"> <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 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 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">Skipped</div><div class="value">{totals.get('skipped', 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> </div>
<h2>By HTTP method</h2> <h2>By HTTP method</h2>
+9 -1
View File
@@ -52,6 +52,7 @@ def main() -> int:
"passed": totals["passed"], "passed": totals["passed"],
"failed": totals["failed"], "failed": totals["failed"],
"skipped": totals["skipped"], "skipped": totals["skipped"],
"coverage": payload.get("coverage"),
"methods": methods, "methods": methods,
"series": series_names, "series": series_names,
"report_json": str(json_path), "report_json": str(json_path),
@@ -86,7 +87,12 @@ def main() -> int:
failed = int(outputs.get("failed") or totals["failed"]) failed = int(outputs.get("failed") or totals["failed"])
total = int(outputs.get("total") or totals["total"]) total = int(outputs.get("total") or totals["total"])
passed = int(outputs.get("passed") or totals["passed"]) 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"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"METHODS {json.dumps(methods, sort_keys=True)}", flush=True)
print(f"HTML report: {html_path}", 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 [] missing_methods = sorted(_FULL_RUN_METHODS - set(methods)) if full_run else []
if missing_methods: if missing_methods:
print(f"MISSING METHODS on full run: {', '.join(missing_methods)}", flush=True) 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 1
return 0 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, "passed": 9150,
"failed": 0, "failed": 0,
"skipped": 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": { "methods": {
"DELETE": 1146, "DELETE": 1146,
"GET": 2314, "GET": 2314,
+31 -8
View File
@@ -9,18 +9,41 @@ import requests
def discover_base_url() -> str: def discover_base_url() -> str:
for candidate in ( """Prefer OVIRT_TEST_URL / OVIRT_ENGINE_PORT; require Engine SSO path (not a foreign :443)."""
os.environ.get("OVIRT_TEST_URL"),
"https://api-gateway", 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",
"https://127.0.0.1:9443", "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 continue
seen.add(candidate)
try: try:
r = requests.get(f"{candidate.rstrip('/')}/health/live", timeout=3, verify=False) live = requests.get(f"{candidate}/health/live", timeout=3, verify=False)
if r.status_code == 200: if live.status_code != 200:
return candidate.rstrip("/") 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: except Exception:
continue continue
pytest.skip("no running oVirt simulator gateway") 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())